@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.221.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 (92) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  25. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  26. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  27. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  28. package/src/custom-fields/constants.ts +4 -3
  29. package/src/custom-fields/feature.ts +7 -8
  30. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  31. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  32. package/src/delivery/db/queries/preferences.ts +3 -2
  33. package/src/delivery/handlers/log.query.ts +1 -0
  34. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  35. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  36. package/src/file-derivatives/feature.ts +6 -1
  37. package/src/file-provider-s3/feature.ts +1 -2
  38. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  39. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  40. package/src/form-draft/handlers/discard.write.ts +8 -8
  41. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  42. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  43. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  44. package/src/jobs/feature.ts +7 -2
  45. package/src/jobs/handlers/detail.query.ts +22 -11
  46. package/src/jobs/handlers/list.query.ts +17 -6
  47. package/src/jobs/job-run-logger.ts +78 -36
  48. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  49. package/src/mail-transport-smtp/feature.ts +1 -2
  50. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  51. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  52. package/src/notes-history/handlers/add-note.write.ts +7 -3
  53. package/src/notes-history/web/notes-section.tsx +10 -2
  54. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  55. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  56. package/src/secrets/feature.ts +9 -2
  57. package/src/shared/index.ts +1 -0
  58. package/src/shared/map-with-concurrency.test.ts +2 -2
  59. package/src/shared/session-field.ts +10 -0
  60. package/src/shared/session-locale-field.test.ts +9 -0
  61. package/src/shared/session-locale-field.ts +4 -1
  62. package/src/shared/session-timezone-field.ts +2 -1
  63. package/src/subscription-stripe/feature.ts +5 -11
  64. package/src/tags/feature.ts +7 -4
  65. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  66. package/src/template-resolver/seeding.ts +78 -6
  67. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  68. package/src/template-resolver/web/client-plugin.tsx +2 -1
  69. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  70. package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
  71. package/src/tenant/handlers/members.query.ts +6 -1
  72. package/src/tenant/handlers/team-list.query.ts +2 -2
  73. package/src/tenant/handlers/update-member-roles.write.ts +1 -1
  74. package/src/tenant/i18n.ts +2 -2
  75. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  76. package/src/tenant-settings/feature.ts +6 -30
  77. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  78. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  79. package/src/user/__tests__/user.integration.test.ts +156 -118
  80. package/src/user/handlers/create.write.ts +5 -4
  81. package/src/user/handlers/detail.query.ts +24 -2
  82. package/src/user/handlers/list.query.ts +19 -26
  83. package/src/user/handlers/update-roles.ts +18 -5
  84. package/src/user/handlers/update.write.ts +6 -5
  85. package/src/user/roles-input-schema.ts +20 -0
  86. package/src/user/schema/user.ts +2 -3
  87. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  88. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  89. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  90. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  91. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  92. package/src/user-profile/web/profile-screen.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.220.1",
3
+ "version": "0.221.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.220.1",
130
- "@cosmicdrift/kumiko-framework": "0.220.1",
131
- "@cosmicdrift/kumiko-headless": "0.220.1",
132
- "@cosmicdrift/kumiko-renderer": "0.220.1",
133
- "@cosmicdrift/kumiko-renderer-web": "0.220.1",
134
- "@cosmicdrift/kumiko-types": "0.220.1",
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",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,7 +160,7 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.220.1",
164
- "@cosmicdrift/kumiko-locale-es": "0.220.1"
163
+ "@cosmicdrift/kumiko-locale-de": "0.221.0",
164
+ "@cosmicdrift/kumiko-locale-es": "0.221.0"
165
165
  }
166
166
  }
@@ -294,7 +294,7 @@ describe("access-invalidation event consumer", () => {
294
294
  {
295
295
  id: userId,
296
296
  version: detail["version"],
297
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
297
+ changes: { roles: ["SystemAdmin"] },
298
298
  },
299
299
  admin,
300
300
  );
@@ -307,6 +307,7 @@ describe("access-invalidation event consumer", () => {
307
307
  const sessionRow = await fetchOne<{ revokedAt: unknown }>(stack.db, userSessionTable, {
308
308
  id: sid,
309
309
  });
310
+ expect(sessionRow).toBeTruthy();
310
311
  expect(sessionRow?.revokedAt).not.toBeNull();
311
312
  });
312
313
  });
