@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.222.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 (72) hide show
  1. package/package.json +10 -9
  2. package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
  3. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  4. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  5. package/src/audit/__tests__/constants.test.ts +6 -1
  6. package/src/audit/constants.ts +10 -1
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +2 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +7 -3
  10. package/src/audit/web/audit-log-screen.tsx +27 -29
  11. package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
  12. package/src/auth-email-password/seeding.ts +3 -1
  13. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  14. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  15. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  16. package/src/cap-counter/stock-cap-guard.ts +1 -1
  17. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  18. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  19. package/src/channel-email/email-channel.ts +14 -7
  20. package/src/channel-email/pii-guard.ts +8 -0
  21. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  22. package/src/crypto-shredding/constants.ts +1 -0
  23. package/src/crypto-shredding/feature.ts +6 -1
  24. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  25. package/src/crypto-shredding/index.ts +2 -0
  26. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  27. package/src/file-derivatives/feature.ts +2 -2
  28. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  29. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  30. package/src/files-provider-s3/s3-provider.ts +45 -0
  31. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  32. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  33. package/src/files-tenant-data/changes.json +1 -0
  34. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  35. package/src/files-tenant-data/hooks.ts +93 -0
  36. package/src/files-tenant-data/index.ts +58 -0
  37. package/src/form-draft/db/queries/cleanup.ts +17 -0
  38. package/src/form-draft/handlers/cleanup.job.ts +20 -10
  39. package/src/form-draft/handlers/discard.write.ts +25 -7
  40. package/src/form-draft/handlers/get.query.ts +4 -2
  41. package/src/form-draft/handlers/list.query.ts +4 -1
  42. package/src/form-draft/lookup.ts +12 -5
  43. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  44. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
  45. package/src/jobs/constants.ts +1 -0
  46. package/src/jobs/handlers/retry.write.ts +26 -6
  47. package/src/jobs/i18n.ts +3 -0
  48. package/src/sessions/session-callbacks.ts +1 -0
  49. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  50. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  51. package/src/tenant/changes.json +1 -1
  52. package/src/tenant/constants.ts +5 -1
  53. package/src/tenant/handlers/remove-member.write.ts +12 -0
  54. package/src/tenant/handlers/update-member-roles.write.ts +10 -37
  55. package/src/tenant/last-tenant-admin.ts +39 -0
  56. package/src/tenant/screens.ts +2 -0
  57. package/src/tenant/seeding.ts +3 -1
  58. package/src/tenant-lifecycle/feature.ts +1 -0
  59. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  60. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  61. package/src/tenant-lifecycle/stages.ts +5 -0
  62. package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
  63. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  64. package/src/user/changes.json +12 -5
  65. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  66. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  67. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  68. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  69. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  70. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
  71. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  72. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.221.0",
3
+ "version": "0.222.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>",
@@ -71,6 +71,7 @@
71
71
  "./file-derivatives": "./src/file-derivatives/index.ts",
72
72
  "./derivatives-sharp": "./src/derivatives-sharp/index.ts",
73
73
  "./files": "./src/files/index.ts",
74
+ "./files-tenant-data": "./src/files-tenant-data/index.ts",
74
75
  "./user-data-rights": "./src/user-data-rights/index.ts",
75
76
  "./user-data-rights/web": "./src/user-data-rights/web/index.ts",
76
77
  "./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
@@ -126,12 +127,12 @@
126
127
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
128
  },
128
129
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.221.0",
130
- "@cosmicdrift/kumiko-framework": "0.221.0",
131
- "@cosmicdrift/kumiko-headless": "0.221.0",
132
- "@cosmicdrift/kumiko-renderer": "0.221.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.221.0",
134
- "@cosmicdrift/kumiko-types": "0.221.0",
130
+ "@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
131
+ "@cosmicdrift/kumiko-framework": "0.222.0",
132
+ "@cosmicdrift/kumiko-headless": "0.222.0",
133
+ "@cosmicdrift/kumiko-renderer": "0.222.0",
134
+ "@cosmicdrift/kumiko-renderer-web": "0.222.0",
135
+ "@cosmicdrift/kumiko-types": "0.222.0",
135
136
  "@mollie/api-client": "^4.5.0",
