@cosmicdrift/kumiko-bundled-features 0.344.0 → 0.346.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 (43) hide show
  1. package/dist/audit/constants.d.ts +7 -0
  2. package/dist/audit/constants.js +3 -0
  3. package/dist/audit/feature.js +1 -1
  4. package/dist/audit/handlers/details.query.d.ts +4 -0
  5. package/dist/audit/handlers/details.query.js +14 -3
  6. package/dist/audit/handlers/list.query.d.ts +4 -0
  7. package/dist/audit/handlers/list.query.js +11 -2
  8. package/dist/audit/handlers/resolve-audit-tenant.d.ts +7 -0
  9. package/dist/audit/handlers/resolve-audit-tenant.js +14 -0
  10. package/dist/audit/i18n.js +3 -0
  11. package/dist/audit/run-escape-hatch-retention.js +25 -12
  12. package/dist/auth-email-password/constants.d.ts +1 -0
  13. package/dist/auth-email-password/constants.js +1 -0
  14. package/dist/auth-email-password/feature.js +6 -2
  15. package/dist/auth-email-password/handlers/invite-signup-complete.write.d.ts +6 -2
  16. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +14 -4
  17. package/dist/auth-email-password/handlers/signup-confirm.write.d.ts +6 -2
  18. package/dist/auth-email-password/handlers/signup-confirm.write.js +8 -1
  19. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +9 -0
  20. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +39 -0
  21. package/dist/auth-email-password/handlers/token-request-handler.js +7 -1
  22. package/dist/auth-email-password/i18n.js +1 -0
  23. package/dist/auth-email-password/web/auth-client.d.ts +6 -1
  24. package/dist/auth-email-password/web/auth-client.js +6 -2
  25. package/dist/auth-email-password/web/signup-complete-screen.js +9 -1
  26. package/dist/billing-foundation/subscription-tier-sync.js +21 -10
  27. package/dist/step-dispatcher/dispatch-payload.d.ts +49 -0
  28. package/dist/step-dispatcher/dispatch-payload.js +115 -0
  29. package/dist/step-dispatcher/feature.js +23 -121
  30. package/dist/step-dispatcher/webhook-runner.d.ts +4 -0
  31. package/dist/step-dispatcher/webhook-runner.js +26 -3
  32. package/dist/tier-engine/constants.d.ts +5 -0
  33. package/dist/tier-engine/constants.js +6 -0
  34. package/dist/tier-engine/entity.js +2 -4
  35. package/dist/tier-engine/feature.js +2 -2
  36. package/dist/tier-engine/handlers/set-tenant-tier.write.js +10 -4
  37. package/dist/tier-engine/index.d.ts +1 -1
  38. package/dist/tier-engine/index.js +1 -1
  39. package/package.json +9 -9
  40. package/src/audit/changes.json +12 -0
  41. package/src/auth-email-password/changes.json +24 -0
  42. package/src/billing-foundation/changes.json +6 -0
  43. package/src/step-dispatcher/changes.json +31 -0
@@ -9,6 +9,13 @@ export declare const AuditQueries: {
9
9
  readonly list: "audit:query:list";
10
10
  readonly details: "audit:query:details";
11
11
  };
