@cosmicdrift/kumiko-bundled-features 0.352.0 → 0.354.0

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 (259) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +17 -5
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
  9. package/dist/agent-tools/manifest-deny-qns.js +17 -0
  10. package/dist/agent-tools/tool-catalog.d.ts +2 -1
  11. package/dist/agent-tools/tool-catalog.js +4 -1
  12. package/dist/agent-tools/tool-dispatch.js +1 -1
  13. package/dist/audit/handlers/list.query.js +13 -8
  14. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  15. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  16. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  17. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  18. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  19. package/dist/auth-email-password/handlers/login.write.js +1 -0
  20. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  21. package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
  22. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  23. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  24. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  25. package/dist/auth-email-password/index.d.ts +2 -0
  26. package/dist/auth-email-password/index.js +2 -0
  27. package/dist/auth-email-password/seeding.d.ts +2 -0
  28. package/dist/auth-email-password/seeding.js +30 -8
  29. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  30. package/dist/auth-email-password/signup-activation.js +41 -0
  31. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  32. package/dist/auth-email-password/signup-token-store.js +8 -3
  33. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  34. package/dist/auth-email-password/web/auth-gate.js +5 -5
  35. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  36. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  37. package/dist/auth-email-password/web/login-screen.js +2 -2
  38. package/dist/auth-email-password/web/user-menu.js +1 -1
  39. package/dist/auth-mfa/feature.js +5 -0
  40. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  41. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  42. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  43. package/dist/auth-mfa/screens.js +2 -2
  44. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  45. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  46. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  47. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  48. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  49. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  50. package/dist/billing-foundation/checkout-core.d.ts +7 -0
  51. package/dist/billing-foundation/checkout-core.js +7 -2
  52. package/dist/billing-foundation/entities.d.ts +8 -0
  53. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
  54. package/dist/billing-foundation/projection.d.ts +8 -0
  55. package/dist/cap-counter/entity.d.ts +1 -0
  56. package/dist/cap-overview/access.js +2 -9
  57. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
  58. package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
  59. package/dist/channel-in-app/in-app-channel.js +1 -1
  60. package/dist/channel-in-app/tables.js +4 -2
  61. package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
  62. package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
  63. package/dist/config/handlers/values.query.d.ts +3 -1
  64. package/dist/config/handlers/values.query.js +7 -2
  65. package/dist/config/table.d.ts +6 -0
  66. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  67. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  68. package/dist/custom-fields/entity.d.ts +4 -0
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  71. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  72. package/dist/delivery/delivery-service.js +60 -12
  73. package/dist/delivery/public-names.js +1 -1
  74. package/dist/delivery/tables.d.ts +6 -0
  75. package/dist/delivery/unsubscribe.js +66 -14
  76. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  77. package/dist/feature-toggles/feature.js +2 -3
  78. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  79. package/dist/feature-toggles/toggle-runtime.js +30 -6
  80. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  81. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  82. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  83. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  84. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  85. package/dist/files-tenant-data/hooks.js +61 -24
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/feature.js +2 -10
  88. package/dist/folders/handlers/delete-folder.write.js +31 -4
  89. package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
  90. package/dist/form-draft/db/queries/cleanup.js +4 -3
  91. package/dist/form-draft/entity.d.ts +2 -0
  92. package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
  93. package/dist/form-draft/handlers/cleanup.job.js +11 -5
  94. package/dist/foundation-shared/index.d.ts +1 -1
  95. package/dist/foundation-shared/index.js +1 -1
  96. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  97. package/dist/foundation-shared/mail-host-policy.js +13 -4
  98. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  99. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  100. package/dist/inbound-provider-imap/feature.js +2 -0
  101. package/dist/inbound-provider-imap/imap-client.js +3 -4
  102. package/dist/jobs/constants.d.ts +1 -0
  103. package/dist/jobs/constants.js +1 -0
  104. package/dist/jobs/db/queries/retention.d.ts +1 -0
  105. package/dist/jobs/db/queries/retention.js +5 -0
  106. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  107. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  108. package/dist/jobs/feature.js +7 -1
  109. package/dist/jobs/handlers/list.query.d.ts +2 -1
  110. package/dist/jobs/handlers/list.query.js +40 -15
  111. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  112. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  113. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  114. package/dist/jobs/i18n.js +1 -0
  115. package/dist/jobs/index.d.ts +2 -0
  116. package/dist/jobs/index.js +1 -0
  117. package/dist/jobs/job-run-logger.d.ts +2 -2
  118. package/dist/jobs/job-run-logger.js +98 -9
  119. package/dist/jobs/job-run-table.d.ts +6 -0
  120. package/dist/jobs/tenant-job-failure-table.js +15 -2
  121. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  122. package/dist/jobs/tenant-job-run-table.js +34 -0
  123. package/dist/ledger/entity.d.ts +24 -8
  124. package/dist/ledger/entity.js +17 -5
  125. package/dist/ledger/feature.js +3 -0
  126. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  127. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  128. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  129. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  130. package/dist/ledger/schedule-subject-pair.js +24 -0
  131. package/dist/ledger/schemas.js +8 -2
  132. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  133. package/dist/mail-transport-smtp/feature.js +4 -13
  134. package/dist/managed-pages/feature.js +28 -10
  135. package/dist/managed-pages/table.d.ts +12 -0
  136. package/dist/notes-history/entity.d.ts +12 -0
  137. package/dist/notes-history/entity.js +4 -0
  138. package/dist/notes-history/feature.js +2 -10
  139. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  140. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  141. package/dist/personal-access-tokens/screens.js +14 -3
  142. package/dist/secrets/table.d.ts +1 -0
  143. package/dist/seo/feature.js +28 -6
  144. package/dist/sessions/feature.d.ts +1 -0
  145. package/dist/sessions/feature.js +9 -8
  146. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  147. package/dist/sessions/handlers/detail.query.js +2 -2
  148. package/dist/sessions/handlers/list.query.d.ts +1 -1
  149. package/dist/sessions/handlers/list.query.js +5 -3
  150. package/dist/sessions/schema/user-session.d.ts +4 -0
  151. package/dist/sessions/screens.d.ts +2 -2
  152. package/dist/sessions/screens.js +6 -7
  153. package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
  154. package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
  155. package/dist/shared/encrypt-for-direct-write.js +10 -2
  156. package/dist/shared/index.d.ts +4 -3
  157. package/dist/shared/index.js +4 -3
  158. package/dist/shared/parent-visibility.d.ts +2 -1
  159. package/dist/shared/parent-visibility.js +21 -0
  160. package/dist/shared/rate-limited-text-response.d.ts +2 -0
  161. package/dist/shared/rate-limited-text-response.js +9 -0
  162. package/dist/shared/row-bound-grant.js +16 -2
  163. package/dist/shared/session-locale-field.d.ts +1 -0
  164. package/dist/shared/session-locale-field.js +6 -0
  165. package/dist/tags/entity.d.ts +9 -0
  166. package/dist/tags/feature.js +2 -10
  167. package/dist/template-resolver/api.d.ts +2 -2
  168. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  169. package/dist/template-resolver/handlers/list.query.js +2 -2
  170. package/dist/template-resolver/table.d.ts +48 -30
  171. package/dist/template-resolver/user-content-table.d.ts +34 -20
  172. package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
  173. package/dist/tenant/decrypt-tenant-names.js +10 -0
  174. package/dist/tenant/feature.js +7 -1
  175. package/dist/tenant/handlers/me.query.js +5 -1
  176. package/dist/tenant/handlers/memberships.query.js +3 -2
  177. package/dist/tenant/handlers/tenant-directory.query.js +9 -3
  178. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  179. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  180. package/dist/tenant/index.d.ts +1 -0
  181. package/dist/tenant/index.js +1 -0
  182. package/dist/tenant/invitation-table.d.ts +54 -8
  183. package/dist/tenant/invitation-table.js +6 -1
  184. package/dist/tenant/invited-membership.d.ts +8 -6
  185. package/dist/tenant/invited-membership.js +28 -7
  186. package/dist/tenant/membership-table.d.ts +4 -0
  187. package/dist/tenant/schema/tenant.d.ts +18 -10
  188. package/dist/tenant/schema/tenant.js +4 -6
  189. package/dist/tenant/screens.js +1 -1
  190. package/dist/tenant-handover/move-entity-graph.js +11 -18
  191. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  192. package/dist/tenant-handover/transfer-graph.js +8 -75
  193. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  194. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  195. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  196. package/dist/tenant-lifecycle/constants.js +5 -0
  197. package/dist/tenant-lifecycle/events.d.ts +5 -0
  198. package/dist/tenant-lifecycle/events.js +5 -0
  199. package/dist/tenant-lifecycle/feature.js +6 -3
  200. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
  201. package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
  202. package/dist/tenant-lifecycle/stages.d.ts +8 -1
  203. package/dist/tenant-lifecycle/stages.js +33 -4
  204. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  205. package/dist/tenant-lifecycle/testing.js +42 -0
  206. package/dist/tier-engine/entity.d.ts +2 -0
  207. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  208. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  209. package/dist/user/handlers/detail.query.js +2 -0
  210. package/dist/user/handlers/list.query.js +6 -2
  211. package/dist/user/handlers/update.write.js +1 -0
  212. package/dist/user/schema/user.d.ts +22 -8
  213. package/dist/user/seeding.d.ts +1 -0
  214. package/dist/user/seeding.js +20 -0
  215. package/dist/user-data-rights/boot-checks.js +5 -0
  216. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
  217. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  218. package/dist/user-data-rights/index.d.ts +1 -0
  219. package/dist/user-data-rights/index.js +1 -0
  220. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  221. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  222. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  223. package/dist/user-data-rights/schema/export-job.d.ts +16 -8
  224. package/dist/user-profile/handlers/change-email.write.js +8 -2
  225. package/dist/waitlist/entity.d.ts +22 -6
  226. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  227. package/dist/workflow-runner/event-trigger.js +63 -15
  228. package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
  229. package/dist/workflow-runner/runner.d.ts +2 -1
  230. package/dist/workflow-runner/runner.js +5 -1
  231. package/package.json +35 -10
  232. package/src/agent-tools/changes.json +12 -0
  233. package/src/audit/changes.json +6 -0
  234. package/src/auth-email-password/changes.json +24 -0
  235. package/src/auth-mfa/changes.json +7 -0
  236. package/src/billing-foundation/changes.json +6 -0
  237. package/src/channel-in-app/changes.json +8 -1
  238. package/src/crypto-shredding/changes.json +12 -0
  239. package/src/delivery/changes.json +6 -0
  240. package/src/files/changes.json +24 -0
  241. package/src/files-tenant-data/changes.json +5 -0
  242. package/src/folders/changes.json +6 -0
  243. package/src/form-draft/changes.json +7 -1
  244. package/src/inbound-provider-imap/changes.json +6 -0
  245. package/src/jobs/changes.json +30 -0
  246. package/src/ledger/changes.json +23 -0
  247. package/src/managed-pages/changes.json +6 -0
  248. package/src/notes-history/changes.json +13 -0
  249. package/src/seo/changes.json +5 -0
  250. package/src/template-resolver/changes.json +5 -0
  251. package/src/tenant/changes.json +19 -0
  252. package/src/tenant-handover/changes.json +12 -0
  253. package/src/tenant-lifecycle/changes.json +22 -0
  254. package/src/tier-engine/changes.json +5 -0
  255. package/src/user/changes.json +15 -0
  256. package/src/user-data-rights/changes.json +6 -0
  257. package/src/workflow-runner/changes.json +5 -0
  258. package/dist/shared/has-where-rule.d.ts +0 -2
  259. package/dist/shared/has-where-rule.js +0 -10