136
137
  "@node-rs/argon2": "^2.0.2",
137
138
  "@types/mailparser": "^3.4.6",
@@ -160,7 +161,7 @@
160
161
  "devDependencies": {
161
162
  "@testing-library/user-event": "^14.6.1",
162
163
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.221.0",
164
- "@cosmicdrift/kumiko-locale-es": "0.221.0"
164
+ "@cosmicdrift/kumiko-locale-de": "0.222.0",
165
+ "@cosmicdrift/kumiko-locale-es": "0.222.0"
165
166
  }
166
167
  }
@@ -218,14 +218,14 @@ describe("AuditLogScreen — actor name resolution", () => {
218
218
  expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
219
219
  });
220
220
 
221
- test("createdBy not in members list → cell is empty, never the raw id", async () => {
221
+ test("createdBy not in members list → cell shows Unknown actor, never the raw id", async () => {
222
222
  const view = renderScreen();
223
223
  await waitFor(() => {
224
224
  const cell = view.queryByTestId("cell-3-actor");
225
225
  if (cell === null) throw new Error("actor cell not rendered yet");
226
226
  });
227
227
  const cell = view.getByTestId("cell-3-actor");
228
- expect(cell.textContent).toBe("");
228
+ expect(cell.textContent).toBe("Unknown actor");
229
229
  expect(cell.textContent).not.toBe("user-missing");
230
230
  });
231
231
 
@@ -248,14 +248,14 @@ describe("AuditLogScreen — actor name resolution", () => {
248
248
  expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
249
249
  });
250
250
 
251
- test("createdBy is an unresolvable UUID → cell stays empty, raw UUID never rendered", async () => {
251
+ test("createdBy is an unresolvable UUID → Unknown actor label, raw UUID never rendered", async () => {
252
252
  const view = renderScreen();
253
253
  await waitFor(() => {
254
254
  const cell = view.queryByTestId("cell-5-actor");
255
255
  if (cell === null) throw new Error("actor cell not rendered yet");
256
256
  });
257
257
  const cell = view.getByTestId("cell-5-actor");
258
- expect(cell.textContent).toBe("");
258
+ expect(cell.textContent).toBe("Unknown actor");
259
259
  expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
260
260
  });
261
261
  });
@@ -271,7 +271,7 @@ describe("AuditLogScreen — members query failure", () => {
271
271
  if (cell === null) throw new Error("table not rendered yet");
272
272
  });
273
273
  expect(view.getByTestId("cell-1-type").textContent).toBe("widget.created");
274
- expect(view.getByTestId("cell-1-actor").textContent).toBe("");
274
+ expect(view.getByTestId("cell-1-actor").textContent).toBe("Unknown actor");
275
275
  });
276
276
  });
277
277
 
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { createConfigFeature } from "../../config/feature";
5
+ import { createTenantFeature } from "../../tenant/feature";
5
6
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
6
7
  import { createAuditFeature } from "../feature";
7
8
 
