@cosmicdrift/kumiko-samples 0.353.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 (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
  5. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  6. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  7. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  8. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  14. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
  17. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  19. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  21. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  22. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  23. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  24. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  25. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  26. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  27. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  28. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  29. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  30. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  31. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  32. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  33. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  35. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
  36. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  37. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  38. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  39. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  40. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  41. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  42. package/packages/bundled-features/src/delivery/changes.json +6 -0
  43. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  44. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  45. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  47. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  48. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  50. package/packages/bundled-features/src/files/changes.json +24 -0
  51. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  52. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
  54. package/packages/bundled-features/src/folders/changes.json +6 -0
  55. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  56. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  57. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  58. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  59. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  60. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  61. package/packages/bundled-features/src/jobs/changes.json +30 -0
  62. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  63. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  64. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  65. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  66. package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
  67. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  68. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  69. package/packages/bundled-features/src/jobs/index.ts +2 -0
  70. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  71. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  72. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  73. package/packages/bundled-features/src/ledger/changes.json +23 -0
  74. package/packages/bundled-features/src/ledger/entity.ts +13 -5
  75. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  76. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  77. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  78. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  79. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  80. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  81. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  82. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  83. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  84. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  85. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  86. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  87. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  88. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  89. package/packages/bundled-features/src/shared/index.ts +2 -1
  90. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  91. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  92. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  93. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  94. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  96. package/packages/bundled-features/src/tenant/changes.json +12 -0
  97. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  98. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  100. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  101. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  102. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  104. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  105. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  106. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  107. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  108. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
  109. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
  110. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
  111. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  112. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  113. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  114. package/packages/bundled-features/src/user/changes.json +15 -0
  115. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  116. package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
  117. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  118. package/packages/bundled-features/src/user/seeding.ts +34 -0
  119. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  120. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  122. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  123. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  124. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
  125. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
  126. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  127. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  128. package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
  129. package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
  130. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  131. package/samples/apps/ui-walkthrough/package.json +3 -0
  132. package/samples/apps/use-all-bundled/README.md +1 -2
  133. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  134. package/samples/apps/user-data-rights-demo/README.md +1 -3
  135. package/samples/recipes/legal-pages/README.md +3 -4
  136. package/samples/recipes/managed-pages/README.md +2 -2
  137. package/samples/recipes/seed-migration/README.md +0 -2
@@ -21,26 +21,15 @@
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
23
 
24
- import { generateToken } from "@cosmicdrift/kumiko-framework/api";
25
24
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
26
25
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
27
- import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
28
26
  import * as z from "zod";
29
27
  import { findSignupHandoverProvider, type SignupHandoverBinding } from "../../shared/index.js";
30
- import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants.js";
31
28
  import type { AuthMailLocale } from "../email-templates.js";
32
- import { renderActivationEmail } from "../email-templates.js";
33
- import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
29
+ import { resolveHandlerMailLocale } from "../magic-link-mail.js";
34
30
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.js";
35
- import {
36
- invalidateExistingSignupToken,
37
- normalizeEmail,
38
- storeSignupHandover,
39
- storeSignupToken,
40
- takeOverSignupHandoverForResend,
41
- } from "../signup-token-store.js";
42
-
43
- const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
31
+ import { issueSignupActivation } from "../signup-activation.js";
32
+ import { storeSignupHandover, takeOverSignupHandoverForResend } from "../signup-token-store.js";
44
33
 
45
34
  const SignupRequestSchema = z.object({
46
35
  email: z.email(),
@@ -85,9 +74,6 @@ export type SignupRequestOptions = {
85
74
  };
86
75
 
87
76
  export function createSignupRequestHandler(opts: SignupRequestOptions) {
88
- const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES;
89
- const ttlSeconds = ttlMinutes * 60;
90
-
91
77
  return defineWriteHandler<"signup-request", typeof SignupRequestSchema, SignupRequestData>({
92
78
  name: "signup-request",
93
79
  schema: SignupRequestSchema,
@@ -138,94 +124,65 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
138
124
  // verified grant over to the new token instead of silently dropping it.
139
125
  const priorBinding = await takeOverSignupHandoverForResend(ctx.redis, email);
140
126
 
141
- // At most one live signup token per email: invalidate whatever's
142
- // there before minting the new one (see signup-token-store.ts).
143
- await invalidateExistingSignupToken(ctx.redis, email);
144
- // 32 random bytes = 256 bits unguessable randomness, base64url
145
- // encoded to 43 chars. Math.random used to be a bug here:
146
- // xorshift128+ has ~128 bits of state that's reconstructible after
147
- // ~5 observed outputs — an attacker could trigger their own
148
- // signup-requests and predict other users' tokens. generateToken
149
- // uses randomBytes from node:crypto, the same source as CSRF/
150
- // session tokens.
151
- const token = generateToken();
152
-
153
- const issuedAt = Temporal.Now.instant();
154
- const expiresAt = issuedAt.add({ seconds: ttlSeconds });
155
- const expiresAtIso = expiresAt.toString();
156
-
157
- await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
158
-
159
- // A handover grant in this request re-verifies and replaces the prior
160
- // binding; anything short of a fresh, verified grant (no provider
161
- // mounted, or verification failed) falls back to it instead —
162
- // dropping a still-live binding on a bad request would strand the
163
- // visitor's run. A throw here must never skip the mail below, or the
164
- // signup itself silently breaks for a bug in a provider this handler
165
- // doesn't own.
166
- let binding: SignupHandoverBinding | null = priorBinding;
167
- if (event.payload.handover) {
168
- const { entityType, token: grantToken } = event.payload.handover;
169
- const provider = findSignupHandoverProvider(ctx.registry);
170
- if (!provider) {
171
- ctx.log?.warn(
172
- "signup-request: handover payload present but no signupHandover provider mounted",
173
- );
174
- } else {
175
- try {
176
- const verified = await provider.verifyGrant({
177
- db: ctx.db.unsafeRaw(),
178
- registry: ctx.registry,
179
- entityType,
180
- token: grantToken,
181
- });
182
- if (verified) {
183
- binding = verified;
127
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
128
+ const redis = ctx.redis;
129
+
130
+ const issued = await issueSignupActivation({
131
+ redis,
132
+ notify: ctx.notify,
133
+ email,
134
+ appUrl: opts.appUrl,
135
+ timeZone: ctx.tz.user,
136
+ ...(opts.tokenTtlMinutes !== undefined && { tokenTtlMinutes: opts.tokenTtlMinutes }),
137
+ ...(opts.appName !== undefined && { appName: opts.appName }),
138
+ ...(locale !== undefined && { locale }),
139
+ // A handover grant in this request re-verifies and replaces the prior
140
+ // binding; anything short of a fresh, verified grant (no provider
141
+ // mounted, or verification failed) falls back to it instead —
142
+ // dropping a still-live binding on a bad request would strand the
143
+ // visitor's run. A throw here must never skip the mail, or the
144
+ // signup itself silently breaks for a bug in a provider this handler
145
+ // doesn't own.
146
+ onTokenStored: async ({ token, ttlSeconds }) => {
147
+ let binding: SignupHandoverBinding | null = priorBinding;
148
+ if (event.payload.handover) {
149
+ const { entityType, token: grantToken } = event.payload.handover;
150
+ const provider = findSignupHandoverProvider(ctx.registry);
151
+ if (!provider) {
152
+ ctx.log?.warn(
153
+ "signup-request: handover payload present but no signupHandover provider mounted",
154
+ );
184
155
  } else {
185
- ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
156
+ try {
157
+ const verified = await provider.verifyGrant({
158
+ db: ctx.db.unsafeRaw(),
159
+ registry: ctx.registry,
160
+ entityType,
161
+ token: grantToken,
162
+ });
163
+ if (verified) {
164
+ binding = verified;
165
+ } else {
166
+ ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
167
+ }
168
+ } catch {
169
+ ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
170
+ }
186
171
  }
187
- } catch {
188
- ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
189
172
  }
190
- }
191
- }
192
- if (binding) {
193
- await storeSignupHandover(ctx.redis, { token, binding, ttlSeconds });
194
- }
195
-
196
- // normalizeEmail from the store — one source of truth for
197
- // normalization; the delivery recipient + lookup path consistently
198
- // get the same format.
199
- const normalizedEmail = normalizeEmail(email);
200
-
201
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
202
-
203
- await dispatchMagicLinkMail(
204
- ctx.notify,
205
- {
206
- handlerName: "signup-request",
207
- notificationType: SIGNUP_NOTIFICATION_TYPE,
208
- renderContent: renderActivationEmail,
209
- },
210
- {
211
- email: normalizedEmail,
212
- appUrl: opts.appUrl,
213
- token,
214
- expiresAt: expiresAtIso,
215
- issuedAt: issuedAt.toString(),
216
- timeZone: ctx.tz.user,
217
- ...(opts.appName !== undefined && { appName: opts.appName }),
218
- locale,
173
+ if (binding) {
174
+ await storeSignupHandover(redis, { token, binding, ttlSeconds });
175
+ }
219
176
  },
220
- );
177
+ });
221
178
 
222
179
  return {
223
180
  isSuccess: true,
224
181
  data: {
225
182
  kind: "signup-requested",
226
- email: normalizedEmail,
227
- token,
228
- expiresAt: expiresAtIso,
183
+ email: issued.email,
184
+ token: issued.token,
185
+ expiresAt: issued.expiresAt,
229
186
  },
230
187
  };
231
188
  },
@@ -109,6 +109,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
109
109
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
110
110
  additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
111
111
  escapeHatch: {
112
+ grants: ["systemIdentity"],
112
113
  reason:
113
114
  "Anonymous requester has no session — looks up the target user by email via " +
114
115
  "ctx.queryAs(SYSTEM, user:findForAuth) to mint the out-of-band token.",
@@ -29,6 +29,7 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
29
29
  access: { roles: ["anonymous"] },
30
30
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
31
31
  escapeHatch: {
32
+ grants: ["systemIdentity"],
32
33
  reason:
33
34
  "Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
34
35
  "target user via ctx.writeAs(SYSTEM, user:update) to set emailVerified.",
@@ -58,3 +58,15 @@ export {
58
58
  // statt eigene HMAC-Logik zu duplizieren. Purpose-string diskriminiert
59
59
  // Cross-Replay zwischen Flows.
60
60
  export { signToken, TokenPurpose, type VerifyResult, verifyToken } from "./signed-token.js";
61
+ export {
62
+ type IssuedSignupActivation,
63
+ type IssueSignupActivationArgs,
64
+ issueSignupActivation,
65
+ SIGNUP_ACTIVATION_NOTIFICATION_TYPE,
66
+ } from "./signup-activation.js";
67
+ export {
68
+ invalidateExistingSignupToken,
69
+ normalizeEmail,
70
+ SIGNUP_TOKEN_KEY_PREFIXES,
71
+ storeSignupToken,
72
+ } from "./signup-token-store.js";
@@ -22,6 +22,7 @@ import {
22
22
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
23
23
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
24
24
  import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
25
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
25
26
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
26
27
  import { hashPassword } from "../shared/index.js";
27
28
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
@@ -29,7 +30,13 @@ import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenan
29
30
  // kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
30
31
  import { USER_STATUS, userTable } from "../user/schema/user.js";
31
32
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
32
- import { reconcileSeededUserEmailVerified, seedUser } from "../user/seeding.js";
33
+ import {
34
+ reconcileSeededUserEmailVerified,
35
+ rewriteSeededUserEmail,
36
+ seedUser,
37
+ } from "../user/seeding.js";
38
+
39
+ const log = createFallbackLogger("auth-email-password-seeding");
33
40
 
34
41
  // Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
35
42
  // Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
@@ -126,6 +133,8 @@ export type ProvisionSignupAccountOptions = {
126
133
  readonly tenantName: string;
127
134
  readonly tenantId: TenantId;
128
135
  readonly memberRoles?: readonly string[];
136
+ /** Stored on the new user row so the first session already carries the locale claim. */
137
+ readonly locale?: string;
129
138
  };
130
139
 
131
140
  export async function provisionSignupAccount(
@@ -166,6 +175,7 @@ export async function provisionSignupAccount(
166
175
  password: options.password,
167
176
  displayName: options.displayName,
168
177
  emailVerified: true,
178
+ ...(options.locale !== undefined && { locale: options.locale }),
169
179
  },
170
180
  hooks,
171
181
  );
@@ -306,15 +316,30 @@ async function findExistingUserIdForEmail(
306
316
  status: { ne: USER_STATUS.Deleted },
307
317
  });
308
318
  const matches: ActiveUserRow[] = [];
319
+ let undecryptableRows = 0;
309
320
  for (const row of rows) {
310
321
  // Compared byte-exact, deliberately not case-folded: email_bidx is an
311
322
  // HMAC over the raw value and login matches case-sensitively, so a case
312
323
  // variant is a different account to every other code path.
313
- const decrypted = await decryptPiiFieldValues({ email: row.email }, ["email"], kms, {
314
- requestId: "seed-admin-guarded",
315
- });
324
+ // One undecryptable foreign row (missing key row, ciphertext from another
325
+ // KMS store) must not abort the boot seed: the account we look for is
326
+ // decryptable by definition.
327
+ let decrypted: Record<string, unknown>;
328
+ try {
329
+ decrypted = await decryptPiiFieldValues({ email: row.email }, ["email"], kms, {
330
+ requestId: "seed-admin-guarded",
331
+ });
332
+ } catch {
333
+ undecryptableRows += 1;
334
+ continue;
335
+ }
316
336
  if (decrypted["email"] === email) matches.push(row);
317
337
  }
338
+ if (undecryptableRows > 0) {
339
+ log.warn(
340
+ `seedAdminGuarded: skipped ${undecryptableRows} user row(s) whose email could not be decrypted`,
341
+ );
342
+ }
318
343
  return matches.length === 0 ? undefined : pickCanonicalUserId(matches);
319
344
  }
320
345
 
@@ -351,9 +376,15 @@ export async function seedAdminGuarded(
351
376
  if (existingId === undefined) return seedAdmin(db, options);
352
377
 
353
378
  const by = options.by ?? TestUsers.systemAdmin;
354
- if (options.emailVerified === true) {
355
- const existingRow = await fetchOne(db, userTable, { id: existingId });
356
- if (existingRow) await reconcileSeededUserEmailVerified(db, existingRow, by);
379
+ const existingRow = await fetchOne(db, userTable, { id: existingId });
380
+ if (existingRow) {
381
+ // Re-write the email so email_bidx is recomputed; without it the account
382
+ // stays unfindable for login and every boot re-scans the whole table.
383
+ await rewriteSeededUserEmail(db, existingRow, options.email, by);
384
+ if (options.emailVerified === true) {
385
+ const refreshed = (await fetchOne(db, userTable, { id: existingId })) ?? existingRow;
386
+ await reconcileSeededUserEmailVerified(db, refreshed, by);
387
+ }
357
388
  }
358
389
  for (const m of options.memberships) {
359
390
  await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
@@ -0,0 +1,83 @@
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
+
7
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
8
+ import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
10
+ import type { Redis } from "ioredis";
11
+ import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "./constants.js";
12
+ import { renderActivationEmail } from "./email-templates.js";
13
+ import { dispatchMagicLinkMail } from "./magic-link-mail.js";
14
+ import {
15
+ invalidateExistingSignupToken,
16
+ normalizeEmail,
17
+ storeSignupToken,
18
+ } from "./signup-token-store.js";
19
+
20
+ export const SIGNUP_ACTIVATION_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
21
+
22
+ export type IssueSignupActivationArgs = {
23
+ readonly redis: Redis;
24
+ readonly notify: NotifyFn | undefined;
25
+ readonly email: string;
26
+ readonly appUrl: string | ((locale: string) => string);
27
+ readonly appName?: string;
28
+ readonly locale?: string;
29
+ readonly timeZone?: string;
30
+ readonly tokenTtlMinutes?: number;
31
+ /** Runs after the token is stored and before the mail goes out. */
32
+ readonly onTokenStored?: (stored: {
33
+ readonly token: string;
34
+ readonly ttlSeconds: number;
35
+ }) => Promise<void>;
36
+ };
37
+
38
+ export type IssuedSignupActivation = {
39
+ readonly email: string;
40
+ readonly token: string;
41
+ readonly expiresAt: string;
42
+ };
43
+
44
+ export async function issueSignupActivation(
45
+ args: IssueSignupActivationArgs,
46
+ ): Promise<IssuedSignupActivation> {
47
+ const ttlSeconds = (args.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES) * 60;
48
+
49
+ // At most one live signup token per email.
50
+ await invalidateExistingSignupToken(args.redis, args.email);
51
+ // 256 bits from node:crypto — never Math.random: xorshift128+ state is
52
+ // reconstructible from a few observed outputs, which would let an attacker
53
+ // predict other users' tokens.
54
+ const token = generateToken();
55
+
56
+ const issuedAt = Temporal.Now.instant();
57
+ const expiresAt = issuedAt.add({ seconds: ttlSeconds }).toString();
58
+
59
+ await storeSignupToken(args.redis, { email: args.email, token, ttlSeconds });
60
+ await args.onTokenStored?.({ token, ttlSeconds });
61
+
62
+ const email = normalizeEmail(args.email);
63
+ await dispatchMagicLinkMail(
64
+ args.notify,
65
+ {
66
+ handlerName: "signup-request",
67
+ notificationType: SIGNUP_ACTIVATION_NOTIFICATION_TYPE,
68
+ renderContent: renderActivationEmail,
69
+ },
70
+ {
71
+ email,
72
+ appUrl: args.appUrl,
73
+ token,
74
+ expiresAt,
75
+ issuedAt: issuedAt.toString(),
76
+ ...(args.timeZone !== undefined && { timeZone: args.timeZone }),
77
+ ...(args.appName !== undefined && { appName: args.appName }),
78
+ ...(args.locale !== undefined && { locale: args.locale }),
79
+ },
80
+ );
81
+
82
+ return { email, token, expiresAt };
83
+ }
@@ -26,10 +26,16 @@ export function normalizeEmail(email: string): string {
26
26
  return email.toLowerCase();
27
27
  }
28
28
 
29
+ export const SIGNUP_TOKEN_KEY_PREFIXES = {
30
+ token: "signup:by-token:",
31
+ email: "signup:by-email:",
32
+ burn: "signup:burn:",
33
+ } as const;
34
+
29
35
  const store = createSingleUseTokenStore({
30
- tokenPrefix: "signup:by-token:",
31
- subjectPrefix: "signup:by-email:",
32
- burnPrefix: "signup:burn:",
36
+ tokenPrefix: SIGNUP_TOKEN_KEY_PREFIXES.token,
37
+ subjectPrefix: SIGNUP_TOKEN_KEY_PREFIXES.email,
38
+ burnPrefix: SIGNUP_TOKEN_KEY_PREFIXES.burn,
33
39
  });
34
40
 
35
41
  /** Stores the pair bidirectionally and sets TTL on both keys.
@@ -14,7 +14,7 @@ import { type ComponentType, type ReactNode, useEffect, useState } from "react";
14
14
  import {
15
15
  assertNavigableUrl,
16
16
  buildLoginRedirectUrl,
17
- followNextAfterLogin,
17
+ followNextOrLandingAfterLogin,
18
18
  } from "./auth-redirect.js";
19
19
  import { LoginScreen, type LoginScreenProps } from "./login-screen.js";
20
20
  import { SessionProvider, useSession } from "./session.js";
@@ -26,7 +26,7 @@ import { SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
26
26
  // wire auth-mfa's MfaVerifyScreen in here via EmailPasswordClientOptions.
27
27
  export type MfaVerifyComponentProps = {
28
28
  readonly challengeToken: string;
29
- readonly onSuccess?: () => void;
29
+ readonly onSuccess?: (landingPath?: string) => void;
30
30
  readonly onCancel?: () => void;
31
31
  };
32
32
 
@@ -36,7 +36,7 @@ export type MfaVerifyComponentProps = {
36
36
  export type MfaSetupComponentProps = {
37
37
  readonly preauthSetupToken: string;
38
38
  readonly accountLabel: string;
39
- readonly onSuccess?: () => void;
39
+ readonly onSuccess?: (landingPath?: string) => void;
40
40
  readonly onCancel?: () => void;
41
41
  };
42
42
 
@@ -113,9 +113,9 @@ export function createLoginRoute(
113
113
  return (
114
114
  <MfaVerifyComponent
115
115
  challengeToken={challengeToken}
116
- onSuccess={() => {
116
+ onSuccess={(landingPath) => {
117
117
  setChallengeToken(null);
118
- followNextAfterLogin();
118
+ followNextOrLandingAfterLogin(landingPath);
119
119
  }}
120
120
  onCancel={() => setChallengeToken(null)}
121
121
  />
@@ -128,7 +128,7 @@ export function createLoginRoute(
128
128
  <MfaSetupComponent
129
129
  preauthSetupToken={setupRequest.preauthSetupToken}
130
130
  accountLabel={setupRequest.accountLabel}
131
- onSuccess={() => {
131
+ onSuccess={(landingPath) => {
132
132
  // MfaSetupPreauthScreen has no session to refresh itself with
133
133
  // (it runs pre-auth) — the gate owns the session, so it refreshes.
134
134
  // refresh() never rejects: a failed refresh surfaces as status
@@ -136,7 +136,7 @@ export function createLoginRoute(
136
136
  // success path needs to clear setupRequest.
137
137
  void refresh().then(() => {
138
138
  setSetupRequest(null);
139
- followNextAfterLogin();
139
+ followNextOrLandingAfterLogin(landingPath);
140
140
  });
141
141
  }}
142
142
  onCancel={() => setSetupRequest(null)}
@@ -72,3 +72,18 @@ export function followNextAfterLogin(
72
72
  location.replace(next);
73
73
  return true;
74
74
  }
75
+
76
+ // An explicit `next` (the page the user was sent away from) beats the server's
77
+ // landingPath (auth.postAuthLanding); without either the caller keeps its own
78
+ // client-side routing. landingPath is re-validated like `next`, so a
79
+ // misconfigured resolver cannot turn the login into an open redirect.
80
+ export function followNextOrLandingAfterLogin(
81
+ landingPath: string | undefined,
82
+ location: Pick<Location, "search" | "pathname" | "replace"> = window.location,
83
+ ): boolean {
84
+ if (followNextAfterLogin(location)) return true;
85
+ if (landingPath === undefined || !isSafeNextPath(landingPath)) return false;
86
+ if (new URL(landingPath, "https://next.invalid").pathname === location.pathname) return false;
87
+ location.replace(landingPath);
88
+ return true;
89
+ }
@@ -17,7 +17,7 @@ import {
17
17
  requestEmailVerification,
18
18
  } from "./auth-client.js";
19
19
  import { AuthCard } from "./auth-form-primitives.js";
20
- import { followNextAfterLogin } from "./auth-redirect.js";
20
+ import { followNextOrLandingAfterLogin } from "./auth-redirect.js";
21
21
  import { useSession } from "./session.js";
22
22
 
23
23
  // Resend-Status für den "Bestätigungs-Mail erneut senden"-Flow, der bei
@@ -144,7 +144,7 @@ export function LoginScreen({
144
144
  const res = await session.login({ email, password });
145
145
  setSubmitting(false);
146
146
  if (res.kind === "success") {
147
- followNextAfterLogin();
147
+ followNextOrLandingAfterLogin(res.data.landingPath);
148
148
  return;
149
149
  }
150
150
  if (res.kind === "mfa-challenge") {
@@ -78,7 +78,7 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
78
78
  <DropdownMenuSeparator />
79
79
  </>
80
80
  )}
81
- <DropdownMenuItem onSelect={() => void logout()}>
81
+ <DropdownMenuItem data-testid="user-menu-logout" onSelect={() => void logout()}>
82
82
  <LogOut className="h-4 w-4" />
83
83
  <span>{t("auth.user.menu.logout")}</span>
84
84
  </DropdownMenuItem>
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "auth-mfa declares the master-key env slots, so composeEnvSchema requires KUMIKO_SECRETS_MASTER_KEY_V1 when auth-mfa is mounted",
6
+ "detail": "Review batch H1: framework parts of consumer-app findings.\n- `auth-email-password` exports `issueSignupActivation` (invalidate, mint, store and mail an activation link; the signup-request handler now runs on it), `SIGNUP_ACTIVATION_NOTIFICATION_TYPE`, `storeSignupToken`, `invalidateExistingSignupToken`, `normalizeEmail` and `SIGNUP_TOKEN_KEY_PREFIXES`.\n- `auth-mfa` declares the master-key env slots (`KUMIKO_SECRETS_MASTER_KEY_V1`, `_CURRENT_VERSION`, the `_CIPHERTEXT` twin) as a shared fragment with `secrets`, so a rotated `V<n>` slot is unpacked even without `secrets` mounted.\n- `user-data-rights` exports `TENANT_MODEL_CONFIG_KEY`; `createTemplateResolverApi` takes a `DbRunner`.\n- The user menu's logout item carries `data-testid=\"user-menu-logout\"`.\n- renderer-web: a `Field` description is linked to its text input via `aria-describedby`; the facet filter dropdown is capped to the available viewport height and scrolls; `createBrowserLocaleResolver` takes `normalizeLocale` and the default storage key is exported as `BROWSER_LOCALE_STORAGE_KEY`.\n- testing: `loginViaApi(request, credentials, { bucketKey })`, `CLIENT_IP_HEADER` from `@cosmicdrift/kumiko-testing/e2e`, and a Playwright-free `@cosmicdrift/kumiko-testing/e2e/constants` subpath (`KUMIKO_SECRETS_MASTER_KEY_V1`).",
7
+ "migration": "Apps that bring their own masterKey provider instead of the env KEK add \"auth-mfa\" to composeEnvSchema's optionalFeatures, like they already do for \"secrets\". Required wins as soon as auth-mfa is not in optionalFeatures, even when `secrets` is listed there."
8
+ },
2
9
  {
3
10
  "version": "0.341.0",
4
11
  "type": "breaking",
@@ -1,5 +1,6 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { secretsEnvSchema } from "../secrets/index.js";
3
4
  import { type MfaRequiredPolicy, mfaRequiredConfigKey } from "./config.js";
4
5
  import { createDisableHandler } from "./handlers/disable.write.js";
5
6
  import { createEnableConfirmHandler } from "./handlers/enable-confirm.write.js";
@@ -125,6 +126,10 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
125
126
  category: "identity",
126
127
  recommended: true,
127
128
  });
129
+ // Same field instances as `secrets`, so composeEnvSchema treats them as one
130
+ // shared variable; an app mounting auth-mfa without `secrets` still gets the
131
+ // KEK slot (and its _CIPHERTEXT twin) declared.
132
+ r.envSchema(secretsEnvSchema);
128
133
  r.requires("user");
129
134
  r.requires("config");
130
135
  // verify.write.ts dispatches "tenant:query:memberships" — without this
@@ -71,6 +71,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
71
71
  access: { roles: ["anonymous"], personalData: "public-intake" },
72
72
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
73
73
  escapeHatch: {
74
+ grants: ["systemIdentity", "unsafeRaw"],
74
75
  reason:
75
76
  "Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
76
77
  "user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user. " +
@@ -48,6 +48,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
48
48
  access: { roles: ["anonymous"], personalData: "public-intake" },
49
49
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
50
50
  escapeHatch: {
51
+ grants: ["systemIdentity", "unsafeRaw"],
51
52
  reason:
52
53
  "Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
53
54
  "user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names. " +
@@ -22,14 +22,7 @@ export function MfaRecoveryCodesReveal({
22
22
  const [acknowledged, setAcknowledged] = useState(false);
23
23
 
24
24
  return (
25
- <Section
26
- testId="mfa-regenerate-reveal"
27
- actions={
28
- <Button variant="primary" onClick={onDismiss} disabled={!acknowledged}>
29
- {t("auth.mfa.regenerate.done")}
30
- </Button>
31
- }
32
- >
25
+ <Section testId="mfa-regenerate-reveal">
33
26
  <div className="flex flex-col items-center gap-2 text-center">
34
27
  <span className="text-sm font-semibold">{t("auth.mfa.regenerate.newCodesTitle")}</span>
35
28
  <span className="text-xs text-muted-foreground">
@@ -48,6 +41,11 @@ export function MfaRecoveryCodesReveal({
48
41
  onChange={setAcknowledged}
49
42
  />
50
43
  </Field>
44
+ <div className="flex justify-end">
45
+ <Button variant="primary" onClick={onDismiss} disabled={!acknowledged}>
46
+ {t("auth.mfa.regenerate.done")}
47
+ </Button>
48
+ </div>
51
49
  </Section>
52
50
  );
53
51
  }