12
+ /** Read scope of the audit queries: the caller's own tenant (default) or the system tenant (SystemAdmin only). */
13
+ export declare const AuditScopes: {
14
+ readonly tenant: "tenant";
15
+ readonly system: "system";
16
+ };
17
+ export type AuditScope = (typeof AuditScopes)[keyof typeof AuditScopes];
18
+ export declare const AUDIT_SCOPE_VALUES: readonly ["tenant", "system"];
12
19
  /** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
13
20
  export declare const AUDIT_LOG_SCREEN_ID: "audit-log";
14
21
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
@@ -14,6 +14,9 @@ export const AuditQueries = {
14
14
  list: "audit:query:list",
15
15
  details: "audit:query:details",
16
16
  };
17
+ /** Read scope of the audit queries: the caller's own tenant (default) or the system tenant (SystemAdmin only). */
18
+ export const AuditScopes = { tenant: "tenant", system: "system" };
19
+ export const AUDIT_SCOPE_VALUES = [AuditScopes.tenant, AuditScopes.system];
17
20
  /** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
18
21
  export const AUDIT_LOG_SCREEN_ID = "audit-log";
19
22
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
@@ -20,7 +20,7 @@ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
20
20
  // framework).
21
21
  export function createAuditFeature() {
22
22
  return defineFeature("audit", (r) => {
23
- r.describe("Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).");
23
+ r.describe("Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: \"system\"` to read the app-instance system events such as `app.started`. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).");
24
24
  r.uiHints({
25
25
  displayLabel: "Audit Log",
26
26
  category: "compliance",
@@ -1,6 +1,10 @@
1
1
  import * as z from "zod";
2
2
  export declare const detailsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"details", z.ZodObject<{
3
3
  id: z.ZodString;
4
+ scope: z.ZodOptional<z.ZodEnum<{
5
+ system: "system";
6
+ tenant: "tenant";
7
+ }>>;
4
8
  }, z.core.$strip>, {
5
9
  id: string;
6
10
  aggregateId: string;
@@ -1,19 +1,30 @@
1
1
  // Single audit event by its event-store id — backs the audit-log-detail
2
2
  // screen. Tenant-isolated at the WHERE level like list.query, so a caller
3
- // can only read events in their own tenant.
3
+ // can only read events in their own tenant; a SystemAdmin can pass scope
4
+ // "system" to read app-instance system events (e.g. app.started).
4
5
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
6
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
7
8
  import * as z from "zod";
9
+ import { AUDIT_SCOPE_VALUES } from "../constants.js";
10
+ import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
8
11
  export const detailsQuery = defineQueryHandler({
9
12
  name: "details",
10
- description: "Returns one audit-trail event of the caller's tenant by its event-store id, with full payload and metadata; use it to inspect the exact change behind a row of the audit log list.",
13
+ description: 'Returns one audit-trail event of the caller\'s tenant by its event-store id, with full payload and metadata; use it to inspect the exact change behind a row of the audit log list. A SystemAdmin can set scope "system" to read an app-instance system event (e.g. app.started).',
11
14
  schema: z.object({
12
15
  id: z.string().regex(/^[1-9]\d*$/, "id must be a positive integer"),
16
+ scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
13
17
  }),
14
18
  access: { roles: access.admin },
15
19
  handler: async (query, ctx) => {
16
- const rows = await selectMany(ctx.db, eventsTable, { tenantId: query.user.tenantId, id: BigInt(query.payload.id) }, { limit: 1 });
20
+ const scopeFilter = resolveAuditScopeFilter(query.user, query.payload.scope);
21
+ const rows = await selectMany(ctx.db, eventsTable, {
22
+ tenantId: scopeFilter.tenantId,
23
+ ...(scopeFilter.aggregateType !== undefined && {
24
+ aggregateType: scopeFilter.aggregateType,
25
+ }),
26
+ id: BigInt(query.payload.id),
27
+ }, { limit: 1 });
17
28
  const row = rows[0];
18
29
  if (row === undefined)
19
30
  return null;
@@ -18,6 +18,10 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
18
18
  }>>;
19
19
  from: z.ZodOptional<z.ZodISODateTime>;
20
20
  to: z.ZodOptional<z.ZodISODateTime>;
21
+ scope: z.ZodOptional<z.ZodEnum<{
22
+ system: "system";
23
+ tenant: "tenant";
24
+ }>>;
21
25
  }, z.core.$strip>, {
22
26
  rows: {
23
27
  id: string;
@@ -6,6 +6,8 @@
6
6
  // No projection, no separate audit table. Queryable with the same filter
7
7
  // surface any audit UI needs; tenant-isolated at the WHERE level so cross-
8
8
  // tenant peeking is structurally impossible for non-SystemAdmin callers.
9
+ // A SystemAdmin can pass scope "system" to read the app-instance system events
10
+ // (e.g. app.started); no other cross-tenant read exists.
9
11
  //
10
12
  // Sensitive field-values are ciphertext inside the event payload (the log
11
13
  // carries them encrypted); stripSensitive only strips the event echo. This
@@ -16,6 +18,8 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
16
18
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
19
  import { Temporal } from "temporal-polyfill";
18
20
  import * as z from "zod";
21
+ import { AUDIT_SCOPE_VALUES } from "../constants.js";
22
+ import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
19
23
  const MAX_LIMIT = 100;
20
24
  function buildDateRange(from, to) {
21
25
  if (!from && !to)
@@ -52,7 +56,7 @@ function buildAuditWhere(tenantId, p) {
52
56
  }
53
57
  export const listQuery = defineQueryHandler({
54
58
  name: "list",
55
- description: "Lists the tenant's audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when.",
59
+ description: 'Lists the tenant\'s audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when. A SystemAdmin can set scope "system" to read the app-instance system events (e.g. app.started) instead of the own tenant.',
56
60
  schema: z
57
61
  .object({
58
62
  cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
@@ -67,6 +71,7 @@ export const listQuery = defineQueryHandler({
67
71
  sortDirection: z.enum(["asc", "desc"]).optional(),
68
72
  from: z.iso.datetime().optional(),
69
73
  to: z.iso.datetime().optional(),
74
+ scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
70
75
  })
71
76
  .refine((v) => !v.from || !v.to || v.from <= v.to, {
72
77
  message: "`from` must be less than or equal to `to`",
@@ -75,7 +80,11 @@ export const listQuery = defineQueryHandler({
75
80
  access: { roles: access.admin },
76
81
  handler: async (query, ctx) => {
77
82
  const p = query.payload;
78
- const where = buildAuditWhere(query.user.tenantId, p);
83
+ const scopeFilter = resolveAuditScopeFilter(query.user, p.scope);
84
+ const where = buildAuditWhere(scopeFilter.tenantId, {
85
+ ...p,
86
+ aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
87
+ });
79
88
  const rows = await selectMany(ctx.db, eventsTable, where, {
80
89
  orderBy: {
81
90
  col: query.payload.sort === "type" ? "type" : "createdAt",
@@ -0,0 +1,7 @@
1
+ import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type AuditScope } from "../constants.js";
3
+ export type AuditScopeFilter = {
4
+ readonly tenantId: string;
5
+ readonly aggregateType?: string;
6
+ };
7
+ export declare function resolveAuditScopeFilter(user: SessionUser, scope: AuditScope | undefined): AuditScopeFilter;
@@ -0,0 +1,14 @@
1
+ import { crossTenantOverrideDenied } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { APP_INSTANCE_STREAM_TYPE } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-types/identifiers";
4
+ import { AuditScopes } from "../constants.js";
5
+ // The system tenant also holds personal streams of other features (session
6
+ // revocations, exports, ...); only app-instance streams are released.
7
+ export function resolveAuditScopeFilter(user, scope) {
8
+ if (scope !== AuditScopes.system)
9
+ return { tenantId: user.tenantId };
10
+ const denied = crossTenantOverrideDenied(user, SYSTEM_TENANT_ID, "audit.errors.systemScopeRequiresSystemAdmin");
11
+ if (denied)
12
+ throw denied;
13
+ return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
14
+ }
@@ -6,6 +6,9 @@ export const AUDIT_I18N = {
6
6
  "audit.log.detail.subtitle": {
7
7
  en: "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
8
8
  },
9
+ "audit.errors.systemScopeRequiresSystemAdmin": {
10
+ en: "Only SystemAdmin may read system events.",
11
+ },
9
12
  "audit:nav.auditLog": { en: "Audit" },
10
13
  "audit.log.col.when": { en: "When" },
11
14
  "audit.log.col.type": { en: "Event" },
@@ -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 { COMPLIANCE_PROFILES, subtractRetentionSpec, } from "@cosmicdrift/kumiko-framework/compliance";
3
3
  import { parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -38,10 +38,14 @@ function targetTenantOf(payload) {
38
38
  const parsed = escapeHatchUsedSchema.safeParse(payload);
39
39
  return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
40
40
  }
41
- async function pruneByTenantProfiles(db) {
42
- const rows = await selectMany(db, eventsTable, {
43
- aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
41
+ async function storingTenantIds(db) {
42
+ const groups = await aggregateWhere(db, eventsTable, { measure: { fn: "count" }, groupBy: [{ field: "tenantId" }] }, { aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE });
43
+ return groups.flatMap((group) => {
44
+ const tenantId = parseTenantId(group.keys[0]);
45
+ return tenantId ? [tenantId] : [];
44
46
  });
47
+ }
48
+ async function pruneByTenantProfiles(db) {
45
49
  const now = getTemporal().Now.instant();
46
50
  const cutoffByTenant = new Map();
47
51
  const cutoffOf = async (tenantId) => {
@@ -52,15 +56,24 @@ async function pruneByTenantProfiles(db) {
52
56
  cutoffByTenant.set(tenantId, cutoff);
53
57
  return cutoff;
54
58
  };
59
+ // The effective cutoff is never later than the storing tenant's, so only rows older than
60
+ // that one can expire; everything younger stays in the database.
55
61
  const expiredAggregateIds = [];
56
- for (const row of rows) {
57
- const targetTenantId = targetTenantOf(row.payload);
58
- const storeCutoff = await cutoffOf(row.tenantId);
59
- const cutoff = targetTenantId
60
- ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
61
- : storeCutoff;
62
- if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
63
- expiredAggregateIds.push(row.aggregateId);
62
+ for (const storingTenantId of await storingTenantIds(db)) {
63
+ const storeCutoff = await cutoffOf(storingTenantId);
64
+ const candidates = await selectMany(db, eventsTable, {
65
+ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
66
+ tenantId: storingTenantId,
67
+ createdAt: { lt: storeCutoff },
68
+ });
69
+ for (const row of candidates) {
70
+ const targetTenantId = targetTenantOf(row.payload);
71
+ const cutoff = targetTenantId
72
+ ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
73
+ : storeCutoff;
74
+ if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
75
+ expiredAggregateIds.push(row.aggregateId);
76
+ }
64
77
  }
65
78
  }
66
79
  let deletedCount = 0;
@@ -17,6 +17,7 @@ export declare const AuthHandlers: {
17
17
  readonly inviteAccept: "auth-email-password:write:invite-accept";
18
18
  readonly inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login";
19
19
  readonly inviteSignupComplete: "auth-email-password:write:invite-signup-complete";
20
+ readonly switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate";
20
21
  readonly inviteCancel: "auth-email-password:write:invite-cancel";
21
22
  };
22
23
  export declare const AuthQueries: {
@@ -44,6 +44,7 @@ export const AuthHandlers = {
44
44
  inviteAccept: "auth-email-password:write:invite-accept",
45
45
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
46
46
  inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
47
+ switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
47
48
  inviteCancel: "auth-email-password:write:invite-cancel",
48
49
  };
49
50
  // Qualified query names. Anonymous-readable status so the (unauthenticated)
@@ -17,6 +17,7 @@ import { createResetPasswordHandler } from "./handlers/reset-password.write.js";
17
17
  import { selfRegistrationStatusQuery } from "./handlers/self-registration-status.query.js";
18
18
  import { createSignupConfirmHandler } from "./handlers/signup-confirm.write.js";
19
19
  import { createSignupRequestHandler, } from "./handlers/signup-request.write.js";
20
+ import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
20
21
  import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
21
22
  /**
22
23
  * Env-vars contract for the `auth-email-password` feature.
@@ -101,6 +102,9 @@ export function createAuthEmailPasswordFeature(opts = {}) {
101
102
  changePassword: r.writeHandler(changePasswordWrite),
102
103
  logout: r.writeHandler(logoutWrite),
103
104
  };
105
+ if (opts.mfaStatusChecker) {
106
+ r.writeHandler(createSwitchTenantMfaGateHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
107
+ }
104
108
  if (opts.passwordReset) {
105
109
  r.writeHandler(createRequestPasswordResetHandler(opts.passwordReset));
106
110
  r.writeHandler(createResetPasswordHandler(opts.passwordReset));
@@ -119,7 +123,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
119
123
  };
120
124
  if (opts.signup) {
121
125
  r.writeHandler(createSignupRequestHandler(opts.signup));
122
- r.writeHandler(createSignupConfirmHandler());
126
+ r.writeHandler(createSignupConfirmHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
123
127
  }
124
128
  if (opts.invite) {
125
129
  r.writeHandler(createInviteCreateHandler(opts.invite));
@@ -132,7 +136,7 @@ export function createAuthEmailPasswordFeature(opts = {}) {
132
136
  accountLockout: opts.accountLockout,
133
137
  strictEmailVerification: strictVerification,
134
138
  }));
135
- r.writeHandler(createInviteSignupCompleteHandler());
139
+ r.writeHandler(createInviteSignupCompleteHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
136
140
  }
137
141
  if (opts.accountUnlock) {
138
142
  r.writeHandler(createRequestAccountUnlockHandler(opts.accountUnlock));
@@ -1,12 +1,16 @@
1
1
  import { type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import { type LoginHandlerOptions, type LoginResult } from "./login.write.js";
3
4
  export type InviteSignupCompleteData = {
4
5
  readonly kind: "auth-session";
5
6
  readonly session: SessionUser;
6
7
  readonly tenantId: TenantId;
7
8
  readonly role: string;
8
- };
9
- export declare function createInviteSignupCompleteHandler(): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"invite-signup-complete", z.ZodObject<{
9
+ } | Exclude<LoginResult, {
10
+ readonly kind: "auth-session";
11
+ }>;
12
+ export type InviteSignupCompleteOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
13
+ export declare function createInviteSignupCompleteHandler(opts?: InviteSignupCompleteOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"invite-signup-complete", z.ZodObject<{
10
14
  token: z.ZodString;
11
15
  password: z.ZodString;
12
16
  }, z.core.$strip>, InviteSignupCompleteData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -37,13 +37,14 @@ import { burnInviteToken, deleteInviteToken, getInvitationIdForToken, unburnInvi
37
37
  import { passwordSchema } from "../password-policy.js";
38
38
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
39
39
  import { seedUserWithPassword } from "../seeding.js";
40
+ import { gateEnforceMfa } from "./login.write.js";
40
41
  const InviteSignupCompleteSchema = z.object({
41
42
  token: z.string().min(1),
42
43
  password: passwordSchema,
43
44
  });
44
45
  const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
45
46
  const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON = "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.";
46
- export function createInviteSignupCompleteHandler() {
47
+ export function createInviteSignupCompleteHandler(opts = {}) {
47
48
  return defineWriteHandler({
48
49
  name: "invite-signup-complete",
49
50
  schema: InviteSignupCompleteSchema,
@@ -119,13 +120,22 @@ export function createInviteSignupCompleteHandler() {
119
120
  }, createSystemUser(invitationTenantId), invitationTdb);
120
121
  if (!updateResult.isSuccess)
121
122
  return updateResult;
123
+ // buildSessionRoles calls stripForbiddenMembershipRoles internally —
124
+ // a reserved role on the invitation itself must never reach the session.
125
+ const mergedRoles = buildSessionRoles(invitationGlobalRoles, [invitationRole]);
126
+ // Same gate as invite-accept-with-login: the account exists after this
127
+ // call, but an MFA-gated role gets the MFA step instead of a session.
128
+ // It runs before the token is deleted so a throwing check leaves the link retryable.
129
+ const mfaGate = await gateEnforceMfa(ctx, { mfaStatusChecker: opts.mfaStatusChecker }, userId, invitationTenantId, mergedRoles);
122
130
  await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
131
+ if (mfaGate !== undefined) {
132
+ committed = true;
133
+ return { isSuccess: true, data: mfaGate };
134
+ }
123
135
  const session = {
124
136
  id: userId,
125
137
  tenantId: invitationTenantId,
126
- // buildSessionRoles calls stripForbiddenMembershipRoles internally —
127
- // a reserved role on the invitation itself must never reach the session.
128
- roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
138
+ roles: mergedRoles,
129
139
  };
130
140
  committed = true;
131
141
  return {
@@ -1,5 +1,6 @@
1
1
  import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import { type LoginHandlerOptions, type LoginResult } from "./login.write.js";
3
4
  export type SignupConfirmData = {
4
5
  readonly kind: "auth-session";
5
6
  readonly session: SessionUser;
@@ -8,8 +9,11 @@ export type SignupConfirmData = {
8
9
  readonly entityType: string;
9
10
  readonly id: string;
10
11
  };
11
- };
12
- export declare function createSignupConfirmHandler(): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"signup-confirm", z.ZodObject<{
12
+ } | Exclude<LoginResult, {
13
+ readonly kind: "auth-session";
14
+ }>;
15
+ export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
16
+ export declare function createSignupConfirmHandler(opts?: SignupConfirmOptions): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"signup-confirm", z.ZodObject<{
13
17
  token: z.ZodString;
14
18
  password: z.ZodString;
15
19
  }, z.core.$strip>, SignupConfirmData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -32,6 +32,7 @@ import { passwordSchema } from "../password-policy.js";
32
32
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
33
33
  import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding.js";
34
34
  import { burnSignupToken, deleteSignupHandover, deleteSignupToken, getEmailForSignupToken, getSignupHandover, unburnSignupToken, } from "../signup-token-store.js";
35
+ import { gateEnforceMfa } from "./login.write.js";
35
36
  const SignupConfirmSchema = z.object({
36
37
  token: z.string().min(8),
37
38
  password: passwordSchema,
@@ -70,7 +71,7 @@ async function claimBoundHandover(ctx, session, binding) {
70
71
  });
71
72
  return undefined;
72
73
  }
73
- export function createSignupConfirmHandler() {
74
+ export function createSignupConfirmHandler(opts = {}) {
74
75
  return defineWriteHandler({
75
76
  name: "signup-confirm",
76
77
  schema: SignupConfirmSchema,
@@ -158,11 +159,17 @@ export function createSignupConfirmHandler() {
158
159
  const handover = handoverBinding
159
160
  ? await claimBoundHandover(ctx, session, handoverBinding)
160
161
  : undefined;
162
+ // Before the token keys go: a throwing MFA check must leave the link retryable.
163
+ const mfaGate = await gateEnforceMfa(ctx, { mfaStatusChecker: opts.mfaStatusChecker }, provisioned.userId, provisioned.tenantId, session.roles);
161
164
  // Drop both token lookup keys; the burn key stays for its remaining
162
165
  // TTL as replay protection.
163
166
  await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
164
167
  if (handoverBinding)
165
168
  await deleteSignupHandover(ctx.redis, event.payload.token);
169
+ if (mfaGate !== undefined) {
170
+ committed = true;
171
+ return { isSuccess: true, data: mfaGate };
172
+ }
166
173
  committed = true;
167
174
  return {
168
175
  isSuccess: true,
@@ -0,0 +1,9 @@
1
+ import * as z from "zod";
2
+ import { gateEnforceMfa, type LoginHandlerOptions } from "./login.write.js";
3
+ export type SwitchTenantMfaGateData = {
4
+ readonly kind: "mfa-gate-clear";
5
+ } | NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
6
+ export declare function createSwitchTenantMfaGateHandler(opts: Pick<LoginHandlerOptions, "mfaStatusChecker">): import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"switch-tenant-mfa-gate", z.ZodObject<{
7
+ userId: z.ZodString;
8
+ tenantId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
9
+ }, z.core.$strip>, SwitchTenantMfaGateData, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -0,0 +1,39 @@
1
+ import { buildSessionRoles, createSystemUser, defineWriteHandler, SYSTEM_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
3
+ import * as z from "zod";
4
+ import { UserQueries } from "../../user/index.js";
5
+ import { parseAuthUserRow } from "../auth-user-row.js";
6
+ import { noMembership } from "../errors.js";
7
+ import { gateEnforceMfa } from "./login.write.js";
8
+ const SwitchTenantMfaGateSchema = z.object({
9
+ userId: z.string().min(1),
10
+ tenantId: z
11
+ .string()
12
+ .min(1)
13
+ .transform((id) => id),
14
+ });
15
+ // Dispatched by POST /auth/switch-tenant with the system identity. The handler
16
+ // resolves membership and roles itself, so the gate never trusts caller-supplied
17
+ // roles; system-only access keeps it out of reach of /api/write callers.
18
+ export function createSwitchTenantMfaGateHandler(opts) {
19
+ return defineWriteHandler({
20
+ name: "switch-tenant-mfa-gate",
21
+ schema: SwitchTenantMfaGateSchema,
22
+ access: { roles: [SYSTEM_ROLE] },
23
+ escapeHatch: {
24
+ reason: "reads the MFA enrollment of the switch target tenant, not the caller's current tenant, via the mfaStatusChecker callback (it uses ctx.db.unsafeRaw())",
25
+ },
26
+ agent: { expose: false },
27
+ description: "Runs the MFA gate of the login for a tenant switch and answers with an MFA challenge, a setup requirement, or clearance.",
28
+ handler: async (event, ctx) => {
29
+ const { userId, tenantId } = event.payload;
30
+ const active = await ctx.resolveActiveMembership(userId, tenantId);
31
+ if (active.kind === "rejected")
32
+ return noMembership();
33
+ const userRow = parseAuthUserRow(await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, { id: userId }));
34
+ const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
35
+ const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
36
+ return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
37
+ },
38
+ });
39
+ }
@@ -13,6 +13,7 @@
13
13
  // spec rather than duplicated across two near-identical handler bodies.
14
14
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
16
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
16
17
  import * as z from "zod";
17
18
  import { UserQueries } from "../../user/index.js";
18
19
  import { parseAuthUserRow } from "../auth-user-row.js";
@@ -20,6 +21,11 @@ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-m
20
21
  const RequestTokenSchema = z.object({
21
22
  email: z.email(),
22
23
  });
24
+ // The requester is anonymous, so ctx.tz.user is only the tenant default; the recipient's
25
+ // own profile zone is the one the expiry should be read in.
26
+ function recipientTimeZone(user, fallbackTimeZone) {
27
+ return user.timezone && isValidIanaTimeZone(user.timezone) ? user.timezone : fallbackTimeZone;
28
+ }
23
29
  export function createTokenRequestHandler(spec, opts) {
24
30
  const ttl = opts.tokenTtlMinutes ?? spec.defaultTtlMinutes;
25
31
  return defineWriteHandler({
@@ -69,7 +75,7 @@ export function createTokenRequestHandler(spec, opts) {
69
75
  token,
70
76
  expiresAt: expiresAt.toString(),
71
77
  issuedAt,
72
- timeZone: ctx.tz.user,
78
+ timeZone: recipientTimeZone(user, ctx.tz.user),
73
79
  ...(opts.appName !== undefined && { appName: opts.appName }),
74
80
  locale,
75
81
  });
@@ -99,6 +99,7 @@ export const defaultTranslations = {
99
99
  "auth.signupComplete.submit": "Activate account",
100
100
  "auth.signupComplete.submitting": "…",
101
101
  "auth.signupComplete.missingToken": "Activation link is missing a token. Please request a new one.",
102
+ "auth.signupComplete.activatedMfaPending": "Your account is active. Sign in to set up two-factor authentication.",
102
103
  "auth.signupComplete.activatedTitle": "Account activated",
103
104
  "auth.signupComplete.activated": "Your account is active and you're signed in.",
104
105
  "auth.signupComplete.continue": "Continue",
@@ -112,9 +112,14 @@ export type SignupConfirmSuccess = {
112
112
  };
113
113
  readonly landingPath?: string;
114
114
  };
115
+ export type SignupConfirmResult = ({
116
+ readonly kind: "signed-in";
117
+ } & SignupConfirmSuccess) | {
118
+ readonly kind: "mfa-pending";
119
+ };
115
120
  export declare function confirmSignup(token: string, password: string): Promise<{
116
121
  ok: true;
117
- data: SignupConfirmSuccess;
122
+ data: SignupConfirmResult;
118
123
  } | {
119
124
  ok: false;
120
125
  error: AuthTokenFailure;
@@ -229,8 +229,12 @@ export async function confirmSignup(token, password) {
229
229
  body: JSON.stringify({ token, password }),
230
230
  });
231
231
  if (res.ok) {
232
- const body = (await res.json()); // @cast-boundary engine-payload
233
- return { ok: true, data: body };
232
+ // @cast-boundary engine-payload
233
+ const body = (await res.json());
234
+ if (body.mfaRequired === true || body.mfaSetupRequired === true) {
235
+ return { ok: true, data: { kind: "mfa-pending" } };
236
+ }
237
+ return { ok: true, data: { kind: "signed-in", ...body } };
234
238
  }
235
239
  return { ok: false, error: await parseTokenFailure(res) };
236
240
  }
@@ -31,6 +31,7 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
31
31
  const [submitting, setSubmitting] = useState(false);
32
32
  const [error, setError] = useState(null);
33
33
  const [continueHref, setContinueHref] = useState(null);
34
+ const [mfaPending, setMfaPending] = useState(false);
34
35
  const doSubmit = async () => {
35
36
  setError(null);
36
37
  const issue = passwordPairIssue(password, confirmPassword);
@@ -46,6 +47,11 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
46
47
  const res = await confirmSignup(token, password);
47
48
  setSubmitting(false);
48
49
  if (res.ok) {
50
+ if (res.data.kind === "mfa-pending") {
51
+ setMfaPending(true);
52
+ setContinueHref(loginHref);
53
+ return;
54
+ }
49
55
  // Cookies are already set (auto-login). Show a confirmation with an
50
56
  // explicit continue button instead of navigating away silently —
51
57
  // the user otherwise gets no signal that activation worked.
@@ -80,7 +86,9 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
80
86
  return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] }));
81
87
  }
82
88
  if (continueHref !== null) {
83
- return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t("auth.signupComplete.activated") }), _jsx(Link, { href: continueHref, variant: "button", children: t("auth.signupComplete.continue") })] }));
89
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t(mfaPending
90
+ ? "auth.signupComplete.activatedMfaPending"
91
+ : "auth.signupComplete.activated") }), _jsx(Link, { href: continueHref, variant: "button", children: t("auth.signupComplete.continue") })] }));
84
92
  }
85
93
  return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-password", label: t("auth.signupComplete.password"), required: true, children: _jsx(Input, { kind: "password", id: "signup-password", name: "signup-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "signup-confirm-password", label: t("auth.signupComplete.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "signup-confirm-password", name: "signup-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit") })] })] }));
86
94
  }