@cosmicdrift/kumiko-samples 0.344.0 → 0.345.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 (21) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +31 -12
  5. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  6. package/packages/bundled-features/src/auth-email-password/constants.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +9 -2
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +33 -10
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +32 -15
  10. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +60 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -1
  12. package/packages/bundled-features/src/auth-email-password/i18n.ts +2 -0
  13. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +20 -7
  14. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -1
  15. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  16. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -1
  17. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +10 -0
  18. package/samples/apps/use-all-bundled/feature-manifest.json +2 -1
  19. package/samples/recipes/i18n/README.md +1 -1
  20. package/samples/recipes/rate-limiting/README.md +1 -1
  21. package/samples/recipes/webhook-step/README.md +8 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.344.0",
3
+ "version": "0.345.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.344.0",
3
+ "version": "0.345.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.345.0",
4
+ "type": "fix",
5
+ "title": "Escape-hatch retention loads only expirable rows",
6
+ "detail": "Escape-hatch audit retention loads only rows that can expire\nThe retention job used to load every escapeHatchUse event to decide which ones to prune. It now reads the storing tenants with one grouped query and loads only events older than each storing tenant's cutoff. The same rows are pruned as before."
7
+ },
2
8
  {
3
9
  "version": "0.343.0",
4
10
  "type": "improvement",
@@ -1,4 +1,4 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { aggregateWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  COMPLIANCE_PROFILES,
4
4
  subtractRetentionSpec,
@@ -82,10 +82,20 @@ function targetTenantOf(payload: unknown): TenantId | null {
82
82
  return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
83
83
  }
84
84
 
85
- async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
86
- const rows = await selectMany<AuditEventRow>(db, eventsTable, {
87
- aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
85
+ async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]> {
86
+ const groups = await aggregateWhere(
87
+ db,
88
+ eventsTable,
89
+ { measure: { fn: "count" }, groupBy: [{ field: "tenantId" }] },
90
+ { aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE },
91
+ );
92
+ return groups.flatMap((group) => {
93
+ const tenantId = parseTenantId(group.keys[0]);
94
+ return tenantId ? [tenantId] : [];
88
95
  });
96
+ }
97
+
98
+ async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
89
99
  const now = getTemporal().Now.instant();
90
100
  const cutoffByTenant = new Map<TenantId, Temporal.Instant>();
91
101
  const cutoffOf = async (tenantId: TenantId): Promise<Temporal.Instant> => {
@@ -96,15 +106,24 @@ async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
96
106
  return cutoff;
97
107
  };
98
108
 
109
+ // The effective cutoff is never later than the storing tenant's, so only rows older than
110
+ // that one can expire; everything younger stays in the database.
99
111
  const expiredAggregateIds: string[] = [];
100
- for (const row of rows) {
101
- const targetTenantId = targetTenantOf(row.payload);
102
- const storeCutoff = await cutoffOf(row.tenantId);
103
- const cutoff = targetTenantId
104
- ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
105
- : storeCutoff;
106
- if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
107
- expiredAggregateIds.push(row.aggregateId);
112
+ for (const storingTenantId of await storingTenantIds(db)) {
113
+ const storeCutoff = await cutoffOf(storingTenantId);
114
+ const candidates = await selectMany<AuditEventRow>(db, eventsTable, {
115
+ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
116
+ tenantId: storingTenantId,
117
+ createdAt: { lt: storeCutoff },
118
+ });
119
+ for (const row of candidates) {
120
+ const targetTenantId = targetTenantOf(row.payload);
121
+ const cutoff = targetTenantId
122
+ ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
123
+ : storeCutoff;
124
+ if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
125
+ expiredAggregateIds.push(row.aggregateId);
126
+ }
108
127
  }
109
128
  }
110
129
 
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.345.0",
4
+ "type": "fix",
5
+ "title": "invite-signup-complete enforces the MFA gate before issuing a session",
6
+ "detail": "`invite-signup-complete` now applies the same MFA gate as `invite-accept-with-login`. The account is still created and the invitation accepted, but an invitee whose role requires MFA (for example an admin invitation under the `admins` policy) receives the MFA step instead of a session. Member invitations keep receiving a session directly."
7
+ },
8
+ {
9
+ "version": "0.345.0",
10
+ "type": "fix",
11
+ "title": "signup-confirm enforces the MFA gate before issuing a session",
12
+ "detail": "`POST /auth/signup-confirm` now runs the login's MFA gate before it issues the first session. Self-signup makes the new user TenantAdmin of a fresh tenant, so under an MFA policy that covers admins the route used to hand out an admin session without a second factor. The account, the tenant and a bound handover claim are still created, but when the gate applies the route answers with the login contract (`mfaSetupRequired` plus `preauthSetupToken`, or `mfaRequired` plus `challengeToken`) and sets no cookies. `SignupCompleteScreen` then tells the user the account is active and sends them to sign in, where they set up the second factor. Without `mfaStatusChecker` nothing changes. Two exported types change shape: `SignupConfirmData` gains the two MFA variants, and `confirmSignup` now resolves to `SignupConfirmResult` with `kind: \"signed-in\"` (the previous `SignupConfirmSuccess` fields) or `kind: \"mfa-pending\"`. The handler and `invite-signup-complete` also run the gate before they delete the token, so an error in the MFA check leaves the link usable for a retry."
13
+ },
14
+ {
15
+ "version": "0.345.0",
16
+ "type": "fix",
17
+ "title": "switch-tenant enforces the MFA gate before issuing a session",
18
+ "detail": "`POST /auth/switch-tenant` now runs the same MFA gate as login before it issues the new session. A user who is a plain member in one tenant and an admin in another can no longer reach an admin session without a second factor by switching. When the target tenant requires MFA the route answers with the login contract (`mfaRequired` plus `challengeToken`, or `mfaSetupRequired` plus `preauthSetupToken`) and sets no cookies. The gate runs in a new system-only handler, `auth-email-password:write:switch-tenant-mfa-gate`, which resolves the membership and roles itself; `runDevApp` and `runProdApp` wire it as `switchTenantMfaGateHandler` whenever auth-mfa is mounted."
19
+ },
20
+ {
21
+ "version": "0.345.0",
22
+ "type": "fix",
23
+ "title": "Token mail expiry uses the recipient's profile time zone",
24
+ "detail": "Token mails show the expiry in the recipient's own time zone\nPassword reset, email verification and account unlock are requested anonymously, so `ctx.tz.user` only held the tenant default or UTC. These mails now use the recipient's profile time zone when it is a valid IANA zone and fall back to the previous value otherwise. Signup and invite mails keep the fallback because the recipient has no profile yet."
25
+ },
2
26
  {
3
27
  "version": "0.343.0",
4
28
  "type": "improvement",
@@ -46,6 +46,7 @@ export const AuthHandlers = {
46
46
  inviteAccept: "auth-email-password:write:invite-accept",
47
47
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
48
48
  inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
49
+ switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
49
50
  inviteCancel: "auth-email-password:write:invite-cancel",
50
51
  } as const;
51
52
 
@@ -28,6 +28,7 @@ import {
28
28
  createSignupRequestHandler,
29
29
  type SignupRequestOptions,
30
30
  } from "./handlers/signup-request.write.js";
31
+ import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
31
32
  import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
32
33
 
33
34
  /**
@@ -221,6 +222,10 @@ export function createAuthEmailPasswordFeature(
221
222
  logout: r.writeHandler(logoutWrite),
222
223
  };
223
224
 
225
+ if (opts.mfaStatusChecker) {
226
+ r.writeHandler(createSwitchTenantMfaGateHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
227
+ }
228
+
224
229
  if (opts.passwordReset) {
225
230
  r.writeHandler(createRequestPasswordResetHandler(opts.passwordReset));
226
231
  r.writeHandler(createResetPasswordHandler(opts.passwordReset));
@@ -242,7 +247,7 @@ export function createAuthEmailPasswordFeature(
242
247
 
243
248
  if (opts.signup) {
244
249
  r.writeHandler(createSignupRequestHandler(opts.signup));
245
- r.writeHandler(createSignupConfirmHandler());
250
+ r.writeHandler(createSignupConfirmHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
246
251
  }
247
252
 
248
253
  if (opts.invite) {
@@ -258,7 +263,9 @@ export function createAuthEmailPasswordFeature(
258
263
  strictEmailVerification: strictVerification,
259
264
  }),
260
265
  );
261
- r.writeHandler(createInviteSignupCompleteHandler());
266
+ r.writeHandler(
267
+ createInviteSignupCompleteHandler({ mfaStatusChecker: opts.mfaStatusChecker }),
268
+ );
262
269
  }
263
270
 
264
271
  if (opts.accountUnlock) {
@@ -60,18 +60,23 @@ import {
60
60
  import { passwordSchema } from "../password-policy.js";
61
61
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
62
62
  import { seedUserWithPassword } from "../seeding.js";
63
+ import { gateEnforceMfa, type LoginHandlerOptions, type LoginResult } from "./login.write.js";
63
64
 
64
65
  const InviteSignupCompleteSchema = z.object({
65
66
  token: z.string().min(1),
66
67
  password: passwordSchema,
67
68
  });
68
69
 
69
- export type InviteSignupCompleteData = {
70
- readonly kind: "auth-session";
71
- readonly session: SessionUser;
72
- readonly tenantId: TenantId;
73
- readonly role: string;
74
- };
70
+ export type InviteSignupCompleteData =
71
+ | {
72
+ readonly kind: "auth-session";
73
+ readonly session: SessionUser;
74
+ readonly tenantId: TenantId;
75
+ readonly role: string;
76
+ }
77
+ | Exclude<LoginResult, { readonly kind: "auth-session" }>;
78
+
79
+ export type InviteSignupCompleteOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
75
80
 
76
81
  const invitationExecutor = createEventStoreExecutor(
77
82
  tenantInvitationsTable,
@@ -82,7 +87,7 @@ const invitationExecutor = createEventStoreExecutor(
82
87
  const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON =
83
88
  "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists.";
84
89
 
85
- export function createInviteSignupCompleteHandler() {
90
+ export function createInviteSignupCompleteHandler(opts: InviteSignupCompleteOptions = {}) {
86
91
  return defineWriteHandler<
87
92
  "invite-signup-complete",
88
93
  typeof InviteSignupCompleteSchema,
@@ -190,14 +195,32 @@ export function createInviteSignupCompleteHandler() {
190
195
  );
191
196
  if (!updateResult.isSuccess) return updateResult;
192
197
 
198
+ // buildSessionRoles calls stripForbiddenMembershipRoles internally —
199
+ // a reserved role on the invitation itself must never reach the session.
200
+ const mergedRoles = buildSessionRoles(invitationGlobalRoles, [invitationRole]);
201
+
202
+ // Same gate as invite-accept-with-login: the account exists after this
203
+ // call, but an MFA-gated role gets the MFA step instead of a session.
204
+ // It runs before the token is deleted so a throwing check leaves the link retryable.
205
+ const mfaGate = await gateEnforceMfa(
206
+ ctx,
207
+ { mfaStatusChecker: opts.mfaStatusChecker },
208
+ userId,
209
+ invitationTenantId,
210
+ mergedRoles,
211
+ );
212
+
193
213
  await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
194
214
 
215
+ if (mfaGate !== undefined) {
216
+ committed = true;
217
+ return { isSuccess: true, data: mfaGate };
218
+ }
219
+
195
220
  const session: SessionUser = {
196
221
  id: userId,
197
222
  tenantId: invitationTenantId,
198
- // buildSessionRoles calls stripForbiddenMembershipRoles internally —
199
- // a reserved role on the invitation itself must never reach the session.
200
- roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
223
+ roles: mergedRoles,
201
224
  };
202
225
 
203
226
  committed = true;
@@ -56,26 +56,29 @@ import {
56
56
  getSignupHandover,
57
57
  unburnSignupToken,
58
58
  } from "../signup-token-store.js";
59
+ import { gateEnforceMfa, type LoginHandlerOptions, type LoginResult } from "./login.write.js";
59
60
 
60
61
  const SignupConfirmSchema = z.object({
61
62
  token: z.string().min(8),
62
63
  password: passwordSchema,
63
64
  });
64
65
 
65
- // Mirror der login-handler-Shape (kind: "auth-session", session: SessionUser)
66
- // damit die Route-Layer den signup-confirm-success genauso behandeln kann
67
- // wie einen erfolgreichen login: JWT-Mint, Cookies setzen, Session-Body
68
- // returnen. Der zusätzliche tenantKey landet als sibling am data-objekt
69
- // (NICHT in SessionUser — der ist generic, tenantKey ist signup-spezifisch
70
- // für den Post-Signup-Redirect zu /<tenantKey>/).
71
- export type SignupConfirmData = {
72
- readonly kind: "auth-session";
73
- readonly session: SessionUser;
74
- readonly tenantKey: string;
75
- // Present only when a bound tenant-handover grant existed AND its claim
76
- // actually succeeded (see the handler body below).
77
- readonly handover?: { readonly entityType: string; readonly id: string };
78
- };
66
+ // Mirrors the login handler's session shape so the route treats a successful
67
+ // confirm like a login. tenantKey stays a sibling of the session: it is only
68
+ // for the post-signup redirect, SessionUser is generic. When the MFA gate
69
+ // applies, the MFA step is returned instead of a session.
70
+ export type SignupConfirmData =
71
+ | {
72
+ readonly kind: "auth-session";
73
+ readonly session: SessionUser;
74
+ readonly tenantKey: string;
75
+ // Present only when a bound tenant-handover grant existed AND its claim
76
+ // actually succeeded (see the handler body below).
77
+ readonly handover?: { readonly entityType: string; readonly id: string };
78
+ }
79
+ | Exclude<LoginResult, { readonly kind: "auth-session" }>;
80
+
81
+ export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
79
82
 
80
83
  const SIGNUP_CONFIRM_PROVISION_REASON =
81
84
  "provisions a new tenant, its first user and membership before any tenant context exists";
@@ -123,7 +126,7 @@ async function claimBoundHandover(
123
126
  return undefined;
124
127
  }
125
128
 
126
- export function createSignupConfirmHandler() {
129
+ export function createSignupConfirmHandler(opts: SignupConfirmOptions = {}) {
127
130
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
128
131
  name: "signup-confirm",
129
132
  schema: SignupConfirmSchema,
@@ -221,11 +224,25 @@ export function createSignupConfirmHandler() {
221
224
  ? await claimBoundHandover(ctx, session, handoverBinding)
222
225
  : undefined;
223
226
 
227
+ // Before the token keys go: a throwing MFA check must leave the link retryable.
228
+ const mfaGate = await gateEnforceMfa(
229
+ ctx,
230
+ { mfaStatusChecker: opts.mfaStatusChecker },
231
+ provisioned.userId,
232
+ provisioned.tenantId,
233
+ session.roles,
234
+ );
235
+
224
236
  // Drop both token lookup keys; the burn key stays for its remaining
225
237
  // TTL as replay protection.
226
238
  await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
227
239
  if (handoverBinding) await deleteSignupHandover(ctx.redis, event.payload.token);
228
240
 
241
+ if (mfaGate !== undefined) {
242
+ committed = true;
243
+ return { isSuccess: true, data: mfaGate };
244
+ }
245
+
229
246
  committed = true;
230
247
  return {
231
248
  isSuccess: true,
@@ -0,0 +1,60 @@
1
+ import {
2
+ buildSessionRoles,
3
+ createSystemUser,
4
+ defineWriteHandler,
5
+ SYSTEM_ROLE,
6
+ type TenantId,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
+ import * as z from "zod";
10
+ import { UserQueries } from "../../user/index.js";
11
+ import { parseAuthUserRow } from "../auth-user-row.js";
12
+ import { noMembership } from "../errors.js";
13
+ import { gateEnforceMfa, type LoginHandlerOptions } from "./login.write.js";
14
+
15
+ const SwitchTenantMfaGateSchema = z.object({
16
+ userId: z.string().min(1),
17
+ tenantId: z
18
+ .string()
19
+ .min(1)
20
+ .transform((id) => id as TenantId),
21
+ });
22
+
23
+ export type SwitchTenantMfaGateData =
24
+ | { readonly kind: "mfa-gate-clear" }
25
+ | NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
26
+
27
+ // Dispatched by POST /auth/switch-tenant with the system identity. The handler
28
+ // resolves membership and roles itself, so the gate never trusts caller-supplied
29
+ // roles; system-only access keeps it out of reach of /api/write callers.
30
+ export function createSwitchTenantMfaGateHandler(
31
+ opts: Pick<LoginHandlerOptions, "mfaStatusChecker">,
32
+ ) {
33
+ return defineWriteHandler<
34
+ "switch-tenant-mfa-gate",
35
+ typeof SwitchTenantMfaGateSchema,
36
+ SwitchTenantMfaGateData
37
+ >({
38
+ name: "switch-tenant-mfa-gate",
39
+ schema: SwitchTenantMfaGateSchema,
40
+ access: { roles: [SYSTEM_ROLE] },
41
+ escapeHatch: {
42
+ reason:
43
+ "reads the MFA enrollment of the switch target tenant, not the caller's current tenant, via the mfaStatusChecker callback (it uses ctx.db.unsafeRaw())",
44
+ },
45
+ agent: { expose: false },
46
+ description:
47
+ "Runs the MFA gate of the login for a tenant switch and answers with an MFA challenge, a setup requirement, or clearance.",
48
+ handler: async (event, ctx) => {
49
+ const { userId, tenantId } = event.payload;
50
+ const active = await ctx.resolveActiveMembership(userId, tenantId);
51
+ if (active.kind === "rejected") return noMembership();
52
+ const userRow = parseAuthUserRow(
53
+ await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, { id: userId }),
54
+ );
55
+ const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
56
+ const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
57
+ return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
58
+ },
59
+ });
60
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
17
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
17
18
  import type { Temporal } from "temporal-polyfill";
18
19
  import * as z from "zod";
19
20
  import { UserQueries } from "../../user/index.js";
@@ -89,6 +90,12 @@ export type TokenRequestOptions = {
89
90
  readonly locale?: AuthMailLocale;
90
91
  };
91
92
 
93
+ // The requester is anonymous, so ctx.tz.user is only the tenant default; the recipient's
94
+ // own profile zone is the one the expiry should be read in.
95
+ function recipientTimeZone(user: AuthUserRow, fallbackTimeZone: string): string {
96
+ return user.timezone && isValidIanaTimeZone(user.timezone) ? user.timezone : fallbackTimeZone;
97
+ }
98
+
92
99
  export function createTokenRequestHandler<TName extends string, TSuccessKind extends string>(
93
100
  spec: TokenRequestSpec<TName, TSuccessKind>,
94
101
  opts: TokenRequestOptions,
@@ -156,7 +163,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
156
163
  token,
157
164
  expiresAt: expiresAt.toString(),
158
165
  issuedAt,
159
- timeZone: ctx.tz.user,
166
+ timeZone: recipientTimeZone(user, ctx.tz.user),
160
167
  ...(opts.appName !== undefined && { appName: opts.appName }),
161
168
  locale,
162
169
  },
@@ -116,6 +116,8 @@ export const defaultTranslations: TranslationsByLocale = {
116
116
  "auth.signupComplete.submitting": "…",
117
117
  "auth.signupComplete.missingToken":
118
118
  "Activation link is missing a token. Please request a new one.",
119
+ "auth.signupComplete.activatedMfaPending":
120
+ "Your account is active. Sign in to set up two-factor authentication.",
119
121
  "auth.signupComplete.activatedTitle": "Account activated",
120
122
  "auth.signupComplete.activated": "Your account is active and you're signed in.",
121
123
  "auth.signupComplete.continue": "Continue",
@@ -345,10 +345,10 @@ export async function requestSignup(
345
345
  return { ok: false, error: await parseTokenFailure(res) };
346
346
  }
347
347
 
348
- // POST /api/auth/signup-confirm. Token aus URL + Password. Erfolgreich:
349
- // Cookies (kumiko_auth + kumiko_csrf) werden gesetzt — User ist sofort
350
- // eingeloggt. Response liefert tenantKey für den Post-Signup-Redirect.
351
- // 422 invalid_signup_token bei abgelaufenem/unbekanntem Token.
348
+ // POST /api/auth/signup-confirm. On success the server sets the auth cookies
349
+ // (auto-login) and returns the tenantKey for the post-signup redirect, unless
350
+ // the MFA gate applies (kind "mfa-pending", no session). 422 invalid_signup_token
351
+ // for an expired or unknown token.
352
352
  export type SignupConfirmSuccess = {
353
353
  readonly user: { readonly id: string; readonly tenantId: string; readonly roles: string[] };
354
354
  readonly tenantKey: string;
@@ -360,10 +360,16 @@ export type SignupConfirmSuccess = {
360
360
  readonly landingPath?: string;
361
361
  };
362
362
 
363
+ // mfa-pending: account exists but the server issued no session because the
364
+ // new roles require a second factor — the user must sign in to enroll.
365
+ export type SignupConfirmResult =
366
+ | ({ readonly kind: "signed-in" } & SignupConfirmSuccess)
367
+ | { readonly kind: "mfa-pending" };
368
+
363
369
  export async function confirmSignup(
364
370
  token: string,
365
371
  password: string,
366
- ): Promise<{ ok: true; data: SignupConfirmSuccess } | { ok: false; error: AuthTokenFailure }> {
372
+ ): Promise<{ ok: true; data: SignupConfirmResult } | { ok: false; error: AuthTokenFailure }> {
367
373
  const res = await fetch("/api/auth/signup-confirm", {
368
374
  method: "POST",
369
375
  credentials: "same-origin",
@@ -371,8 +377,15 @@ export async function confirmSignup(
371
377
  body: JSON.stringify({ token, password }),
372
378
  });
373
379
  if (res.ok) {
374
- const body = (await res.json()) as SignupConfirmSuccess; // @cast-boundary engine-payload
375
- return { ok: true, data: body };
380
+ // @cast-boundary engine-payload
381
+ const body = (await res.json()) as SignupConfirmSuccess & {
382
+ readonly mfaRequired?: boolean;
383
+ readonly mfaSetupRequired?: boolean;
384
+ };
385
+ if (body.mfaRequired === true || body.mfaSetupRequired === true) {
386
+ return { ok: true, data: { kind: "mfa-pending" } };
387
+ }
388
+ return { ok: true, data: { kind: "signed-in", ...body } };
376
389
  }
377
390
  return { ok: false, error: await parseTokenFailure(res) };
378
391
  }
@@ -57,6 +57,7 @@ export function SignupCompleteScreen({
57
57
  const [submitting, setSubmitting] = useState(false);
58
58
  const [error, setError] = useState<string | null>(null);
59
59
  const [continueHref, setContinueHref] = useState<string | null>(null);
60
+ const [mfaPending, setMfaPending] = useState(false);
60
61
 
61
62
  const doSubmit = async (): Promise<void> => {
62
63
  setError(null);
@@ -73,6 +74,11 @@ export function SignupCompleteScreen({
73
74
  const res = await confirmSignup(token, password);
74
75
  setSubmitting(false);
75
76
  if (res.ok) {
77
+ if (res.data.kind === "mfa-pending") {
78
+ setMfaPending(true);
79
+ setContinueHref(loginHref);
80
+ return;
81
+ }
76
82
  // Cookies are already set (auto-login). Show a confirmation with an
77
83
  // explicit continue button instead of navigating away silently —
78
84
  // the user otherwise gets no signal that activation worked.
@@ -124,7 +130,11 @@ export function SignupCompleteScreen({
124
130
  return (
125
131
  <AuthCard title={effectiveTitle}>
126
132
  <p className="text-sm text-muted-foreground" role="status">
127
- {t("auth.signupComplete.activated")}
133
+ {t(
134
+ mfaPending
135
+ ? "auth.signupComplete.activatedMfaPending"
136
+ : "auth.signupComplete.activated",
137
+ )}
128
138
  </p>
129
139
  <Link href={continueHref} variant="button">
130
140
  {t("auth.signupComplete.continue")}
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.345.0",
4
+ "type": "improvement",
5
+ "title": "Webhook sends carry an Idempotency-Key header that is stable across redeliveries",
6
+ "detail": "Webhook sends carry an Idempotency-Key header\nEvery `r.step.webhook.send` request now has `Idempotency-Key: <dispatch stream id>`. The value stays the same when the same dispatch request is delivered again, so receivers can deduplicate. An explicit `Idempotency-Key` in `headers` (any casing) takes precedence."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -193,7 +193,7 @@ function buildDispatchSpec(
193
193
  export function createStepDispatcherFeature(): FeatureDefinition {
194
194
  return defineFeature("step-dispatcher", (r) => {
195
195
  r.describe(
196
- "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.",
196
+ "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.",
197
197
  );
198
198
  r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
199
199
  r.uiHints({
@@ -270,6 +270,7 @@ export function createStepDispatcherFeature(): FeatureDefinition {
270
270
  tenantId: event.tenantId,
271
271
  userId: event.metadata.userId || SYSTEM_USER_ID,
272
272
  secrets: ctx.secrets,
273
+ idempotencyKey: event.aggregateId,
273
274
  })
274
275
  : await performMailDispatch(dispatchSpec.spec);
275
276
  if (result.ok) {
@@ -114,6 +114,7 @@ export type WebhookDispatchDeps = {
114
114
  readonly tenantId: TenantId;
115
115
  readonly userId: string;
116
116
  readonly secrets: SecretsContext | undefined;
117
+ readonly idempotencyKey: string;
117
118
  };
118
119
 
119
120
  // Never includes the secret name or value — spec.auth.secret is a
@@ -121,11 +122,20 @@ export type WebhookDispatchDeps = {
121
122
  // dispatch-failed event, so it stays generic.
122
123
  const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
123
124
 
125
+ export const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
126
+
127
+ function hasIdempotencyKeyHeader(headers: Readonly<Record<string, string>>): boolean {
128
+ return Object.keys(headers).some((name) => name.toLowerCase() === WEBHOOK_IDEMPOTENCY_KEY_HEADER);
129
+ }
130
+
124
131
  async function buildWebhookHeaders(
125
132
  spec: WebhookSpec,
126
133
  deps: WebhookDispatchDeps,
127
134
  ): Promise<{ ok: true; headers: Record<string, string> } | { ok: false; error: string }> {
128
135
  const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
136
+ if (!hasIdempotencyKeyHeader(headers)) {
137
+ headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
138
+ }
129
139
  if (!spec.auth) return { ok: true, headers };
130
140
  if (!deps.secrets) return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
131
141
  // A throwing get (corrupt envelope, rotated key, KMS error) must become a
@@ -127,6 +127,7 @@
127
127
  "auth-email-password:write:reset-password",
128
128
  "auth-email-password:write:signup-confirm",
129
129
  "auth-email-password:write:signup-request",
130
+ "auth-email-password:write:switch-tenant-mfa-gate",
130
131
  "auth-email-password:write:system-invite-create",
131
132
  "auth-email-password:write:verify-email"
132
133
  ],
@@ -2301,7 +2302,7 @@
2301
2302
  },
2302
2303
  {
2303
2304
  "name": "step-dispatcher",
2304
- "description": "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.",
2305
+ "description": "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.",
2305
2306
  "toggleableDefault": null,
2306
2307
  "requires": [
2307
2308
  "secrets"
@@ -13,7 +13,7 @@ A translation value can be a CLDR plural-forms object instead of a plain
13
13
  string. `other` is required; the rest (`zero`/`one`/`two`/`few`/`many`) only
14
14
  exist where the locale's grammar needs them:
15
15
 
16
- ```ts
16
+ ```ts illustration
17
17
  "greeting.unread_count": {
18
18
  de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
19
19
  en: { one: "{count} unread message", other: "{count} unread messages" },
@@ -12,7 +12,7 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
12
12
  A public write handler that mails an address typed by the caller is also limited per
13
13
  address, so rotating IPs does not help against mail flooding. Declare it next to `rateLimit`:
14
14
 
15
- ```ts
15
+ ```ts illustration
16
16
  r.writeHandler({
17
17
  // ...
18
18
  access: { roles: ["anonymous"] },
@@ -16,7 +16,7 @@ never a process-wide env var. The `secrets` feature must be mounted
16
16
  (`createSecretsFeature()` + a `MasterKeyProvider`) alongside
17
17
  `step-dispatcher`. Set the secret per tenant before dispatching:
18
18
 
19
- ```ts
19
+ ```ts illustration
20
20
  await stack.http.writeOk(
21
21
  "secrets:write:set",
22
22
  { key: "step-dispatcher:webhook-auth.incident-hook", value: "<token>" },
@@ -33,6 +33,13 @@ Never combine `auth.secret` with a caller-controlled `url`: the caller would
33
33
  receive the secret as the `Authorization` header. `incident:open-authenticated`
34
34
  therefore posts to a fixed URL.
35
35
 
36
+ ## Idempotency-Key
37
+
38
+ Every webhook request carries `Idempotency-Key: <dispatch stream id>`. The
39
+ value is the same when the same dispatch request is delivered again, so a
40
+ receiver can deduplicate redelivered calls. An `Idempotency-Key` set explicitly in
41
+ `headers` (any casing) wins.
42
+
36
43
  ## Source
37
44
 
38
45
  Feature entry point: `src/feature.ts`.