@@ -21,7 +21,8 @@ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-w
21
21
  import { render, waitFor } from "@testing-library/react";
22
22
  import type { ReactNode } from "react";
23
23
  import { TenantQueries } from "../../tenant/constants";
24
- import { AuditQueries } from "../constants";
24
+ import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
25
+ import { AuditLogDetailScreen } from "../web/audit-log-detail-screen";
25
26
  import { AuditLogScreen } from "../web/audit-log-screen";
26
27
  import { defaultTranslations } from "../web/i18n";
27
28
 
@@ -124,14 +125,32 @@ const MEMBERS: readonly MemberRowFixture[] = [
124
125
  { userId: "user-2", email: "bob@example.com", displayName: null },
125
126
  ];
126
127
 
127
- function makeDispatcher(): Dispatcher {
128
+ type AuditDetailFixture = AuditRowFixture & { readonly metadata: Record<string, unknown> };
129
+
130
+ function detailFixtureFor(row: AuditRowFixture): AuditDetailFixture {
131
+ return { ...row, metadata: {} };
132
+ }
133
+
134
+ type MembersResult =
135
+ | { readonly isSuccess: true; readonly data: readonly MemberRowFixture[] }
136
+ | { readonly isSuccess: false; readonly error: { readonly message: string } };
137
+
138
+ function makeDispatcher(
139
+ options: {
140
+ readonly detail?: AuditDetailFixture | null;
141
+ readonly membersResult?: MembersResult;
142
+ } = {},
143
+ ): Dispatcher {
128
144
  const statusStore = createStore<DispatcherStatus>("online");
129
145
  const query = (async (type: string) => {
130
146
  if (type === AuditQueries.list) {
131
147
  return { isSuccess: true, data: { rows: AUDIT_ROWS, nextBefore: null } };
132
148
  }
149
+ if (type === AuditQueries.details) {
150
+ return { isSuccess: true, data: options.detail ?? null };
151
+ }
133
152
  if (type === TenantQueries.members) {
134
- return { isSuccess: true, data: MEMBERS };
153
+ return options.membersResult ?? { isSuccess: true, data: MEMBERS };
135
154
  }
136
155
  return { isSuccess: true, data: null };
137
156
  }) as unknown as Dispatcher["query"];
@@ -146,7 +165,12 @@ function makeDispatcher(): Dispatcher {
146
165
  } as unknown as Dispatcher; // @cast-boundary test-stub
147
166
  }
148
167
 
149
- function renderScreen(): ReturnType<typeof render> {
168
+ function renderWithProviders(
169
+ ui: ReactNode,
170
+ options: { readonly nav?: NavApi; readonly dispatcher?: Dispatcher } = {},
171
+ ): ReturnType<typeof render> {
172
+ const nav = options.nav ?? stubNav;
173
+ const dispatcher = options.dispatcher ?? makeDispatcher();
150
174
  const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
151
175
  <TokensProvider value={stubTokens}>
152
176
  <LocaleProvider
@@ -154,16 +178,25 @@ function renderScreen(): ReturnType<typeof render> {
154
178
  fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
155
179
  >
156
180
  <PrimitivesProvider value={defaultPrimitives}>
157
- <NavProvider value={stubNav}>
181
+ <NavProvider value={nav}>
158
182
  <LiveEventsProvider value={stubLiveEvents}>
159
- <DispatcherProvider dispatcher={makeDispatcher()}>{children}</DispatcherProvider>
183
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
160
184
  </LiveEventsProvider>
161
185
  </NavProvider>
162
186
  </PrimitivesProvider>
163
187
  </LocaleProvider>
164
188
  </TokensProvider>
165
189
  );
166
- return render(<AuditLogScreen />, { wrapper });
190
+ return render(ui, { wrapper });
191
+ }
192
+
193
+ function renderScreen(dispatcher?: Dispatcher): ReturnType<typeof render> {
194
+ return renderWithProviders(<AuditLogScreen />, { dispatcher });
195
+ }
196
+
197
+ function renderDetailScreen(entityId: string, dispatcher: Dispatcher): ReturnType<typeof render> {
198
+ const nav: NavApi = { ...stubNav, route: { screenId: AUDIT_LOG_DETAIL_SCREEN_ID, entityId } };
199
+ return renderWithProviders(<AuditLogDetailScreen />, { nav, dispatcher });
167
200
  }
168
201
 
169
202
  describe("AuditLogScreen — actor name resolution", () => {
@@ -226,3 +259,44 @@ describe("AuditLogScreen — actor name resolution", () => {
226
259
  expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
227
260
  });
228
261
  });
262
+
263
+ describe("AuditLogScreen — members query failure", () => {
264
+ test("members query returns isSuccess:false → list still renders, no crash", async () => {
265
+ const dispatcher = makeDispatcher({
266
+ membersResult: { isSuccess: false, error: { message: "members unavailable" } },
267
+ });
268
+ const view = renderScreen(dispatcher);
269
+ await waitFor(() => {
270
+ const cell = view.queryByTestId("cell-1-type");
271
+ if (cell === null) throw new Error("table not rendered yet");
272
+ });
273
+ expect(view.getByTestId("cell-1-type").textContent).toBe("widget.created");
274
+ expect(view.getByTestId("cell-1-actor").textContent).toBe("");
275
+ });
276
+ });
277
+
278
+ describe("AuditLogDetailScreen — actor name resolution", () => {
279
+ test("known member with displayName → detail actor field shows the display name", async () => {
280
+ const row = AUDIT_ROWS[0];
281
+ if (row === undefined) throw new Error("fixture missing");
282
+ const dispatcher = makeDispatcher({ detail: detailFixtureFor(row) });
283
+ const view = renderDetailScreen(row.id, dispatcher);
284
+ await waitFor(() => {
285
+ const cell = view.queryByTestId("audit-detail-actor");
286
+ if (cell === null) throw new Error("actor field not rendered yet");
287
+ });
288
+ expect(view.getByTestId("audit-detail-actor").textContent).toBe("Alice Example");
289
+ });
290
+
291
+ test("known member without displayName → detail actor field falls back to email", async () => {
292
+ const row = AUDIT_ROWS[1];
293
+ if (row === undefined) throw new Error("fixture missing");
294
+ const dispatcher = makeDispatcher({ detail: detailFixtureFor(row) });
295
+ const view = renderDetailScreen(row.id, dispatcher);
296
+ await waitFor(() => {
297
+ const cell = view.queryByTestId("audit-detail-actor");
298
+ if (cell === null) throw new Error("actor field not rendered yet");
299
+ });
300
+ expect(view.getByTestId("audit-detail-actor").textContent).toBe("bob@example.com");
301
+ });
302
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SYSTEM_ACTOR_ID } from "../constants";
3
+
4
+ describe("audit constants", () => {
5
+ test("SYSTEM_ACTOR_ID matches the event-store system user id", () => {
6
+ expect(SYSTEM_ACTOR_ID).toBe("system");
7
+ });
8
+ });
@@ -34,7 +34,15 @@ type State =
34
34
  | { readonly kind: "loading" }
35
35
  | { readonly kind: "missing" }
36
36
  | { readonly kind: "error"; readonly message: string }
37
- | { readonly kind: "ready"; readonly event: AuditDetail; readonly actorName: string };
37
+ | {
38
+ readonly kind: "ready";
39
+ readonly event: AuditDetail;
40
+ /** Resolved member display string, or "" if unknown. System actor is
41
+ * flagged separately so the translated label is formed in render
42
+ * (avoids refetch on locale change — fw#2297). */
43
+ readonly memberLabel: string;
44
+ readonly isSystemActor: boolean;
45
+ };
38
46
 
39
47
  export function AuditLogDetailScreen(): ReactNode {
40
48
  const t = useTranslation();
@@ -60,21 +68,24 @@ export function AuditLogDetailScreen(): ReactNode {
60
68
  return;
61
69
  }
62
70
  const event = res.data;
63
- const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
71
+ if (event.createdBy === SYSTEM_ACTOR_ID) {
72
+ setState({ kind: "ready", event, memberLabel: "", isSystemActor: true });
73
+ return;
74
+ }
75
+ // Single-user filter — do not decrypt the whole tenant roster for one name.
76
+ const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {
77
+ userId: event.createdBy,
78
+ });
64
79
  const member = membersRes.isSuccess
65
80
  ? membersRes.data.find((m) => m.userId === event.createdBy)
66
81
  : undefined;
67
82
  setState({
68
83
  kind: "ready",
69
84
  event,
70
- actorName:
71
- event.createdBy === SYSTEM_ACTOR_ID
72
- ? t("audit.log.actor.system")
73
- : member
74
- ? (member.displayName ?? member.email ?? "")
75
- : "",
85
+ memberLabel: member ? (member.displayName ?? member.email ?? "") : "",
86
+ isSystemActor: false,
76
87
  });
77
- }, [dispatcher, eventId, t]);
88
+ }, [dispatcher, eventId]);
78
89
 