@@ -20,18 +20,14 @@
20
20
  // stays always-200 + resend-idempotent so the request path leaks nothing;
21
21
  // suppressing the link on the request side would be defense-in-depth, but
22
22
  // with an enumeration risk of its own (separate concern).
23
- import { generateToken } from "@cosmicdrift/kumiko-framework/api";
24
23
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
25
24
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
- import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
27
25
  import * as z from "zod";
28
26
  import { findSignupHandoverProvider } from "../../shared/index.js";
29
- import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants.js";
30
- import { renderActivationEmail } from "../email-templates.js";
31
- import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
27
+ import { resolveHandlerMailLocale } from "../magic-link-mail.js";
32
28
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.js";
33
- import { invalidateExistingSignupToken, normalizeEmail, storeSignupHandover, storeSignupToken, takeOverSignupHandoverForResend, } from "../signup-token-store.js";
34
- const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
29
+ import { issueSignupActivation } from "../signup-activation.js";
30
+ import { storeSignupHandover, takeOverSignupHandoverForResend } from "../signup-token-store.js";
35
31
  const SignupRequestSchema = z.object({
36
32
  email: z.email(),
37
33
  // Cross-device try-first handover (kumiko-framework#3035 follow-up,
@@ -47,8 +43,6 @@ const SignupRequestSchema = z.object({
47
43
  });
48
44
  const HANDOVER_GRANT_VERIFY_REASON = "verifies a tenant-handover grant against its root row in the source tenant (read-only anchor lookup) before binding it to the signup token";
49
45
  export function createSignupRequestHandler(opts) {
50
- const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES;
51
- const ttlSeconds = ttlMinutes * 60;
52
46
  return defineWriteHandler({
53
47
  name: "signup-request",
54
48
  schema: SignupRequestSchema,
@@ -93,84 +87,64 @@ export function createSignupRequestHandler(opts) {
93
87
  // the visitor just retyped their email) must still carry the earlier
94
88
  // verified grant over to the new token instead of silently dropping it.
95
89
  const priorBinding = await takeOverSignupHandoverForResend(ctx.redis, email);
96
- // At most one live signup token per email: invalidate whatever's
97
- // there before minting the new one (see signup-token-store.ts).
98
- await invalidateExistingSignupToken(ctx.redis, email);
99
- // 32 random bytes = 256 bits unguessable randomness, base64url
100
- // encoded to 43 chars. Math.random used to be a bug here:
101
- // xorshift128+ has ~128 bits of state that's reconstructible after
102
- // ~5 observed outputs — an attacker could trigger their own
103
- // signup-requests and predict other users' tokens. generateToken
104
- // uses randomBytes from node:crypto, the same source as CSRF/
105
- // session tokens.
106
- const token = generateToken();
107
- const issuedAt = Temporal.Now.instant();
108
- const expiresAt = issuedAt.add({ seconds: ttlSeconds });
109
- const expiresAtIso = expiresAt.toString();
110
- await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
111
- // A handover grant in this request re-verifies and replaces the prior
112
- // binding; anything short of a fresh, verified grant (no provider
113
- // mounted, or verification failed) falls back to it instead —
114
- // dropping a still-live binding on a bad request would strand the
115
- // visitor's run. A throw here must never skip the mail below, or the
116
- // signup itself silently breaks for a bug in a provider this handler
117
- // doesn't own.
118
- let binding = priorBinding;
119
- if (event.payload.handover) {
120
- const { entityType, token: grantToken } = event.payload.handover;
121
- const provider = findSignupHandoverProvider(ctx.registry);
122
- if (!provider) {
123
- ctx.log?.warn("signup-request: handover payload present but no signupHandover provider mounted");
124
- }
125
- else {
126
- try {
127
- const verified = await provider.verifyGrant({
128
- db: ctx.db.unsafeRaw(),
129
- registry: ctx.registry,
130
- entityType,
131
- token: grantToken,
132
- });
133
- if (verified) {
134
- binding = verified;
90
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
91
+ const redis = ctx.redis;
92
+ const issued = await issueSignupActivation({
93
+ redis,
94
+ notify: ctx.notify,
95
+ email,
96
+ appUrl: opts.appUrl,
97
+ timeZone: ctx.tz.user,
98
+ ...(opts.tokenTtlMinutes !== undefined && { tokenTtlMinutes: opts.tokenTtlMinutes }),
99
+ ...(opts.appName !== undefined && { appName: opts.appName }),
100
+ ...(locale !== undefined && { locale }),
101
+ // A handover grant in this request re-verifies and replaces the prior
102
+ // binding; anything short of a fresh, verified grant (no provider
103
+ // mounted, or verification failed) falls back to it instead —
104
+ // dropping a still-live binding on a bad request would strand the
105
+ // visitor's run. A throw here must never skip the mail, or the
106
+ // signup itself silently breaks for a bug in a provider this handler
107
+ // doesn't own.
108
+ onTokenStored: async ({ token, ttlSeconds }) => {
109
+ let binding = priorBinding;
110
+ if (event.payload.handover) {
111
+ const { entityType, token: grantToken } = event.payload.handover;
112
+ const provider = findSignupHandoverProvider(ctx.registry);
113
+ if (!provider) {
114
+ ctx.log?.warn("signup-request: handover payload present but no signupHandover provider mounted");
135
115
  }
136
116
  else {
137
- ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
117
+ try {
118
+ const verified = await provider.verifyGrant({
119
+ db: ctx.db.unsafeRaw(),
120
+ registry: ctx.registry,
121
+ entityType,
122
+ token: grantToken,
123
+ });
124
+ if (verified) {
125
+ binding = verified;
126
+ }
127
+ else {
128
+ ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
129
+ }
130
+ }
131
+ catch {
132
+ ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
133
+ }
138
134
  }
139
135
  }
140
- catch {
141
- ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
136
+ if (binding) {
137
+ await storeSignupHandover(redis, { token, binding, ttlSeconds });
142
138
  }
143
- }
144
- }
145
- if (binding) {
146
- await storeSignupHandover(ctx.redis, { token, binding, ttlSeconds });
147
- }
148
- // normalizeEmail from the store — one source of truth for
149
- // normalization; the delivery recipient + lookup path consistently
150
- // get the same format.
151
- const normalizedEmail = normalizeEmail(email);
152
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
153
- await dispatchMagicLinkMail(ctx.notify, {
154
- handlerName: "signup-request",
155
- notificationType: SIGNUP_NOTIFICATION_TYPE,
156
- renderContent: renderActivationEmail,
157
- }, {
158
- email: normalizedEmail,
159
- appUrl: opts.appUrl,
160
- token,
161
- expiresAt: expiresAtIso,
162
- issuedAt: issuedAt.toString(),
163
- timeZone: ctx.tz.user,
164
- ...(opts.appName !== undefined && { appName: opts.appName }),
165
- locale,
139
+ },
166
140
  });
167
141
  return {
168
142
  isSuccess: true,
169
143
  data: {
170
144
  kind: "signup-requested",
171
- email: normalizedEmail,
172
- token,
173
- expiresAt: expiresAtIso,
145
+ email: issued.email,
146
+ token: issued.token,
147
+ expiresAt: issued.expiresAt,
174
148
  },
175
149
  };
176
150
  },
@@ -35,6 +35,7 @@ export function createTokenRequestHandler(spec, opts) {
35
35
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
36
36
  additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
37
37
  escapeHatch: {
38
+ grants: ["systemIdentity"],
38
39
  reason: "Anonymous requester has no session — looks up the target user by email via " +
39
40
  "ctx.queryAs(SYSTEM, user:findForAuth) to mint the out-of-band token.",
40
41
  },
@@ -21,6 +21,7 @@ export function createVerifyEmailHandler(opts) {
21
21
  access: { roles: ["anonymous"] },
22
22
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
23
23
  escapeHatch: {
24
+ grants: ["systemIdentity"],
24
25
  reason: "Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
25
26
  "target user via ctx.writeAs(SYSTEM, user:update) to set emailVerified.",
26
27
  },
@@ -9,3 +9,5 @@ export type { AccountLockoutOptions, AccountUnlockOptions, AuthEmailPasswordOpti
9
9
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature.js";
10
10
  export { AUTH_SELF_REGISTRATION_FEATURE, createAuthSelfRegistrationToggleFeature, } from "./self-registration-toggle.js";
11
11
  export { signToken, TokenPurpose, type VerifyResult, verifyToken } from "./signed-token.js";
12
+ export { type IssuedSignupActivation, type IssueSignupActivationArgs, issueSignupActivation, SIGNUP_ACTIVATION_NOTIFICATION_TYPE, } from "./signup-activation.js";
13
+ export { invalidateExistingSignupToken, normalizeEmail, SIGNUP_TOKEN_KEY_PREFIXES, storeSignupToken, } from "./signup-token-store.js";
@@ -17,3 +17,5 @@ export { AUTH_SELF_REGISTRATION_FEATURE, createAuthSelfRegistrationToggleFeature
17
17
  // statt eigene HMAC-Logik zu duplizieren. Purpose-string diskriminiert
18
18
  // Cross-Replay zwischen Flows.
19
19
  export { signToken, TokenPurpose, verifyToken } from "./signed-token.js";
20
+ export { issueSignupActivation, SIGNUP_ACTIVATION_NOTIFICATION_TYPE, } from "./signup-activation.js";
21
+ export { invalidateExistingSignupToken, normalizeEmail, SIGNUP_TOKEN_KEY_PREFIXES, storeSignupToken, } from "./signup-token-store.js";
@@ -64,6 +64,8 @@ export type ProvisionSignupAccountOptions = {
64
64
  readonly tenantName: string;
65
65
  readonly tenantId: TenantId;
66
66
  readonly memberRoles?: readonly string[];
67
+ /** Stored on the new user row so the first session already carries the locale claim. */
68
+ readonly locale?: string;
67
69
  };
68
70
  export declare function provisionSignupAccount(db: DbConnection, options: ProvisionSignupAccountOptions, hooks?: SeedTenantHooks): Promise<{
69
71
  readonly userId: string;
@@ -14,6 +14,7 @@
14
14
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { configuredBlindIndexKey, configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
16
16
  import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
17
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
17
18
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
18
19
  import { hashPassword } from "../shared/index.js";
19
20
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
@@ -21,7 +22,8 @@ import { seedTenant, seedTenantMembership } from "../tenant/seeding.js";
21
22
  // kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
22
23
  import { USER_STATUS, userTable } from "../user/schema/user.js";
23
24
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
24
- import { reconcileSeededUserEmailVerified, seedUser } from "../user/seeding.js";
25
+ import { reconcileSeededUserEmailVerified, rewriteSeededUserEmail, seedUser, } from "../user/seeding.js";
26
+ const log = createFallbackLogger("auth-email-password-seeding");
25
27
  // Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
26
28
  // Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
27
29
  // password + tenant + membership" — wer das nutzt soll nicht aus drei
@@ -114,6 +116,7 @@ hooks) {
114
116
  password: options.password,
115
117
  displayName: options.displayName,
116
118
  emailVerified: true,
119
+ ...(options.locale !== undefined && { locale: options.locale }),
117
120
  }, hooks);
118
121
  await seedTenantMembership(db, {
119
122
  userId,
@@ -186,16 +189,30 @@ async function findExistingUserIdForEmail(db, kms, email) {
186
189
  status: { ne: USER_STATUS.Deleted },
187
190
  });
188
191
  const matches = [];
192
+ let undecryptableRows = 0;
189
193
  for (const row of rows) {
190
194
  // Compared byte-exact, deliberately not case-folded: email_bidx is an
191
195
  // HMAC over the raw value and login matches case-sensitively, so a case
192
196
  // variant is a different account to every other code path.
193
- const decrypted = await decryptPiiFieldValues({ email: row.email }, ["email"], kms, {
194
- requestId: "seed-admin-guarded",
195
- });
197
+ // One undecryptable foreign row (missing key row, ciphertext from another
198
+ // KMS store) must not abort the boot seed: the account we look for is
199
+ // decryptable by definition.
200
+ let decrypted;
201
+ try {
202
+ decrypted = await decryptPiiFieldValues({ email: row.email }, ["email"], kms, {
203
+ requestId: "seed-admin-guarded",
204
+ });
205
+ }
206
+ catch {
207
+ undecryptableRows += 1;
208
+ continue;
209
+ }
196
210
  if (decrypted["email"] === email)
197
211
  matches.push(row);
198
212
  }
213
+ if (undecryptableRows > 0) {
214
+ log.warn(`seedAdminGuarded: skipped ${undecryptableRows} user row(s) whose email could not be decrypted`);
215
+ }
199
216
  return matches.length === 0 ? undefined : pickCanonicalUserId(matches);
200
217
  }
201
218
  /**
@@ -225,10 +242,15 @@ export async function seedAdminGuarded(db, options) {
225
242
  if (existingId === undefined)
226
243
  return seedAdmin(db, options);
227
244
  const by = options.by ?? TestUsers.systemAdmin;
228
- if (options.emailVerified === true) {
229
- const existingRow = await fetchOne(db, userTable, { id: existingId });
230
- if (existingRow)
231
- await reconcileSeededUserEmailVerified(db, existingRow, by);
245
+ const existingRow = await fetchOne(db, userTable, { id: existingId });
246
+ if (existingRow) {
247
+ // Re-write the email so email_bidx is recomputed; without it the account
248
+ // stays unfindable for login and every boot re-scans the whole table.
249
+ await rewriteSeededUserEmail(db, existingRow, options.email, by);
250
+ if (options.emailVerified === true) {
251
+ const refreshed = (await fetchOne(db, userTable, { id: existingId })) ?? existingRow;
252
+ await reconcileSeededUserEmailVerified(db, refreshed, by);
253
+ }
232
254
  }
233
255
  for (const m of options.memberships) {
234
256
  await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
@@ -0,0 +1,24 @@
1
+ import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { Redis } from "ioredis";
3
+ export declare const SIGNUP_ACTIVATION_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
4
+ export type IssueSignupActivationArgs = {
5
+ readonly redis: Redis;
6
+ readonly notify: NotifyFn | undefined;
7
+ readonly email: string;
8
+ readonly appUrl: string | ((locale: string) => string);
9
+ readonly appName?: string;
10
+ readonly locale?: string;
11
+ readonly timeZone?: string;
12
+ readonly tokenTtlMinutes?: number;
13
+ /** Runs after the token is stored and before the mail goes out. */
14
+ readonly onTokenStored?: (stored: {
15
+ readonly token: string;
16
+ readonly ttlSeconds: number;
17
+ }) => Promise<void>;
18
+ };
19
+ export type IssuedSignupActivation = {
20
+ readonly email: string;
21
+ readonly token: string;
22
+ readonly expiresAt: string;
23
+ };
24
+ export declare function issueSignupActivation(args: IssueSignupActivationArgs): Promise<IssuedSignupActivation>;
@@ -0,0 +1,41 @@
1
+ // Mints a signup activation link: invalidates any live token for the email,
2
+ // stores a fresh one and mails the activation link. Shared by the
3
+ // signup-request handler and by apps that issue the same link from their own
4
+ // flow (e.g. a waitlist invite), so TTL, store keys and notification type
5
+ // cannot drift between the two.
6
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
7
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
+ import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "./constants.js";
9
+ import { renderActivationEmail } from "./email-templates.js";
10
+ import { dispatchMagicLinkMail } from "./magic-link-mail.js";
11
+ import { invalidateExistingSignupToken, normalizeEmail, storeSignupToken, } from "./signup-token-store.js";
12
+ export const SIGNUP_ACTIVATION_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
13
+ export async function issueSignupActivation(args) {
14
+ const ttlSeconds = (args.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES) * 60;
15
+ // At most one live signup token per email.
16
+ await invalidateExistingSignupToken(args.redis, args.email);
17
+ // 256 bits from node:crypto — never Math.random: xorshift128+ state is
18
+ // reconstructible from a few observed outputs, which would let an attacker
19
+ // predict other users' tokens.
20
+ const token = generateToken();
21
+ const issuedAt = Temporal.Now.instant();
22
+ const expiresAt = issuedAt.add({ seconds: ttlSeconds }).toString();
23
+ await storeSignupToken(args.redis, { email: args.email, token, ttlSeconds });
24
+ await args.onTokenStored?.({ token, ttlSeconds });
25
+ const email = normalizeEmail(args.email);
26
+ await dispatchMagicLinkMail(args.notify, {
27
+ handlerName: "signup-request",
28
+ notificationType: SIGNUP_ACTIVATION_NOTIFICATION_TYPE,
29
+ renderContent: renderActivationEmail,
30
+ }, {
31
+ email,
32
+ appUrl: args.appUrl,
33
+ token,
34
+ expiresAt,
35
+ issuedAt: issuedAt.toString(),
36
+ ...(args.timeZone !== undefined && { timeZone: args.timeZone }),
37
+ ...(args.appName !== undefined && { appName: args.appName }),
38
+ ...(args.locale !== undefined && { locale: args.locale }),
39
+ });
40
+ return { email, token, expiresAt };
41
+ }
@@ -5,6 +5,11 @@ import { type SignupHandoverBinding } from "../shared/index.js";
5
5
  * in the return body / mail send). Previously two places called
6
6
  * `.toLowerCase()` — one source means no drift. */
7
7
  export declare function normalizeEmail(email: string): string;
8
+ export declare const SIGNUP_TOKEN_KEY_PREFIXES: {
9
+ readonly token: "signup:by-token:";
10
+ readonly email: "signup:by-email:";
11
+ readonly burn: "signup:burn:";
12
+ };
8
13
  /** Stores the pair bidirectionally and sets TTL on both keys.
9
14
  * Idempotent — re-writing the same token-email pair is fine. */
10
15
  export declare function storeSignupToken(redis: Redis, args: {
@@ -18,10 +18,15 @@ import { createSingleUseTokenStore, hashSingleUseToken, } from "../shared/index.
18
18
  export function normalizeEmail(email) {
19
19
  return email.toLowerCase();
20
20
  }
21
+ export const SIGNUP_TOKEN_KEY_PREFIXES = {
22
+ token: "signup:by-token:",
23
+ email: "signup:by-email:",
24
+ burn: "signup:burn:",
25
+ };
21
26
  const store = createSingleUseTokenStore({
22
- tokenPrefix: "signup:by-token:",
23
- subjectPrefix: "signup:by-email:",
24
- burnPrefix: "signup:burn:",
27
+ tokenPrefix: SIGNUP_TOKEN_KEY_PREFIXES.token,
28
+ subjectPrefix: SIGNUP_TOKEN_KEY_PREFIXES.email,
29
+ burnPrefix: SIGNUP_TOKEN_KEY_PREFIXES.burn,
25
30
  });
26
31
  /** Stores the pair bidirectionally and sets TTL on both keys.
27
32
  * Idempotent — re-writing the same token-email pair is fine. */
@@ -2,13 +2,13 @@ import { type ComponentType, type ReactNode } from "react";
2
2
  import { type LoginScreenProps } from "./login-screen.js";
3
3
  export type MfaVerifyComponentProps = {
4
4
  readonly challengeToken: string;
5
- readonly onSuccess?: () => void;
5
+ readonly onSuccess?: (landingPath?: string) => void;
6
6
  readonly onCancel?: () => void;
7
7
  };
8
8
  export type MfaSetupComponentProps = {
9
9
  readonly preauthSetupToken: string;
10
10
  readonly accountLabel: string;
11
- readonly onSuccess?: () => void;
11
+ readonly onSuccess?: (landingPath?: string) => void;
12
12
  readonly onCancel?: () => void;
13
13
  };
14
14
  export type LoginRouteOptions = {
@@ -11,7 +11,7 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
11
11
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
12
12
  import { useLocale } from "@cosmicdrift/kumiko-renderer";
13
13
  import { useEffect, useState } from "react";
14
- import { assertNavigableUrl, buildLoginRedirectUrl, followNextAfterLogin, } from "./auth-redirect.js";
14
+ import { assertNavigableUrl, buildLoginRedirectUrl, followNextOrLandingAfterLogin, } from "./auth-redirect.js";
15
15
  import { LoginScreen } from "./login-screen.js";
16
16
  import { SessionProvider, useSession } from "./session.js";
17
17
  import { SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
@@ -59,15 +59,15 @@ export function createLoginRoute(opts = {}) {
59
59
  if (status === "authenticated" && onAuthenticated)
60
60
  return null;
61
61
  if (challengeToken !== null && MfaVerifyComponent) {
62
- return (_jsx(MfaVerifyComponent, { challengeToken: challengeToken, onSuccess: () => {
62
+ return (_jsx(MfaVerifyComponent, { challengeToken: challengeToken, onSuccess: (landingPath) => {
63
63
  setChallengeToken(null);
64
- followNextAfterLogin();
64
+ followNextOrLandingAfterLogin(landingPath);
65
65
  }, onCancel: () => setChallengeToken(null) }));
66
66
  }
67
67
  // Pending preauthSetupToken from LoginScreen's onMfaSetupRequired. Same
68
68
  // reasoning as challengeToken — a UI-only transition, not session state.
69
69
  if (setupRequest !== null && MfaSetupComponent) {
70
- return (_jsx(MfaSetupComponent, { preauthSetupToken: setupRequest.preauthSetupToken, accountLabel: setupRequest.accountLabel, onSuccess: () => {
70
+ return (_jsx(MfaSetupComponent, { preauthSetupToken: setupRequest.preauthSetupToken, accountLabel: setupRequest.accountLabel, onSuccess: (landingPath) => {
71
71
  // MfaSetupPreauthScreen has no session to refresh itself with
72
72
  // (it runs pre-auth) — the gate owns the session, so it refreshes.
73
73
  // refresh() never rejects: a failed refresh surfaces as status
@@ -75,7 +75,7 @@ export function createLoginRoute(opts = {}) {
75
75
  // success path needs to clear setupRequest.
76
76
  void refresh().then(() => {
77
77
  setSetupRequest(null);
78
- followNextAfterLogin();
78
+ followNextOrLandingAfterLogin(landingPath);
79
79
  });
80
80
  }, onCancel: () => setSetupRequest(null) }));
81
81
  }
@@ -4,3 +4,4 @@ export declare function readNextFromSearch(search: string): string | null;
4
4
  export declare function assertNavigableUrl(url: string, optionName: string): void;
5
5
  export declare function buildLoginRedirectUrl(loginUrl: string, returnPath: string, currentOrigin: string): string;
6
6
  export declare function followNextAfterLogin(location?: Pick<Location, "search" | "pathname" | "replace">): boolean;
7
+ export declare function followNextOrLandingAfterLogin(landingPath: string | undefined, location?: Pick<Location, "search" | "pathname" | "replace">): boolean;
@@ -69,3 +69,17 @@ export function followNextAfterLogin(location = window.location) {
69
69
  location.replace(next);
70
70
  return true;
71
71
  }
72
+ // An explicit `next` (the page the user was sent away from) beats the server's
73
+ // landingPath (auth.postAuthLanding); without either the caller keeps its own
74
+ // client-side routing. landingPath is re-validated like `next`, so a
75
+ // misconfigured resolver cannot turn the login into an open redirect.
76
+ export function followNextOrLandingAfterLogin(landingPath, location = window.location) {
77
+ if (followNextAfterLogin(location))
78
+ return true;
79
+ if (landingPath === undefined || !isSafeNextPath(landingPath))
80
+ return false;
81
+ if (new URL(landingPath, "https://next.invalid").pathname === location.pathname)
82
+ return false;
83
+ location.replace(landingPath);
84
+ return true;
85
+ }
@@ -13,7 +13,7 @@ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
13
13
  import { useState } from "react";
14
14
  import { requestEmailVerification, } from "./auth-client.js";
15
15
  import { AuthCard } from "./auth-form-primitives.js";
16
- import { followNextAfterLogin } from "./auth-redirect.js";
16
+ import { followNextOrLandingAfterLogin } from "./auth-redirect.js";
17
17
  import { useSession } from "./session.js";
18
18
  // Map vom Reason-Code des Login-Handlers auf einen i18n-Key plus
19
19
  // optional extrahierte Interpolations-Parameter. Ungekannte Codes
@@ -66,7 +66,7 @@ export function LoginScreen({ title, subtitle, submitLabel, forgotPasswordHref,
66
66
  const res = await session.login({ email, password });
67
67
  setSubmitting(false);
68
68
  if (res.kind === "success") {
69
- followNextAfterLogin();
69
+ followNextOrLandingAfterLogin(res.data.landingPath);
70
70
  return;
71
71
  }
72
72
  if (res.kind === "mfa-challenge") {
@@ -34,7 +34,7 @@ export function UserMenu({ children, variant = "pill" }) {
34
34
  const hasName = user.displayName.length > 0;
35
35
  const displayName = hasName ? user.displayName : user.email;
36
36
  const avatarText = initials(displayName);
37
- const content = (_jsxs(DropdownMenuContent, { align: variant === "sidebar" ? "start" : "end", "aria-label": t("auth.user.menu.label"), children: [_jsxs(DropdownMenuLabel, { className: "text-xs", children: [_jsx("div", { className: "font-medium text-foreground truncate", children: displayName }), hasName && _jsx("div", { className: "truncate", children: user.email })] }), _jsx(DropdownMenuSeparator, {}), children !== undefined && (_jsxs(_Fragment, { children: [children, _jsx(DropdownMenuSeparator, {})] })), _jsxs(DropdownMenuItem, { onSelect: () => void logout(), children: [_jsx(LogOut, { className: "h-4 w-4" }), _jsx("span", { children: t("auth.user.menu.logout") })] })] }));
37
+ const content = (_jsxs(DropdownMenuContent, { align: variant === "sidebar" ? "start" : "end", "aria-label": t("auth.user.menu.label"), children: [_jsxs(DropdownMenuLabel, { className: "text-xs", children: [_jsx("div", { className: "font-medium text-foreground truncate", children: displayName }), hasName && _jsx("div", { className: "truncate", children: user.email })] }), _jsx(DropdownMenuSeparator, {}), children !== undefined && (_jsxs(_Fragment, { children: [children, _jsx(DropdownMenuSeparator, {})] })), _jsxs(DropdownMenuItem, { "data-testid": "user-menu-logout", onSelect: () => void logout(), children: [_jsx(LogOut, { className: "h-4 w-4" }), _jsx("span", { children: t("auth.user.menu.logout") })] })] }));
38
38
  // Sidebar-Footer: volle NavUser-Row (sidebar-07) als Dropdown-Trigger —
39
39
  // gleiche Optik wie SidebarUser, aber klickbar mit Logout/Profil.
40
40
  if (variant === "sidebar") {
@@ -1,4 +1,5 @@
1
1
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { secretsEnvSchema } from "../secrets/index.js";
2
3
  import { mfaRequiredConfigKey } from "./config.js";
3
4
  import { createDisableHandler } from "./handlers/disable.write.js";
4
5
  import { createEnableConfirmHandler } from "./handlers/enable-confirm.write.js";
@@ -81,6 +82,10 @@ export function createAuthMfaFeature(opts) {
81
82
  category: "identity",
82
83
  recommended: true,
83
84
  });
85
+ // Same field instances as `secrets`, so composeEnvSchema treats them as one
86
+ // shared variable; an app mounting auth-mfa without `secrets` still gets the
87
+ // KEK slot (and its _CIPHERTEXT twin) declared.
88
+ r.envSchema(secretsEnvSchema);
84
89
  r.requires("user");
85
90
  r.requires("config");
86
91
  // verify.write.ts dispatches "tenant:query:memberships" — without this
@@ -36,6 +36,7 @@ export function createEnableConfirmPreauthHandler(opts) {
36
36
  access: { roles: ["anonymous"], personalData: "public-intake" },
37
37
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
38
38
  escapeHatch: {
39
+ grants: ["systemIdentity", "unsafeRaw"],
39
40
  reason: "Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
40
41
  "user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user. " +
41
42
  "Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
@@ -28,6 +28,7 @@ export function createMfaVerifyHandler(opts) {
28
28
  access: { roles: ["anonymous"], personalData: "public-intake" },
29
29
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
30
30
  escapeHatch: {
31
+ grants: ["systemIdentity", "unsafeRaw"],
31
32
  reason: "Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
32
33
  "user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names. " +
33
34
  "Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
@@ -3,6 +3,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -98,6 +101,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
98
101
  readonly type: "text";
99
102
  readonly description?: string;
100
103
  readonly maxLength?: number;
104
+ readonly minLength?: number;
101
105
  readonly required?: boolean;
102
106
  readonly searchable?: boolean;
103
107
  readonly sortable?: boolean;
@@ -119,6 +123,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
119
123
  readonly type: "text";
120
124
  readonly description?: string;
121
125
  readonly maxLength?: number;
126
+ readonly minLength?: number;
122
127
  readonly required?: boolean;
123
128
  readonly searchable?: boolean;
124
129
  readonly sortable?: boolean;
@@ -140,6 +145,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
140
145
  readonly type: "text";
141
146
  readonly description?: string;
142
147
  readonly maxLength?: number;
148
+ readonly minLength?: number;
143
149
  readonly required?: boolean;
144
150
  readonly searchable?: boolean;
145
151
  readonly sortable?: boolean;
@@ -47,7 +47,7 @@ export const mfaEnableScreen = {
47
47
  confirm: {
48
48
  handler: AuthMfaHandlers.enableConfirm,
49
49
  fields: {
50
- code: { type: "text", required: true, maxLength: 6 },
50
+ code: { type: "text", required: true, minLength: 6, maxLength: 6 },
51
51
  },
52
52
  layout: { sections: [{ fields: ["code"] }] },
53
53
  carry: ["setupToken"],
@@ -57,7 +57,7 @@ export const mfaEnableScreen = {
57
57
  };
58
58
  // Either a 6-digit TOTP code or a 9-char recovery code proves possession —
59
59
  // same bounds as the disable/regenerate-recovery handler schemas.
60
- const possessionCodeField = { type: "text", required: true, maxLength: 9 };
60
+ const possessionCodeField = { type: "text", required: true, minLength: 6, maxLength: 9 };
61
61
  export const mfaDisableScreen = {
62
62
  id: MFA_DISABLE_SCREEN_ID,
63
63
  type: "actionForm",
@@ -11,5 +11,5 @@ export function MfaRecoveryCodesReveal({ codes, onDismiss, }) {
11
11
  const t = useTranslation();
12
12
  const { Button, Field, Input, Section } = usePrimitives();
13
13
  const [acknowledged, setAcknowledged] = useState(false);
14
- return (_jsxs(Section, { testId: "mfa-regenerate-reveal", actions: _jsx(Button, { variant: "primary", onClick: onDismiss, disabled: !acknowledged, children: t("auth.mfa.regenerate.done") }), children: [_jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.regenerate.newCodesTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.regenerate.newCodesHint") }), _jsx("code", { className: "inline-block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: codes.join("\n") })] }), _jsx(Field, { id: "mfa-regenerate-ack", label: t("auth.mfa.regenerate.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-regenerate-ack", name: "mfa-regenerate-ack", value: acknowledged, onChange: setAcknowledged }) })] }));
14
+ return (_jsxs(Section, { testId: "mfa-regenerate-reveal", children: [_jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.regenerate.newCodesTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.regenerate.newCodesHint") }), _jsx("code", { className: "inline-block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: codes.join("\n") })] }), _jsx(Field, { id: "mfa-regenerate-ack", label: t("auth.mfa.regenerate.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-regenerate-ack", name: "mfa-regenerate-ack", value: acknowledged, onChange: setAcknowledged }) }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { variant: "primary", onClick: onDismiss, disabled: !acknowledged, children: t("auth.mfa.regenerate.done") }) })] }));
15
15
  }
@@ -8,7 +8,7 @@ export type MfaSetupPreauthScreenProps = {
8
8
  readonly subtitle?: ReactNode;
9
9
  /** Fired once confirm succeeds — the caller owns session refresh, see the
10
10
  * file-level comment above. */
11
- readonly onSuccess?: () => void;
11
+ readonly onSuccess?: (landingPath?: string) => void;
12
12
  readonly onCancel?: () => void;
13
13
  };
14
14
  export declare function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title, subtitle, onSuccess, onCancel, }: MfaSetupPreauthScreenProps): ReactNode;