8
9
  describe("audit log screen + handler access alignment", () => {
9
- const features = [createConfigFeature(), createAuditFeature()];
10
+ const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
10
11
 
11
12
  test("boot-validates with audit-log screen registered", () => {
12
13
  expect(() => validateBoot(features)).not.toThrow();
@@ -21,6 +21,8 @@ import {
21
21
  unsafeCreateEntityTable,
22
22
  } from "@cosmicdrift/kumiko-framework/stack";
23
23
  import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
24
+ import { createConfigFeature } from "../../config";
25
+ import { createTenantFeature } from "../../tenant";
24
26
  import { AuditQueries } from "../constants";
25
27
  import { createAuditFeature } from "../feature";
26
28
 
@@ -59,7 +61,7 @@ const otherTenantAdmin: SessionUser = createTestUser({
59
61
 
60
62
  beforeAll(async () => {
61
63
  stack = await setupTestStack({
62
- features: [widgetFeature, createAuditFeature()],
64
+ features: [widgetFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
63
65
  });
64
66
  await unsafeCreateEntityTable(stack.db, widgetEntity);
65
67
  });
@@ -1,8 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { SYSTEM_ACTOR_ID } from "../constants";
2
+ import { SYSTEM_ACTOR_ID, SYSTEM_ACTOR_IDS } from "../constants";
3
3
 
4
4
  describe("audit constants", () => {
5
5
  test("SYSTEM_ACTOR_ID matches the event-store system user id", () => {
6
6
  expect(SYSTEM_ACTOR_ID).toBe("system");
7
7
  });
8
+
9
+ test("SYSTEM_ACTOR_IDS includes createSystemUser nil UUID", () => {
10
+ expect(SYSTEM_ACTOR_IDS.has(SYSTEM_ACTOR_ID)).toBe(true);
11
+ expect(SYSTEM_ACTOR_IDS.has("00000000-0000-0000-0000-000000000000")).toBe(true);
12
+ });
8
13
  });
@@ -2,9 +2,18 @@
2
2
  // Qualified handler names + screen ids — shared by server feature and web client.
3
3
  export const AUDIT_FEATURE = "audit" as const;
4
4
 
5
- /** Literal `createdBy` written by system-authored events (e.g. delivery attempts). */
5
+ /** Literal `createdBy` written by hand-built system actors (secrets/jobs/…). */
6
6
  export const SYSTEM_ACTOR_ID = "system" as const;
7
7
 
8
+ /** Literal `createdBy` for anonymous / unauthenticated actors. */
9
+ export const ANONYMOUS_USER_ID = "anonymous" as const;
10
+
11
+ /** All `createdBy` values that mean "system" in the audit UI (nil-UUID from createSystemUser + literal). */
12
+ export const SYSTEM_ACTOR_IDS: ReadonlySet<string> = new Set([
13
+ SYSTEM_ACTOR_ID,
14
+ "00000000-0000-0000-0000-000000000000",
15
+ ]);
16
+
8
17
  export const AuditQueries = {
9
18
  list: "audit:query:list",
10
19
  details: "audit:query:details",
@@ -31,6 +31,8 @@ export function createAuditFeature(): FeatureDefinition {
31
31
  recommended: false,
32
32
  });
33
33
  r.translations({ keys: AUDIT_I18N });
34
+ // Screens resolve actor names via tenant:query:members.
35
+ r.requires("tenant");
34
36
 
35
37
  const queries = {
36
38
  list: r.queryHandler(listQuery),
package/src/audit/i18n.ts CHANGED
@@ -17,6 +17,8 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
17
17
  "audit.log.col.aggregate": { en: "Aggregate" },
18
18
  "audit.log.col.actor": { en: "Actor" },
19
19
  "audit.log.actor.system": { en: "System" },
20
+ "audit.log.actor.anonymous": { en: "Anonymous" },
21
+ "audit.log.actor.unknown": { en: "Unknown actor" },
20
22
  "audit.log.filter.eventType": { en: "Event type" },
21
23
  "audit.log.filter.aggregateType": { en: "Aggregate type" },
22
24
  "audit.log.filter.from": { en: "From" },
@@ -11,7 +11,7 @@ import {
11
11
  import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
12
  import { type ReactNode, useCallback, useEffect, useState } from "react";
13
13
  import { TenantQueries } from "../../tenant/constants";
14
- import { AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
14
+ import { ANONYMOUS_USER_ID, AuditQueries, SYSTEM_ACTOR_IDS } from "../constants";
15
15
 
16
16
  type AuditDetail = {
17
17
  readonly id: string;
@@ -68,7 +68,7 @@ export function AuditLogDetailScreen(): ReactNode {
68
68
  return;
69
69
  }
70
70
  const event = res.data;
71
- if (event.createdBy === SYSTEM_ACTOR_ID) {
71
+ if (SYSTEM_ACTOR_IDS.has(event.createdBy)) {
72
72
  setState({ kind: "ready", event, memberLabel: "", isSystemActor: true });
73
73
  return;
74
74
  }
@@ -117,7 +117,11 @@ export function AuditLogDetailScreen(): ReactNode {
117
117
  }
118
118
 
119
119
  const { event, memberLabel, isSystemActor } = state;
120
- const actorName = isSystemActor ? t("audit.log.actor.system") : memberLabel;
120
+ const actorName = isSystemActor
121
+ ? t("audit.log.actor.system")
122
+ : event.createdBy === ANONYMOUS_USER_ID
123
+ ? t("audit.log.actor.anonymous")
124
+ : memberLabel || t("audit.log.actor.unknown");
121
125
 
122
126
  return (
123
127
  <FormScreenShell
@@ -14,7 +14,12 @@ import {
14
14
  import { Filter, RotateCcw } from "lucide-react";
15
15
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
16
16
  import { TenantQueries } from "../../tenant/constants";
17
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
17
+ import {
18
+ ANONYMOUS_USER_ID,
19
+ AUDIT_LOG_DETAIL_SCREEN_ID,
20
+ AuditQueries,
21
+ SYSTEM_ACTOR_IDS,
22
+ } from "../constants";
18
23
 
19
24
  type AuditRow = {
20
25
  readonly id: string;
@@ -36,7 +41,6 @@ type MemberRow = {
36
41
 
37
42
  type Filters = {
38
43
  readonly eventType: string;
39
- readonly aggregateType: string;
40
44
  readonly from: string;
41
45
  readonly to: string;
42
46
  };
@@ -51,7 +55,17 @@ type State =
51
55
  readonly names: ReadonlyMap<string, string>;
52
56
  };
53
57
 
54
- const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to: "" };
58
+ const EMPTY_FILTERS: Filters = { eventType: "", from: "", to: "" };
59
+
60
+ function actorLabel(
61
+ createdBy: string,
62
+ names: ReadonlyMap<string, string>,
63
+ t: (key: string) => string,
64
+ ): string {
65
+ if (SYSTEM_ACTOR_IDS.has(createdBy)) return t("audit.log.actor.system");
66
+ if (createdBy === ANONYMOUS_USER_ID) return t("audit.log.actor.anonymous");
67
+ return names.get(createdBy) ?? t("audit.log.actor.unknown");
68
+ }
55
69
 
56
70
  export function AuditLogScreen(): ReactNode {
57
71
  const t = useTranslation();
@@ -71,10 +85,11 @@ export function AuditLogScreen(): ReactNode {
71
85
  const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
72
86
 
73
87
  // Tenant/dispatcher identity can change without remounting — drop the cache.
74
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on dispatcher identity, not a read inside the effect
75
- useEffect(() => {
88
+ const dispatcherIdentityRef = useRef(dispatcher);
89
+ if (dispatcherIdentityRef.current !== dispatcher) {
90
+ dispatcherIdentityRef.current = dispatcher;
76
91
  membersRef.current = null;
77
- }, [dispatcher]);
92
+ }
78
93
 
79
94
  const load = useCallback(
80
95
  async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
@@ -100,9 +115,6 @@ export function AuditLogScreen(): ReactNode {
100
115
  limit: 50,
101
116
  ...(cursor !== undefined && { before: cursor }),
102
117
  ...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
103
- ...(f.aggregateType.trim() !== "" && {
104
- aggregateType: f.aggregateType.trim(),
105
- }),
106
118
  ...(f.from !== "" && { from: toIsoStart(f.from) }),
107
119
  ...(f.to !== "" && { to: toIsoEnd(f.to) }),
108
120
  }),
@@ -144,7 +156,7 @@ export function AuditLogScreen(): ReactNode {
144
156
  return (
145
157
  <div className="w-full" data-testid="audit-log-screen">
146
158
  <div className="flex flex-wrap items-end gap-4 px-6 pt-6">
147
- <div className="min-w-40 flex-1">
159
+ <div className="min-w-40 max-w-64 flex-1">
148
160
  <Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
149
161
  <Input
150
162
  kind="text"
@@ -155,18 +167,7 @@ export function AuditLogScreen(): ReactNode {
155
167
  />
156
168
  </Field>
157
169
  </div>
158
- <div className="min-w-40 flex-1">
159
- <Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
160
- <Input
161
- kind="text"
162
- id="audit-filter-aggregate"
163
- name="audit-filter-aggregate"
164
- value={filters.aggregateType}
165
- onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
166
- />
167
- </Field>
168
- </div>
169
- <div className="min-w-40 flex-1">
170
+ <div className="min-w-40 max-w-64 flex-1">
170
171
  <Field id="audit-filter-from" label={t("audit.log.filter.from")}>
171
172
  <Input
172
173
  kind="date"
@@ -177,7 +178,7 @@ export function AuditLogScreen(): ReactNode {
177
178
  />
178
179
  </Field>
179
180
  </div>
180
- <div className="min-w-40 flex-1">
181
+ <div className="min-w-40 max-w-64 flex-1">
181
182
  <Field id="audit-filter-to" label={t("audit.log.filter.to")}>
182
183
  <Input
183
184
  kind="date"
@@ -198,7 +199,7 @@ export function AuditLogScreen(): ReactNode {
198
199
  }}
199
200
  testId="audit-log-apply-filters"
200
201
  >
201
- <Filter className="h-4 w-4" />
202
+ <Filter className="h-4 w-4" aria-hidden="true" />
202
203
  <span>{t("audit.log.filter.apply")}</span>
203
204
  </Button>
204
205
  <Button
@@ -211,7 +212,7 @@ export function AuditLogScreen(): ReactNode {
211
212
  }}
212
213
  testId="audit-log-reset-filters"
213
214
  >
214
- <RotateCcw className="h-4 w-4" />
215
+ <RotateCcw className="h-4 w-4" aria-hidden="true" />
215
216
  <span>{t("audit.log.filter.reset")}</span>
216
217
  </Button>
217
218
  </div>
@@ -231,10 +232,7 @@ export function AuditLogScreen(): ReactNode {
231
232
  values: {
232
233
  when: formatWhen(row.createdAt),
233
234
  type: row.type,
234
- actor:
235
- row.createdBy === SYSTEM_ACTOR_ID
236
- ? t("audit.log.actor.system")
237
- : (state.names.get(row.createdBy) ?? ""),
235
+ actor: actorLabel(row.createdBy, state.names, t),
238
236
  },
239
237
  }))}
240
238
  onRowClick={(row) => openDetail(row.id)}
@@ -77,6 +77,9 @@ export type InviteCreateOptions = {
77
77
  // roles, and cannot raise above the inviter's max framework rank — only
78
78
  // further restrict assignable ranked roles.
79
79
  readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
80
+ /** Opt-in allow-list for app-defined (unranked) roles the elevation guard
81
+ * would otherwise reject with unassignable_membership_role (fw#2398). */
82
+ readonly additionalAssignableRoles?: readonly string[];
80
83
  };
81
84
 
82
85
  const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
@@ -108,7 +111,10 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
108
111
  [event.payload.role],
109
112
  [],
110
113
  );
111
- if (elevationForbidden !== undefined) {
114
+ if (
115
+ elevationForbidden !== undefined &&
116
+ !opts.additionalAssignableRoles?.includes(elevationForbidden)
117
+ ) {
112
118
  return writeFailure(unassignableMembershipRoleError(elevationForbidden));
113
119
  }
114
120
 
@@ -108,7 +108,9 @@ export async function seedUserWithPassword(
108
108
  * (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
109
109
  * Stellen unabhängig "TenantAdmin" hardcoded und würden bei einem
110
110
  * Refactor zu role-mismatch zwischen DB und Session leiden. */
111
- export const INITIAL_SIGNUP_ROLES = ["TenantAdmin"] as const;
111
+ // TenantAdmin is the canonical owner role; Admin stays for handlers that still
112
+ // gate on the legacy literal until those call sites move to access.admin.
113
+ export const INITIAL_SIGNUP_ROLES = ["TenantAdmin", "Admin"] as const;
112
114
 
113
115
  export type ProvisionSignupAccountOptions = {
114
116
  readonly email: string;
@@ -0,0 +1,75 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Temporal } from "temporal-polyfill";
3
+
4
+ function hmacSign(input: string, secret: string): string {
5
+ return createHmac("sha256", secret).update(input).digest("base64url");
6
+ }
7
+
8
+ export type HmacTokenVerifyResult<TPayload> =
9
+ | { readonly ok: true; readonly payload: TPayload; readonly expiresAtMs: number }
10
+ | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
11
+
12
+ export function createHmacTokenCodec<
13
+ TPayload extends { readonly userId: string; readonly tenantId: string },
14
+ >(domain: string, isPayload: (value: unknown) => value is TPayload) {
15
+ type EncodedBody = TPayload & { readonly expiresAtMs: number };
16
+
17
+ function isEncodedBody(value: unknown): value is EncodedBody {
18
+ if (typeof value !== "object" || value === null) return false;
19
+ const v = value as Record<string, unknown>;
20
+ const expiresAtMs = v["expiresAtMs"];
21
+ if (typeof expiresAtMs !== "number") return false;
22
+ const { expiresAtMs: _drop, ...rest } = v;
23
+ return isPayload(rest);
24
+ }
25
+
26
+ return {
27
+ sign(
28
+ payload: TPayload,
29
+ ttlMinutes: number,
30
+ secret: string,
31
+ now: Temporal.Instant = Temporal.Now.instant(),
32
+ ): { token: string; expiresAt: Temporal.Instant } {
33
+ const expiresAt = now.add({ minutes: ttlMinutes });
34
+ const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
35
+ const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
36
+ const signature = hmacSign(`${domain}:${bodyB64}`, secret);
37
+ return { token: `${bodyB64}.${signature}`, expiresAt };
38
+ },
39
+
40
+ verify(
41
+ token: string,
42
+ secret: string,
43
+ now: Temporal.Instant = Temporal.Now.instant(),
44
+ ): HmacTokenVerifyResult<TPayload> {
45
+ const parts = token.split(".");
46
+ if (parts.length !== 2) return { ok: false, reason: "malformed" };
47
+ const [bodyB64, providedSig] = parts;
48
+ if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
49
+
50
+ const expected = hmacSign(`${domain}:${bodyB64}`, secret);
51
+ const expectedBuf = Buffer.from(expected, "base64url");
52
+ const providedBuf = Buffer.from(providedSig, "base64url");
53
+ if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
54
+ if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
55
+
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
59
+ } catch {
60
+ return { ok: false, reason: "malformed" };
61
+ }
62
+ if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
63
+
64
+ if (
65
+ Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) >
66
+ 0
67
+ ) {
68
+ return { ok: false, reason: "expired" };
69
+ }
70
+
71
+ const { expiresAtMs, ...payload } = parsed;
72
+ return { ok: true, payload: payload as unknown as TPayload, expiresAtMs };
73
+ },
74
+ };
75
+ }
@@ -1,90 +1,35 @@
1
- // HMAC-signed token minted by the login handler once the password check
2
- // succeeds but a second factor is still owed. Carries just enough to look
3
- // the user back up at `/auth/mfa/verify` — no secret material (unlike
4
- // mfa-setup-token.ts, this token is never shown to the user, only round-
5
- // tripped by the client between the two login requests).
6
- //
7
- // Format: <base64url(JSON body)>.<hmac-base64url> — same shape as
8
- // mfa-setup-token.ts, distinct HMAC domain-separation string so a setup
9
- // token can never be replayed as a challenge token or vice versa.
10
- //
11
- // Replay protection: burnToken()/unburnToken() from shared/token-burn-store
12
- // (purpose "mfa-challenge") make the token single-use on a SUCCESSFUL
13
- // verify. Brute-force protection is a SEPARATE mechanism — see
14
- // mfa-verify-attempts.ts — because burn-on-success alone doesn't cap wrong
15
- // guesses against a still-valid token.
16
- import { createHmac, timingSafeEqual } from "node:crypto";
17
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
2
  import { Temporal } from "temporal-polyfill";
3
+ import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec";
19
4
 
20
5
  export type MfaChallengePayload = {
21
6
  readonly userId: string;
22
7
  readonly tenantId: TenantId;
23
8
  };
24
9
 
25
- type EncodedBody = MfaChallengePayload & { readonly expiresAtMs: number };
26
-
27
- function sign(input: string, secret: string): string {
28
- return createHmac("sha256", secret).update(input).digest("base64url");
10
+ function isPayload(value: unknown): value is MfaChallengePayload {
11
+ if (typeof value !== "object" || value === null) return false;
12
+ const v = value as Record<string, unknown>;
13
+ return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
29
14
  }
30
15
 
16
+ const codec = createHmacTokenCodec("mfa-challenge", isPayload);
17
+
31
18
  export function signMfaChallengeToken(
32
19
  payload: MfaChallengePayload,
33
20
  ttlMinutes: number,
34
21
  secret: string,
35
22
  now: Temporal.Instant = Temporal.Now.instant(),
36
23
  ): { token: string; expiresAt: Temporal.Instant } {
37
- const expiresAt = now.add({ minutes: ttlMinutes });
38
- const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
39
- const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
40
- const signature = sign(`mfa-challenge:${bodyB64}`, secret);
41
- return { token: `${bodyB64}.${signature}`, expiresAt };
24
+ return codec.sign(payload, ttlMinutes, secret, now);
42
25
  }
43
26
 
44
- export type VerifyMfaChallengeResult =
45
- | { readonly ok: true; readonly payload: MfaChallengePayload; readonly expiresAtMs: number }
46
- | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
47
-
48
- function isEncodedBody(value: unknown): value is EncodedBody {
49
- if (typeof value !== "object" || value === null) return false;
50
- const v = value as Record<string, unknown>;
51
- return (
52
- typeof v["userId"] === "string" &&
53
- typeof v["tenantId"] === "string" &&
54
- typeof v["expiresAtMs"] === "number"
55
- );
56
- }
27
+ export type VerifyMfaChallengeResult = HmacTokenVerifyResult<MfaChallengePayload>;
57
28
 
58
29
  export function verifyMfaChallengeToken(
59
30
  token: string,
60
31
  secret: string,
61
32
  now: Temporal.Instant = Temporal.Now.instant(),
62
33
  ): VerifyMfaChallengeResult {
63
- const parts = token.split(".");
64
- if (parts.length !== 2) return { ok: false, reason: "malformed" };
65
- const [bodyB64, providedSig] = parts;
66
- if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
67
-
68
- const expected = sign(`mfa-challenge:${bodyB64}`, secret);
69
- const expectedBuf = Buffer.from(expected, "base64url");
70
- const providedBuf = Buffer.from(providedSig, "base64url");
71
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
72
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
73
-
74
- let parsed: unknown;
75
- try {
76
- parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
77
- } catch {
78
- return { ok: false, reason: "malformed" };
79
- }
80
- if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
81
-
82
- if (
83
- Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
84
- ) {
85
- return { ok: false, reason: "expired" };
86
- }
87
-
88
- const { expiresAtMs, ...payload } = parsed;
89
- return { ok: true, payload, expiresAtMs };
34
+ return codec.verify(token, secret, now);
90
35
  }
@@ -1,86 +1,35 @@
1
- // HMAC-signed token minted by login.write.ts when enforcement policy blocks
2
- // an unenrolled user (mfa-setup-required) — proves the password check
3
- // already succeeded, so a later pre-auth enable-start variant (#1231) can
4
- // let the user enroll without a full session. No secret material (unlike
5
- // mfa-setup-token.ts) — just enough to look the user back up.
6
- //
7
- // Same domain-separated HMAC shape as mfa-challenge-token.ts, distinct
8
- // signing string so this can never be replayed as a login challenge or
9
- // vice versa. Reuses challengeTokenSecret rather than adding a third
10
- // feature secret: both tokens assert the identical trust boundary
11
- // ("password already verified, short-lived follow-up allowed").
12
- import { createHmac, timingSafeEqual } from "node:crypto";
13
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
2
  import { Temporal } from "temporal-polyfill";
3
+ import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec";
15
4
 
16
5
  export type MfaPreauthSetupPayload = {
17
6
  readonly userId: string;
18
7
  readonly tenantId: TenantId;
19
8
  };
20
9
 
21
- type EncodedBody = MfaPreauthSetupPayload & { readonly expiresAtMs: number };
22
-
23
- function sign(input: string, secret: string): string {
24
- return createHmac("sha256", secret).update(input).digest("base64url");
10
+ function isPayload(value: unknown): value is MfaPreauthSetupPayload {
11
+ if (typeof value !== "object" || value === null) return false;
12
+ const v = value as Record<string, unknown>;
13
+ return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
25
14
  }
26
15
 
16
+ const codec = createHmacTokenCodec("mfa-preauth-setup", isPayload);
17
+
27
18
  export function signMfaPreauthSetupToken(
28
19
  payload: MfaPreauthSetupPayload,
29
20
  ttlMinutes: number,
30
21
  secret: string,
31
22
  now: Temporal.Instant = Temporal.Now.instant(),
32
23
  ): { token: string; expiresAt: Temporal.Instant } {
33
- const expiresAt = now.add({ minutes: ttlMinutes });
34
- const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
35
- const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
36
- const signature = sign(`mfa-preauth-setup:${bodyB64}`, secret);
37
- return { token: `${bodyB64}.${signature}`, expiresAt };
24
+ return codec.sign(payload, ttlMinutes, secret, now);
38
25
  }
39
26
 
40
- export type VerifyMfaPreauthSetupResult =
41
- | { readonly ok: true; readonly payload: MfaPreauthSetupPayload; readonly expiresAtMs: number }
42
- | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
43
-
44
- function isEncodedBody(value: unknown): value is EncodedBody {
45
- if (typeof value !== "object" || value === null) return false;
46
- const v = value as Record<string, unknown>;
47
- return (
48
- typeof v["userId"] === "string" &&
49
- typeof v["tenantId"] === "string" &&
50
- typeof v["expiresAtMs"] === "number"
51
- );
52
- }
27
+ export type VerifyMfaPreauthSetupResult = HmacTokenVerifyResult<MfaPreauthSetupPayload>;
53
28
 
54
29
  export function verifyMfaPreauthSetupToken(
55
30
  token: string,
56
31
  secret: string,
57
32
  now: Temporal.Instant = Temporal.Now.instant(),
58
33
  ): VerifyMfaPreauthSetupResult {
59
- const parts = token.split(".");
60
- if (parts.length !== 2) return { ok: false, reason: "malformed" };
61
- const [bodyB64, providedSig] = parts;
62
- if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
63
-
64
- const expected = sign(`mfa-preauth-setup:${bodyB64}`, secret);
65
- const expectedBuf = Buffer.from(expected, "base64url");
66
- const providedBuf = Buffer.from(providedSig, "base64url");
67
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
68
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
69
-
70
- let parsed: unknown;
71
- try {
72
- parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
73
- } catch {
74
- return { ok: false, reason: "malformed" };
75
- }
76
- if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
77
-
78
- if (
79
- Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
80
- ) {
81
- return { ok: false, reason: "expired" };
82
- }
83
-
84
- const { expiresAtMs, ...payload } = parsed;
85
- return { ok: true, payload, expiresAtMs };
34
+ return codec.verify(token, secret, now);
86
35
  }
@@ -49,7 +49,7 @@ export function createStockCapGuard<TCaps>(
49
49
  return writeFailure(
50
50
  new UnprocessableError(spec.code, {
51
51
  i18nKey: spec.i18nKey,
52
- details: { field: spec.field, reason: spec.code, current, limit },
52
+ details: { field: spec.field, current, limit },
53
53
  }),
54
54
  );
55
55
  }