79
90
  // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
80
91
  useEffect(() => {
@@ -83,7 +94,7 @@ export function AuditLogDetailScreen(): ReactNode {
83
94
 
84
95
  if (state.kind === "loading") {
85
96
  return (
86
- <FormScreenShell testId="audit-log-detail-screen">
97
+ <FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
87
98
  <Text variant="small">{t("audit.log.detail.loading")}</Text>
88
99
  </FormScreenShell>
89
100
  );
@@ -91,7 +102,7 @@ export function AuditLogDetailScreen(): ReactNode {
91
102
 
92
103
  if (state.kind === "missing") {
93
104
  return (
94
- <FormScreenShell testId="audit-log-detail-screen">
105
+ <FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
95
106
  <Banner variant="error">{t("audit.log.detail.missing")}</Banner>
96
107
  </FormScreenShell>
97
108
  );
@@ -99,16 +110,21 @@ export function AuditLogDetailScreen(): ReactNode {
99
110
 
100
111
  if (state.kind === "error") {
101
112
  return (
102
- <FormScreenShell testId="audit-log-detail-screen">
113
+ <FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
103
114
  <Banner variant="error">{state.message}</Banner>
104
115
  </FormScreenShell>
105
116
  );
106
117
  }
107
118
 
108
- const { event, actorName } = state;
119
+ const { event, memberLabel, isSystemActor } = state;
120
+ const actorName = isSystemActor ? t("audit.log.actor.system") : memberLabel;
109
121
 
110
122
  return (
111
- <FormScreenShell testId="audit-log-detail-screen" className="flex flex-col gap-6">
123
+ <FormScreenShell
124
+ testId="audit-log-detail-screen"
125
+ maxWidth="3xl"
126
+ className="flex flex-col gap-6"
127
+ >
112
128
  <Card slots={{ title: event.type }}>
113
129
  <dl className="grid gap-3 text-sm">
114
130
  <div>
@@ -70,17 +70,31 @@ export function AuditLogScreen(): ReactNode {
70
70
  // mid-session and refetching on every "older" click is wasted work.
71
71
  const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
72
72
 
73
+ // 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(() => {
76
+ membersRef.current = null;
77
+ }, [dispatcher]);
78
+
73
79
  const load = useCallback(
74
80
  async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
75
81
  setState({ kind: "loading" });
76
82
  const f = overrideFilters ?? filtersRef.current;
77
- membersRef.current ??= dispatcher
78
- .query<readonly MemberRow[]>(TenantQueries.members, {})
79
- .then((res) =>
80
- res.isSuccess
81
- ? new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const))
82
- : new Map<string, string>(),
83
- );
83
+ if (!membersRef.current) {
84
+ const p = dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {}).then((res) => {
85
+ if (!res.isSuccess) {
86
+ // Soft failure: clear so a later filter/page retry can refetch
87
+ // instead of freezing an empty actor column for the session.
88
+ if (membersRef.current === p) membersRef.current = null;
89
+ return new Map<string, string>();
90
+ }
91
+ return new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const));
92
+ });
93
+ membersRef.current = p;
94
+ void p.catch(() => {
95
+ if (membersRef.current === p) membersRef.current = null;
96
+ });
97
+ }
84
98
  const [res, names] = await Promise.all([
85
99
  dispatcher.query<AuditResponse>(AuditQueries.list, {
86
100
  limit: 50,
@@ -78,6 +78,7 @@ async function seedLoginUser(opts: {
78
78
  password: string;
79
79
  tenantId?: TenantId;
80
80
  roles?: string[];
81
+ locale?: string;
81
82
  }): Promise<{ id: string; tenantId: TenantId }> {
82
83
  const hash = await hashPassword(opts.password);
83
84
  const created = await stack.http.writeOk<{ id: string }>(
@@ -86,6 +87,7 @@ async function seedLoginUser(opts: {
86
87
  email: opts.email,
87
88
  passwordHash: hash,
88
89
  displayName: opts.email.split("@")[0] ?? "user",
90
+ locale: opts.locale,
89
91
  },
90
92
  systemAdmin,
91
93
  );
@@ -134,6 +136,33 @@ describe("scenario 1: login success", () => {
134
136
  },
135
137
  );
136
138
  expect(meRes.status).toBe(200);
139
+
140
+ // fw#2333 — a user without a stored locale must not carry a locale
141
+ // claim at all (not null, not "").
142
+ const payload = await stack.jwt.verify(body.token as string);
143
+ expect(payload.locale).toBeUndefined();
144
+ });
145
+
146
+ test("correct credentials with a stored locale → JWT carries the locale claim", async () => {
147
+ const seed = await seedLoginUser({
148
+ email: "good-locale@example.com",
149
+ password: "correct-horse-battery",
150
+ roles: ["User"],
151
+ locale: "de-DE",
152
+ });
153
+
154
+ const res = await stack.http.raw("POST", "/api/auth/login", {
155
+ email: "good-locale@example.com",
156
+ password: "correct-horse-battery",
157
+ });
158
+
159
+ expect(res.status).toBe(200);
160
+ const body = await res.json();
161
+ expect(body.token).toBeTypeOf("string");
162
+
163
+ const payload = await stack.jwt.verify(body.token as string);
164
+ expect(payload.locale).toBe("de-DE");
165
+ expect(payload.sub).toBe(seed.id);
137
166
  });
138
167
  });
139
168
 
@@ -166,6 +166,16 @@ function aliceSession(): SessionUser {
166
166
  return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
167
167
  }
168
168
 
169
+ // auth-mfa:write:verify runs pre-session (access: { roles: ["all"] }) —
170
+ // dispatched here the same way the framework's /api/auth/mfa/verify route
171
+ // would, with a guest identity. The handler derives everything it needs
172
+ // from the challenge token, not from this actor.
173
+ const GUEST: SessionUser = {
174
+ id: "00000000-0000-0000-0000-000000000000",
175
+ tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
176
+ roles: ["all"],
177
+ };
178
+
169
179
  async function inviteEmail(email: string, role: string): Promise<string> {
170
180
  await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
171
181
  const sent = emailTransport.sent.at(-1);
@@ -230,6 +240,79 @@ describe("invite-accept-with-login: MFA gate", () => {
230
240
  // No session — a challenge, not a JWT.
231
241
  expect(body.token).toBeUndefined();
232
242
  });
243
+
244
+ // fw#2333 — completing the challenge via auth-mfa:write:verify must carry
245
+ // the invitee's locale into the minted session, same as a plain login.
246
+ async function acceptWithLoginThenVerifyMfa(opts: {
247
+ actorId: number;
248
+ email: string;
249
+ password: string;
250
+ locale?: string;
251
+ }): Promise<SessionUser> {
252
+ const mfaActor = createTestUser({ id: opts.actorId, tenantId: TENANT_A_ID, roles: ["User"] });
253
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
254
+ AuthMfaHandlers.enableStart,
255
+ { accountLabel: opts.email },
256
+ mfaActor,
257
+ );
258
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
259
+ const secret = base32Decode(secretParam);
260
+ await stack.http.writeOk(
261
+ AuthMfaHandlers.enableConfirm,
262
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
263
+ mfaActor,
264
+ );
265
+
266
+ await seedRow(stack.db, userTable, {
267
+ id: mfaActor.id,
268
+ tenantId: TENANT_A_ID,
269
+ email: opts.email,
270
+ passwordHash: await hashPassword(opts.password),
271
+ displayName: "MFA Invitee",
272
+ ...(opts.locale !== undefined && { locale: opts.locale }),
273
+ emailVerified: true,
274
+ roles: "[]",
275
+ status: USER_STATUS.Active,
276
+ });
277
+
278
+ const token = await inviteEmail(opts.email, "Editor");
279
+ const acceptRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
280
+ token,
281
+ email: opts.email,
282
+ password: opts.password,
283
+ });
284
+ expect(acceptRes.status).toBe(200);
285
+ const acceptBody = (await acceptRes.json()) as { challengeToken?: string };
286
+ if (!acceptBody.challengeToken) {
287
+ throw new Error("invite-accept-with-login did not return a challenge token");
288
+ }
289
+
290
+ const verified = await stack.http.writeOk<{ session: SessionUser }>(
291
+ AuthMfaHandlers.verify,
292
+ { challengeToken: acceptBody.challengeToken, code: currentTotpCode(secret) },
293
+ GUEST,
294
+ );
295
+ return verified.session;
296
+ }
297
+
298
+ test("completing the mfa/verify challenge carries the invitee's locale into the session", async () => {
299
+ const session = await acceptWithLoginThenVerifyMfa({
300
+ actorId: 702,
301
+ email: "mfa-locale-invitee@example.com",
302
+ password: "mfa-locale-invitee-pw-1234",
303
+ locale: "de-DE",
304
+ });
305
+ expect(session.locale).toBe("de-DE");
306
+ });
307
+
308
+ test("completing the mfa/verify challenge without a stored locale omits the claim", async () => {
309
+ const session = await acceptWithLoginThenVerifyMfa({
310
+ actorId: 703,
311
+ email: "mfa-nolocale-invitee@example.com",
312
+ password: "mfa-nolocale-invitee-pw-1234",
313
+ });
314
+ expect(session.locale).toBeUndefined();
315
+ });
233
316
  });
234
317
 
235
318
  describe("invite-accept-with-login: account-status gate", () => {
@@ -369,6 +369,42 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
369
369
  // Membership added
370
370
  const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
371
371
  expect(memberships).toHaveLength(2);
372
+
373
+ // fw#2333 — Bob has no stored locale, so the JWT must carry no locale
374
+ // claim at all (not null, not "").
375
+ const payload = await stack.jwt.verify(body.token as string);
376
+ expect(payload.locale).toBeUndefined();
377
+ });
378
+
379
+ test("Bob accepts with a locale → JWT retains the locale", async () => {
380
+ await asRawClient(stack.db).unsafe(
381
+ `UPDATE "${userTable.tableName}" SET locale = $1 WHERE id = $2`,
382
+ ["de-DE", bobId],
383
+ );
384
+ try {
385
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
386
+
387
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
388
+ token,
389
+ email: BOB_EMAIL,
390
+ password: BOB_PASSWORD,
391
+ });
392
+ expect(res.status).toBe(200);
393
+ const body = (await res.json()) as { token?: string };
394
+ expect(body.token).toBeTypeOf("string");
395
+ if (!body.token) throw new Error("invite acceptance did not return a token");
396
+
397
+ const payload = await stack.jwt.verify(body.token);
398
+ expect(payload.locale).toBe("de-DE");
399
+ } finally {
400
+ // Bob is shared across this describe block (other tests rely on his
401
+ // membership state persisting) — restore his locale so later tests
402
+ // don't silently inherit it.
403
+ await asRawClient(stack.db).unsafe(
404
+ `UPDATE "${userTable.tableName}" SET locale = NULL WHERE id = $1`,
405
+ [bobId],
406
+ );
407
+ }
372
408
  });
