@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { MIN_HMAC_SECRET_LENGTH } from "./constants";
4
4
  import type { AuthMailLocale } from "./email-templates";
5
5
  import { changePasswordWrite } from "./handlers/change-password.write";
6
+ import { createConfirmAccountUnlockHandler } from "./handlers/confirm-account-unlock.write";
6
7
  import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
7
8
  import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write";
8
9
  import {
@@ -12,6 +13,10 @@ import {
12
13
  import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write";
13
14
  import { createLoginHandler, type LoginHandlerOptions } from "./handlers/login.write";
14
15
  import { logoutWrite } from "./handlers/logout.write";
16
+ import {
17
+ createRequestAccountUnlockHandler,
18
+ type RequestAccountUnlockOptions,
19
+ } from "./handlers/request-account-unlock.write";
15
20
  import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
16
21
  import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
17
22
  import { createResetPasswordHandler } from "./handlers/reset-password.write";
@@ -109,6 +114,15 @@ export type SignupOptions = SignupRequestOptions;
109
114
  // 7 Tage (industry standard).
110
115
  export type InviteOptions = InviteCreateOptions;
111
116
 
117
+ // Account-Unlock Magic-Link (#1266). When set, registers the
118
+ // request-account-unlock + confirm-account-unlock handlers — the
119
+ // self-service escape hatch for the monotonic lockout counter (see
120
+ // lockout-store.ts). Only meaningful paired with `accountLockout`, but
121
+ // wired independently like passwordReset/emailVerification — no implicit
122
+ // auto-enable, so apps without lockout don't get an unasked-for new
123
+ // public endpoint.
124
+ export type AccountUnlockOptions = RequestAccountUnlockOptions;
125
+
112
126
  export type AuthEmailPasswordOptions = {
113
127
  // Second-factor gate. auth-mfa (if mounted) wires this in via
114
128
  // mfaStatusCheckerFromFeature() at app-composition time — see
@@ -119,6 +133,7 @@ export type AuthEmailPasswordOptions = {
119
133
  readonly accountLockout?: AccountLockoutOptions;
120
134
  readonly signup?: SignupOptions;
121
135
  readonly invite?: InviteOptions;
136
+ readonly accountUnlock?: AccountUnlockOptions;
122
137
  };
123
138
 
124
139
  // Auth feature — email+password login. Depends on the user feature for
@@ -140,13 +155,18 @@ export function createAuthEmailPasswordFeature(
140
155
  `[auth-email-password] emailVerification.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
141
156
  );
142
157
  }
158
+ if (opts.accountUnlock && opts.accountUnlock.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
159
+ throw new Error(
160
+ `[auth-email-password] accountUnlock.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
161
+ );
162
+ }
143
163
 
144
164
  const strictVerification =
145
165
  opts.emailVerification !== undefined && (opts.emailVerification.mode ?? "strict") === "strict";
146
166
 
147
167
  return defineFeature("auth-email-password", (r) => {
148
168
  r.describe(
149
- "Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. All four magic-link flows (reset, verification, signup activation, tenant invite) dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify) or opaque-random in Redis (signup/invite). Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
169
+ "Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows — password reset, email verification, magic-link self-signup, tenant invite, and account-unlock — are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`, `accountUnlock`) to `createAuthEmailPasswordFeature(opts)`. All five magic-link flows dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify/unlock) or opaque-random in Redis (signup/invite). `accountUnlock` is the self-service escape hatch for `accountLockout`'s monotonic failure-counter (#1266): confirming the mailed token clears the Redis lockout state without touching the user entity. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (≥ 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
150
170
  );
151
171
  r.uiHints({
152
172
  displayLabel: "Auth \u00b7 Email + Password",
@@ -162,14 +182,21 @@ export function createAuthEmailPasswordFeature(
162
182
  },
163
183
  { key: "signup", label: "Self-Signup-Flow", type: "boolean", default: false },
164
184
  { key: "invite", label: "Tenant-Invite-Flow", type: "boolean", default: false },
185
+ { key: "accountUnlock", label: "Account-Unlock-Flow", type: "boolean", default: false },
165
186
  ],
166
187
  });
167
188
  r.requires("user");
168
189
  r.requires("tenant");
169
- // All four magic-link flows (reset/verify/signup/invite) dispatch via
170
- // ctx.notify → delivery must be mounted. Fail closed at boot instead of
171
- // silently dropping the mail.
172
- if (opts.passwordReset || opts.emailVerification || opts.signup || opts.invite) {
190
+ // All five magic-link flows (reset/verify/signup/invite/unlock) dispatch
191
+ // via ctx.notify → delivery must be mounted. Fail closed at boot instead
192
+ // of silently dropping the mail.
193
+ if (
194
+ opts.passwordReset ||
195
+ opts.emailVerification ||
196
+ opts.signup ||
197
+ opts.invite ||
198
+ opts.accountUnlock
199
+ ) {
173
200
  r.requires("delivery");
174
201
  }
175
202
  r.envSchema(authEmailPasswordEnvSchema);
@@ -208,6 +235,11 @@ export function createAuthEmailPasswordFeature(
208
235
  r.writeHandler(createInviteSignupCompleteHandler());
209
236
  }
210
237
 
238
+ if (opts.accountUnlock) {
239
+ r.writeHandler(createRequestAccountUnlockHandler(opts.accountUnlock));
240
+ r.writeHandler(createConfirmAccountUnlockHandler(opts.accountUnlock));
241
+ }
242
+
211
243
  return { handlers };
212
244
  });
213
245
  }
@@ -8,6 +8,7 @@ import { z } from "zod";
8
8
  import { hashPassword, verifyPassword } from "../../shared";
9
9
  import { UserHandlers, UserQueries } from "../../user";
10
10
  import { invalidCredentials } from "../errors";
11
+ import { passwordSchema } from "../password-policy";
11
12
 
12
13
  // Change-password — authenticated. The user supplies their current password
13
14
  // (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
@@ -17,7 +18,7 @@ export const changePasswordWrite = defineWriteHandler({
17
18
  name: "change-password",
18
19
  schema: z.object({
19
20
  oldPassword: z.string().min(1),
20
- newPassword: z.string().min(8).max(200),
21
+ newPassword: passwordSchema,
21
22
  }),
22
23
  access: { roles: access.authenticated },
23
24
  handler: async (event, ctx) => {
@@ -0,0 +1,59 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ InternalError,
4
+ UnprocessableError,
5
+ writeFailure,
6
+ } from "@cosmicdrift/kumiko-framework/errors";
7
+ import { z } from "zod";
8
+ import { AuthErrors } from "../constants";
9
+ import { invalidUnlockToken } from "../errors";
10
+ import { clearLockoutState } from "../lockout-store";
11
+ import { verifyUnlockToken } from "../unlock-token";
12
+
13
+ export type ConfirmAccountUnlockOptions = {
14
+ readonly hmacSecret: string;
15
+ };
16
+
17
+ // Confirm step of the unlock flow. Unlike reset/verify, there's no entity
18
+ // write: the token's HMAC signature already proves it was minted for an
19
+ // existing, non-deleted user (see request-account-unlock.write.ts's
20
+ // findForAuth + extraSilentSkip), so there's no user to (re-)load or
21
+ // validate here. Clearing the Redis lockout state is naturally idempotent
22
+ // (DEL on an already-cleared/missing key is a no-op), so a replayed unlock
23
+ // link within its TTL just re-clears — harmless, unlike reset which sets a
24
+ // password. That's why this skips the single-use burn-store confirm-token-
25
+ // flow.ts needs to protect a state-changing write.
26
+ export function createConfirmAccountUnlockHandler(opts: ConfirmAccountUnlockOptions) {
27
+ return defineWriteHandler({
28
+ name: "confirm-account-unlock",
29
+ schema: z.object({
30
+ token: z.string().min(1),
31
+ }),
32
+ access: { roles: ["all"] },
33
+ handler: async (event, ctx) => {
34
+ if (!opts.hmacSecret) {
35
+ return writeFailure(
36
+ new UnprocessableError(AuthErrors.unlockNotConfigured, {
37
+ i18nKey: "auth.errors.unlockNotConfigured",
38
+ }),
39
+ );
40
+ }
41
+ if (!ctx.redis) {
42
+ return writeFailure(
43
+ new InternalError({
44
+ message: "account-unlock requires ctx.redis to clear lockout state",
45
+ }),
46
+ );
47
+ }
48
+
49
+ // All verify failures (malformed / bad_signature / expired) fold into
50
+ // the same invalid_unlock_token error — a probing caller can't
51
+ // distinguish tampered from stale from random-string.
52
+ const verify = verifyUnlockToken(event.payload.token, opts.hmacSecret);
53
+ if (!verify.ok) return invalidUnlockToken();
54
+
55
+ await clearLockoutState(ctx.redis, verify.userId);
56
+ return { isSuccess: true, data: { kind: "account-unlocked" as const } };
57
+ },
58
+ });
59
+ }
@@ -108,6 +108,7 @@ export function createInviteAcceptWithLoginHandler() {
108
108
  const invitationTenantId = invitation.tenantId;
109
109
  const invitationEmail = await decryptStoredPii(
110
110
  invitation.email,
111
+ "email",
111
112
  "auth:invite-accept-with-login",
112
113
  );
113
114
  const invitationRole = invitation.role;
@@ -101,7 +101,11 @@ export function createInviteAcceptHandler() {
101
101
  return invalidInviteToken();
102
102
 
103
103
  const invitationTenantId = invitation.tenantId;
104
- const invitationEmail = await decryptStoredPii(invitation.email, "auth:invite-accept");
104
+ const invitationEmail = await decryptStoredPii(
105
+ invitation.email,
106
+ "email",
107
+ "auth:invite-accept",
108
+ );
105
109
  const invitationRole = invitation.role;
106
110
  const invitationVersion = invitation.version;
107
111
 
@@ -112,7 +116,7 @@ export function createInviteAcceptHandler() {
112
116
  id: event.user.id,
113
117
  });
114
118
  const userEmail = userRow?.email
115
- ? await decryptStoredPii(userRow.email, "auth:invite-accept")
119
+ ? await decryptStoredPii(userRow.email, "email", "auth:invite-accept")
116
120
  : undefined;
117
121
  if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
118
122
  return inviteEmailMismatch();
@@ -112,6 +112,7 @@ export function createInviteSignupCompleteHandler() {
112
112
  const invitationTenantId = invitation.tenantId;
113
113
  const invitationEmail = await decryptStoredPii(
114
114
  invitation.email,
115
+ "email",
115
116
  "auth:invite-signup-complete",
116
117
  );
117
118
  const invitationRole = invitation.role;
@@ -0,0 +1,38 @@
1
+ import { AUTH_UNLOCK_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
2
+ import { renderUnlockAccountEmail } from "../email-templates";
3
+ import { signUnlockToken } from "../unlock-token";
4
+ import {
5
+ createTokenRequestHandler,
6
+ type TokenRequestData,
7
+ type TokenRequestOptions,
8
+ } from "./token-request-handler";
9
+
10
+ const UNLOCK_NOTIFICATION_TYPE = "auth-email-password:account-unlock";
11
+
12
+ export type RequestAccountUnlockOptions = TokenRequestOptions;
13
+
14
+ // Public shape re-exported for callers that build custom routes on top of
15
+ // the dispatcher (bypassing the framework's auth-routes).
16
+ export type RequestUnlockData = TokenRequestData<"unlock-requested">;
17
+
18
+ // @wrapper-known semantic-alias
19
+ export function createRequestAccountUnlockHandler(opts: RequestAccountUnlockOptions) {
20
+ return createTokenRequestHandler(
21
+ {
22
+ handlerName: "request-account-unlock",
23
+ successKind: "unlock-requested",
24
+ defaultTtlMinutes: AUTH_UNLOCK_DEFAULT_TTL_MINUTES,
25
+ sign: signUnlockToken,
26
+ notConfiguredError: AuthErrors.unlockNotConfigured,
27
+ notConfiguredI18nKey: "auth.errors.unlockNotConfigured",
28
+ // No extra skip condition — any existing, non-deleted user can
29
+ // request an unlock link regardless of whether they're actually
30
+ // locked right now. Confirming just clears the (possibly already
31
+ // empty) lockout state — harmless no-op if they weren't locked.
32
+ extraSilentSkip: () => false,
33
+ notificationType: UNLOCK_NOTIFICATION_TYPE,
34
+ renderContent: renderUnlockAccountEmail,
35
+ },
36
+ opts,
37
+ );
38
+ }
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { hashPassword } from "../../shared";
5
5
  import { AuthErrors } from "../constants";
6
6
  import { invalidResetToken } from "../errors";
7
+ import { passwordSchema } from "../password-policy";
7
8
  import { verifyResetToken } from "../reset-token";
8
9
  import { runConfirmTokenFlow } from "./confirm-token-flow";
9
10
 
@@ -22,7 +23,7 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
22
23
  name: "reset-password",
23
24
  schema: z.object({
24
25
  token: z.string().min(1),
25
- newPassword: z.string().min(8).max(200),
26
+ newPassword: passwordSchema,
26
27
  }),
27
28
  access: { roles: ["all"] },
28
29
  handler: async (event, ctx) => {
@@ -33,6 +33,7 @@ import { z } from "zod";
33
33
  // kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
34
34
  import { tenantTable } from "../../tenant/schema/tenant";
35
35
  import { invalidSignupToken, signupEmailAlreadyRegistered } from "../errors";
36
+ import { passwordSchema } from "../password-policy";
36
37
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
37
38
  import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
38
39
  import {
@@ -44,7 +45,7 @@ import {
44
45
 
45
46
  const SignupConfirmSchema = z.object({
46
47
  token: z.string().min(8),
47
- password: z.string().min(8).max(200),
48
+ password: passwordSchema,
48
49
  });
49
50
 
50
51
  // Mirror der login-handler-Shape (kind: "auth-session", session: SessionUser)
@@ -18,6 +18,7 @@ export const defaultTranslations: TranslationsByLocale = {
18
18
  "auth.login.submit": "Einloggen",
19
19
  "auth.login.submitting": "…",
20
20
  "auth.login.forgotPassword": "Passwort vergessen?",
21
+ "auth.login.unlockAccount": "Konto entsperren?",
21
22
  "auth.login.resendVerification": "Bestätigungs-Mail erneut senden",
22
23
  "auth.login.resendSuccess": "Wir haben dir eine neue Bestätigungs-Mail geschickt.",
23
24
  "auth.login.resendRateLimited": "Bitte warte kurz und versuche es erneut.",
@@ -37,6 +38,8 @@ export const defaultTranslations: TranslationsByLocale = {
37
38
  "auth.errors.invalidResetToken":
38
39
  "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
39
40
  "auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
41
+ "auth.errors.invalidUnlockToken":
42
+ "Der Entsperren-Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
40
43
  "auth.errors.invalidSignupToken":
41
44
  "Der Aktivierungs-Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
42
45
  "auth.errors.signupEmailAlreadyRegistered":
@@ -74,6 +77,25 @@ export const defaultTranslations: TranslationsByLocale = {
74
77
  "Der Link ist ungültig oder abgelaufen. Bitte fordere eine neue Bestätigungs-Mail an.",
75
78
  "auth.verifyEmail.goToLogin": "Zum Login",
76
79
  "auth.verifyEmail.missingToken": "Der Bestätigungs-Link enthält keinen Token.",
80
+ "auth.requestUnlock.title": "Konto entsperren",
81
+ "auth.requestUnlock.intro":
82
+ "Gib deine E-Mail-Adresse ein. Falls dein Konto gesperrt ist, schicken wir dir einen Entsperren-Link.",
83
+ "auth.requestUnlock.email": "E-Mail",
84
+ "auth.requestUnlock.submit": "Link anfordern",
85
+ "auth.requestUnlock.submitting": "…",
86
+ "auth.requestUnlock.successTitle": "Mail gesendet",
87
+ "auth.requestUnlock.successBody":
88
+ "Falls die E-Mail in unserem System existiert und gesperrt ist, ist eine Nachricht mit einem Entsperren-Link unterwegs. Bitte schau in deinen Posteingang.",
89
+ "auth.requestUnlock.backToLogin": "Zurück zum Login",
90
+ "auth.unlockAccount.verifying": "Konto wird entsperrt …",
91
+ "auth.unlockAccount.successTitle": "Konto entsperrt",
92
+ "auth.unlockAccount.successBody":
93
+ "Dein Konto ist wieder entsperrt. Du kannst dich jetzt anmelden.",
94
+ "auth.unlockAccount.errorTitle": "Entsperren fehlgeschlagen",
95
+ "auth.unlockAccount.errorBody":
96
+ "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen Entsperren-Link an.",
97
+ "auth.unlockAccount.goToLogin": "Zum Login",
98
+ "auth.unlockAccount.missingToken": "Der Entsperren-Link enthält keinen Token.",
77
99
  "auth.signup.title": "Account erstellen",
78
100
  "auth.signup.intro":
79
101
  "Gib deine E-Mail-Adresse ein. Wir schicken dir einen Aktivierungs-Link, mit dem du dein Passwort setzt.",
@@ -122,6 +144,7 @@ export const defaultTranslations: TranslationsByLocale = {
122
144
  "auth.login.submit": "Sign in",
123
145
  "auth.login.submitting": "…",
124
146
  "auth.login.forgotPassword": "Forgot password?",
147
+ "auth.login.unlockAccount": "Unlock account?",
125
148
  "auth.login.resendVerification": "Send verification email again",
126
149
  "auth.login.resendSuccess": "We've sent you a new verification email.",
127
150
  "auth.login.resendRateLimited": "Please wait a moment and try again.",
@@ -140,6 +163,8 @@ export const defaultTranslations: TranslationsByLocale = {
140
163
  "This app doesn't support two-factor verification. Please contact support.",
141
164
  "auth.errors.invalidResetToken": "Link is invalid or expired. Please request a new one.",
142
165
  "auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
166
+ "auth.errors.invalidUnlockToken":
167
+ "Unlock link is invalid or expired. Please request a new one.",
143
168
  "auth.errors.invalidSignupToken":
144
169
  "Activation link is invalid or expired. Please request a new one.",
145
170
  "auth.errors.signupEmailAlreadyRegistered":
@@ -176,6 +201,23 @@ export const defaultTranslations: TranslationsByLocale = {
176
201
  "Link is invalid or expired. Please request a new verification email.",
177
202
  "auth.verifyEmail.goToLogin": "Go to sign in",
178
203
  "auth.verifyEmail.missingToken": "Verification link is missing a token.",
204
+ "auth.requestUnlock.title": "Unlock account",
205
+ "auth.requestUnlock.intro":
206
+ "Enter your email address. If your account is locked, we'll send you an unlock link.",
207
+ "auth.requestUnlock.email": "Email",
208
+ "auth.requestUnlock.submit": "Request link",
209
+ "auth.requestUnlock.submitting": "…",
210
+ "auth.requestUnlock.successTitle": "Email sent",
211
+ "auth.requestUnlock.successBody":
212
+ "If that email exists in our system and is locked, a message with an unlock link is on its way. Please check your inbox.",
213
+ "auth.requestUnlock.backToLogin": "Back to sign in",
214
+ "auth.unlockAccount.verifying": "Unlocking account …",
215
+ "auth.unlockAccount.successTitle": "Account unlocked",
216
+ "auth.unlockAccount.successBody": "Your account is unlocked again. You can sign in now.",
217
+ "auth.unlockAccount.errorTitle": "Unlock failed",
218
+ "auth.unlockAccount.errorBody": "Link is invalid or expired. Please request a new unlock link.",
219
+ "auth.unlockAccount.goToLogin": "Go to sign in",
220
+ "auth.unlockAccount.missingToken": "Unlock link is missing a token.",
179
221
  "auth.signup.title": "Create account",
180
222
  "auth.signup.intro":
181
223
  "Enter your email. We'll send you an activation link to set your password.",
@@ -22,10 +22,12 @@ export {
22
22
  renderActivationEmail,
23
23
  renderInviteEmail,
24
24
  renderResetPasswordEmail,
25
+ renderUnlockAccountEmail,
25
26
  renderVerifyEmail,
26
27
  } from "./email-templates";
27
28
  export type {
28
29
  AccountLockoutOptions,
30
+ AccountUnlockOptions,
29
31
  AuthEmailPasswordOptions,
30
32
  EmailVerificationOptions,
31
33
  InviteOptions,
@@ -31,9 +31,13 @@ export type LockoutState = {
31
31
  // Consequence of the monotonic counter: once a user has been locked, the
32
32
  // NEXT wrong password after the lock expires re-locks immediately — the
33
33
  // INCR still returns a value ≥ threshold, so the SET NX re-arms the lock.
34
- // A successful login is the only way to reset the streak. Intentional:
35
- // brute-force resistance favours strictness over UX, and a legitimate user
36
- // who hit the lock once can just log in correctly to clear it.
34
+ // A successful login is one way to reset the streak; the other is the
35
+ // account-unlock magic-link flow (#1266, see
36
+ // handlers/confirm-account-unlock.write.ts), a deliberate escape hatch for
37
+ // a legitimate user who can't currently produce the right password (e.g.
38
+ // they forgot it too) but can prove mailbox ownership. Intentional:
39
+ // brute-force resistance favours strictness over UX for anonymous login
40
+ // attempts, while the unlock flow keeps the DoS from being permanent.
37
41
  const COUNT_KEY_PREFIX = "kumiko:auth:lockout:count:";
38
42
  const UNTIL_KEY_PREFIX = "kumiko:auth:lockout:until:";
39
43
 
@@ -111,8 +115,9 @@ export async function recordFailedAttempt(
111
115
  return { failureCount: count, lockedUntil };
112
116
  }
113
117
 
114
- // Called on successful login. Idempotent — deleting missing keys is a no-op.
115
- // The ONLY path that resets the counter; intentional.
118
+ // Called on successful login, and on a confirmed account-unlock token
119
+ // (#1266). Idempotent — deleting missing keys is a no-op, so a replayed
120
+ // unlock link just re-clears harmlessly.
116
121
  export async function clearLockoutState(redis: Redis, userId: string): Promise<void> {
117
122
  await redis.del(countKey(userId), untilKey(userId));
118
123
  }