373
409
 
374
410
  test("Bob accepts with a timezone → JWT retains the timezone", async () => {
@@ -7,7 +7,8 @@
7
7
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
11
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
11
12
  import {
12
13
  createTestUser,
13
14
  setupTestStack,
@@ -18,11 +19,14 @@ import {
18
19
  } from "@cosmicdrift/kumiko-framework/stack";
19
20
  import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
20
21
  import {
22
+ AuthMfaHandlers,
23
+ base32Decode,
21
24
  createAuthMfaFeature,
22
25
  mfaRequiredConfigHandle,
23
26
  mfaStatusCheckerFromFeature,
24
27
  } from "../../auth-mfa";
25
28
  import { userMfaEntity } from "../../auth-mfa/schema/user-mfa";
29
+ import { currentTotpCode } from "../../auth-mfa/totp";
26
30
  import { ConfigHandlers, createConfigFeature } from "../../config";
27
31
  import { createConfigResolver } from "../../config/resolver";
28
32
  import { configValuesTable } from "../../config/table";
@@ -42,8 +46,20 @@ let stack: TestStack;
42
46
  const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
43
47
  const TENANT_ID: TenantId = testTenantId(400);
44
48
 
49
+ // auth-mfa:write:enable-start-preauth / enable-confirm-preauth both run
50
+ // pre-session (access: { roles: ["all"] }) — dispatched here the same way
51
+ // the framework's /api/auth/mfa/preauth-enable-start and preauth-confirm
52
+ // routes would, with a guest identity. Both handlers derive everything they
53
+ // need from the verified token, not from this actor.
54
+ const GUEST: SessionUser = {
55
+ id: "00000000-0000-0000-0000-000000000000",
56
+ tenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
57
+ roles: ["all"],
58
+ };
59
+
45
60
  beforeAll(async () => {
46
61
  const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
62
+ configureEntityFieldEncryption(encryption);
47
63
  const resolver = createConfigResolver({ cipher: encryption });
48
64
  const authMfaFeature = createAuthMfaFeature({
49
65
  setupTokenSecret: "test-mfa-setup-token-secret-at-least-32-bytes!!",
@@ -126,4 +142,76 @@ describe("login: mfa-setup-required carries a verifiable preauthSetupToken", ()
126
142
  expect(verified.payload).toEqual({ userId: created.id, tenantId: TENANT_ID });
127
143
  }
128
144
  });
145
+
146
+ // fw#2333 — completing the blocked login via auth-mfa:write:enable-start-
147
+ // preauth + enable-confirm-preauth must carry the user's stored locale
148
+ // into the session it mints, same as a plain login.
149
+ async function completeMfaSetupRequiredLogin(opts: {
150
+ actorId: number;
151
+ email: string;
152
+ locale?: string;
153
+ }): Promise<SessionUser> {
154
+ const password = "correct-horse-battery-2026";
155
+ const hash = await hashPassword(password);
156
+ const created = await stack.http.writeOk<{ id: string }>(
157
+ UserHandlers.create,
158
+ {
159
+ email: opts.email,
160
+ passwordHash: hash,
161
+ displayName: "Unenrolled",
162
+ ...(opts.locale !== undefined && { locale: opts.locale }),
163
+ },
164
+ createTestUser({ id: opts.actorId, tenantId: TENANT_ID, roles: ["SystemAdmin"] }),
165
+ );
166
+ await seedTenantMembership(stack.db, {
167
+ userId: created.id,
168
+ tenantId: TENANT_ID,
169
+ roles: ["User"],
170
+ });
171
+ await stack.http.writeOk(
172
+ ConfigHandlers.set,
173
+ { key: mfaRequiredConfigHandle.name, value: "all" },
174
+ createTestUser({ id: opts.actorId + 1, tenantId: TENANT_ID, roles: ["Admin"] }),
175
+ );
176
+
177
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
178
+ email: opts.email,
179
+ password,
180
+ });
181
+ expect(loginRes.status).toBe(200);
182
+ const loginBody = await loginRes.json();
183
+ expect(typeof loginBody.preauthSetupToken).toBe("string");
184
+
185
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
186
+ AuthMfaHandlers.enableStartPreauth,
187
+ { preauthSetupToken: loginBody.preauthSetupToken, accountLabel: opts.email },
188
+ GUEST,
189
+ );
190
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
191
+ const secret = base32Decode(secretParam);
192
+
193
+ const confirmed = await stack.http.writeOk<{ session: SessionUser }>(
194
+ AuthMfaHandlers.enableConfirmPreauth,
195
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
196
+ GUEST,
197
+ );
198
+ return confirmed.session;
199
+ }
200
+
201
+ test("completing mfa-setup-required via enable-start/confirm-preauth carries the user's locale", async () => {
202
+ const session = await completeMfaSetupRequiredLogin({
203
+ actorId: 501,
204
+ email: "preauth-locale@example.com",
205
+ locale: "de-DE",
206
+ });
207
+ expect(session.locale).toBe("de-DE");
208
+ });
209
+
210
+ test("completing mfa-setup-required via enable-start/confirm-preauth without a stored locale omits the claim", async () => {
211
+ const session = await completeMfaSetupRequiredLogin({
212
+ actorId: 503,
213
+ email: "preauth-nolocale@example.com",
214
+ });
215
+ expect(session.locale).toBeUndefined();
216
+ });
129
217
  });