@cosmicdrift/kumiko-bundled-features 0.220.1 → 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 (151) hide show
  1. package/package.json +10 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +85 -11
  4. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  5. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  6. package/src/audit/__tests__/constants.test.ts +13 -0
  7. package/src/audit/constants.ts +10 -1
  8. package/src/audit/feature.ts +2 -0
  9. package/src/audit/i18n.ts +2 -0
  10. package/src/audit/web/audit-log-detail-screen.tsx +35 -15
  11. package/src/audit/web/audit-log-screen.tsx +45 -33
  12. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  13. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  14. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  15. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  16. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  18. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  19. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  20. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  21. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  22. package/src/auth-email-password/handlers/invite-create.write.ts +15 -10
  23. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  24. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  25. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  26. package/src/auth-email-password/i18n.ts +1 -0
  27. package/src/auth-email-password/magic-link-mail.ts +17 -1
  28. package/src/auth-email-password/seeding.ts +3 -1
  29. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  30. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  31. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  32. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  33. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  34. package/src/cap-counter/stock-cap-guard.ts +1 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  36. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  37. package/src/channel-email/email-channel.ts +14 -7
  38. package/src/channel-email/pii-guard.ts +8 -0
  39. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  40. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  41. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  42. package/src/crypto-shredding/constants.ts +1 -0
  43. package/src/crypto-shredding/feature.ts +6 -1
  44. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  45. package/src/crypto-shredding/index.ts +2 -0
  46. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  47. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  48. package/src/custom-fields/constants.ts +4 -3
  49. package/src/custom-fields/feature.ts +7 -8
  50. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  51. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  52. package/src/delivery/db/queries/preferences.ts +3 -2
  53. package/src/delivery/handlers/log.query.ts +1 -0
  54. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  55. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  56. package/src/file-derivatives/feature.ts +8 -3
  57. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  58. package/src/file-provider-s3/feature.ts +1 -2
  59. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  60. package/src/files-provider-s3/s3-provider.ts +45 -0
  61. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  62. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  63. package/src/files-tenant-data/changes.json +1 -0
  64. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  65. package/src/files-tenant-data/hooks.ts +93 -0
  66. package/src/files-tenant-data/index.ts +58 -0
  67. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  68. package/src/form-draft/db/queries/cleanup.ts +17 -0
  69. package/src/form-draft/handlers/cleanup.job.ts +51 -30
  70. package/src/form-draft/handlers/discard.write.ts +33 -15
  71. package/src/form-draft/handlers/get.query.ts +4 -2
  72. package/src/form-draft/handlers/list.query.ts +4 -1
  73. package/src/form-draft/lookup.ts +12 -5
  74. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  75. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  76. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  77. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +286 -23
  78. package/src/jobs/constants.ts +1 -0
  79. package/src/jobs/feature.ts +7 -2
  80. package/src/jobs/handlers/detail.query.ts +22 -11
  81. package/src/jobs/handlers/list.query.ts +17 -6
  82. package/src/jobs/handlers/retry.write.ts +26 -6
  83. package/src/jobs/i18n.ts +3 -0
  84. package/src/jobs/job-run-logger.ts +78 -36
  85. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  86. package/src/mail-transport-smtp/feature.ts +1 -2
  87. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  88. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  89. package/src/notes-history/handlers/add-note.write.ts +7 -3
  90. package/src/notes-history/web/notes-section.tsx +10 -2
  91. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  92. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  93. package/src/secrets/feature.ts +9 -2
  94. package/src/sessions/session-callbacks.ts +1 -0
  95. package/src/shared/index.ts +1 -0
  96. package/src/shared/map-with-concurrency.test.ts +2 -2
  97. package/src/shared/session-field.ts +10 -0
  98. package/src/shared/session-locale-field.test.ts +9 -0
  99. package/src/shared/session-locale-field.ts +4 -1
  100. package/src/shared/session-timezone-field.ts +2 -1
  101. package/src/subscription-stripe/feature.ts +5 -11
  102. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  103. package/src/tags/feature.ts +7 -4
  104. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  105. package/src/template-resolver/seeding.ts +78 -6
  106. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  107. package/src/template-resolver/web/client-plugin.tsx +2 -1
  108. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  109. package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
  110. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  111. package/src/tenant/changes.json +1 -1
  112. package/src/tenant/constants.ts +5 -1
  113. package/src/tenant/handlers/members.query.ts +6 -1
  114. package/src/tenant/handlers/remove-member.write.ts +12 -0
  115. package/src/tenant/handlers/team-list.query.ts +2 -2
  116. package/src/tenant/handlers/update-member-roles.write.ts +11 -38
  117. package/src/tenant/i18n.ts +2 -2
  118. package/src/tenant/last-tenant-admin.ts +39 -0
  119. package/src/tenant/screens.ts +2 -0
  120. package/src/tenant/seeding.ts +3 -1
  121. package/src/tenant-lifecycle/feature.ts +1 -0
  122. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  123. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  124. package/src/tenant-lifecycle/stages.ts +5 -0
  125. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  126. package/src/tenant-settings/feature.ts +6 -30
  127. package/src/tier-engine/web/tier-admin-screen.tsx +1 -1
  128. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  129. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  130. package/src/user/__tests__/user.integration.test.ts +156 -118
  131. package/src/user/changes.json +12 -5
  132. package/src/user/handlers/create.write.ts +5 -4
  133. package/src/user/handlers/detail.query.ts +24 -2
  134. package/src/user/handlers/list.query.ts +19 -26
  135. package/src/user/handlers/update-roles.ts +18 -5
  136. package/src/user/handlers/update.write.ts +6 -5
  137. package/src/user/roles-input-schema.ts +20 -0
  138. package/src/user/schema/user.ts +2 -3
  139. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  140. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  141. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  142. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  143. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  144. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  145. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  146. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  147. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +33 -6
  148. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  149. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  150. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
  151. package/src/user-profile/web/profile-screen.tsx +1 -1
@@ -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
  });
@@ -0,0 +1,38 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
3
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
4
+
5
+ describe("dispatchMagicLinkMail appUrl locale negotiation", () => {
6
+ beforeAll(() => {
7
+ registerMailTranslations("de", { "test.unused": "x" });
8
+ });
9
+
10
+ test("negotiates de-AT down to de for language-in-path appUrl", async () => {
11
+ const seen: string[] = [];
12
+ await dispatchMagicLinkMail(
13
+ async () => {},
14
+ {
15
+ handlerName: "test",
16
+ notificationType: "test.mail",
17
+ renderContent: (args) => {
18
+ seen.push(args.url);
19
+ return {
20
+ subject: "s",
21
+ header: "h",
22
+ sections: [],
23
+ footer: "f",
24
+ };
25
+ },
26
+ },
27
+ {
28
+ email: "a@example.com",
29
+ appUrl: (locale) => `https://app.example.com/${locale}/activate`,
30
+ token: "tok",
31
+ expiresAt: "2099-01-01T00:00:00Z",
32
+ locale: "de-AT",
33
+ },
34
+ );
35
+ expect(seen[0]).toContain("https://app.example.com/de/activate");
36
+ expect(seen[0]).not.toContain("/de-AT/");
37
+ });
38
+ });
@@ -173,7 +173,7 @@ describe("multi-roles: privilege-escalation blocked via field-level write-access
173
173
  {
174
174
  id: targetId,
175
175
  version: 1,
176
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
176
+ changes: { roles: ["SystemAdmin"] },
177
177
  },
178
178
  tenantAdminSession,
179
179
  );
@@ -201,7 +201,7 @@ describe("multi-roles: privilege-escalation blocked via field-level write-access
201
201
  {
202
202
  id: userId,
203
203
  version: 1,
204
- changes: { roles: JSON.stringify(["SystemAdmin"]) },
204
+ changes: { roles: ["SystemAdmin"] },
205
205
  },
206
206
  userSession,
207
207
  );
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
4
5
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
6
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
@@ -41,11 +42,6 @@ import { signResetToken } from "../reset-token";
41
42
  // directly (no jobRunner in the test stack → inline send).
42
43
  const emailTransport = createInMemoryTransport();
43
44
 
44
- // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
45
- // constant is a framework-internal implementation detail, not exported from
46
- // the public /api barrel (same as TENANT_HEADER_NAME).
47
- const LOCALE_HEADER_NAME = "X-Locale";
48
-
49
45
  registerMailTranslations("de", localeDeBundle);
50
46
 
51
47
  // Records the userId every time the sessions feature's auto-revoke hook
@@ -26,6 +26,7 @@
26
26
  // zusammen).
27
27
 
28
28
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
29
+ import { LOCALE_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
29
30
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
30
31
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
31
32
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -98,11 +99,6 @@ const tenantMembershipHookVendorFeature = defineFeature(
98
99
 
99
100
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
100
101
 
101
- // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
102
- // constant is a framework-internal implementation detail, not exported from
103
- // the public /api barrel (same as TENANT_HEADER_NAME).
104
- const LOCALE_HEADER_NAME = "X-Locale";
105
-
106
102
  registerMailTranslations("de", localeDeBundle);
107
103
 
108
104
  // Activation mails now go through delivery (ctx.notify → channel-email). The
@@ -39,6 +39,11 @@ import {
39
39
  tenantInvitationEntity,
40
40
  tenantInvitationsTable,
41
41
  } from "../../tenant/invitation-table";
42
+ // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
43
+ import {
44
+ findForbiddenMembershipRole,
45
+ reservedMembershipRoleError,
46
+ } from "../../tenant/membership-roles";
42
47
  // kumiko-lint-ignore cross-feature-import membership-seed-helper for a privileged cross-tenant add
43
48
  import { seedTenantMembership } from "../../tenant/seeding";
44
49
  // kumiko-lint-ignore cross-feature-import login-style password-check
@@ -204,6 +209,10 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
204
209
  const dbConn = ctx.db.raw;
205
210
 
206
211
  if (!alreadyMember) {
212
+ const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
213
+ if (forbiddenInviteRole !== undefined) {
214
+ return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
215
+ }
207
216
  await seedTenantMembership(dbConn, {
208
217
  userId,
209
218
  tenantId: invitationTenantId,
@@ -31,6 +31,11 @@ import {
31
31
  tenantInvitationEntity,
32
32
  tenantInvitationsTable,
33
33
  } from "../../tenant/invitation-table";
34
+ // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
35
+ import {
36
+ findForbiddenMembershipRole,
37
+ reservedMembershipRoleError,
38
+ } from "../../tenant/membership-roles";
34
39
  // kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
35
40
  import { tenantMembershipsTable } from "../../tenant/membership-table";
36
41
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
@@ -70,6 +75,7 @@ export function createInviteAcceptHandler() {
70
75
  // nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
71
76
  // dispatched wird.
72
77
  access: { openToAll: true },
78
+ // kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
73
79
  handler: async (event, ctx) => {
74
80
  if (!ctx.redis) {
75
81
  return writeFailure(
@@ -142,6 +148,10 @@ export function createInviteAcceptHandler() {
142
148
  // ["SystemAdmin"]-Role akzeptiert; createSystemUser produziert
143
149
  // "system"-Role die NICHT matcht. Direkt-via-Executor bypassed
144
150
  // den Access-Check für privilegierte Cross-Tenant-Operationen.
151
+ const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
152
+ if (forbiddenInviteRole !== undefined) {
153
+ return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
154
+ }
145
155
  await seedTenantMembership(dbConn, {
146
156
  userId: event.user.id,
147
157
  tenantId: invitationTenantId,
@@ -17,7 +17,7 @@
17
17
  // erlaubt new-user-signup mit dem Token. Keine Enumeration durchs
18
18
  // invite-create.
19
19
 
20
- import { generateToken, requestContext } from "@cosmicdrift/kumiko-framework/api";
20
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
21
21
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
22
22
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
23
23
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
@@ -41,7 +41,7 @@ import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
41
41
  import type { AuthMailLocale } from "../email-templates";
42
42
  import { renderInviteEmail } from "../email-templates";
43
43
  import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store";
44
- import { dispatchMagicLinkMail } from "../magic-link-mail";
44
+ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
45
45
 
46
46
  const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
47
47
 
@@ -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, {
@@ -103,10 +106,15 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
103
106
  return writeFailure(reservedMembershipRoleError(forbiddenRole));
104
107
  }
105
108
 
106
- const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [
107
- event.payload.role,
108
- ]);
109
- if (elevationForbidden !== undefined) {
109
+ const elevationForbidden = findForbiddenRoleAssignment(
110
+ event.user.roles,
111
+ [event.payload.role],
112
+ [],
113
+ );
114
+ if (
115
+ elevationForbidden !== undefined &&
116
+ !opts.additionalAssignableRoles?.includes(elevationForbidden)
117
+ ) {
110
118
  return writeFailure(unassignableMembershipRoleError(elevationForbidden));
111
119
  }
112
120
 
@@ -168,10 +176,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
168
176
 
169
177
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
170
178
 
171
- // Same precedence as signup/reset/verify: the inviting admin's active
172
- // browser language wins when the request carries one, else the
173
- // handler's static opts.locale, else ctx.locale's own default.
174
- const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
179
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
175
180
 
176
181
  await dispatchMagicLinkMail(
177
182
  ctx.notify,
@@ -39,6 +39,11 @@ import {
39
39
  tenantInvitationEntity,
40
40
  tenantInvitationsTable,
41
41
  } from "../../tenant/invitation-table";
42
+ // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
43
+ import {
44
+ findForbiddenMembershipRole,
45
+ reservedMembershipRoleError,
46
+ } from "../../tenant/membership-roles";
42
47
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
43
48
  import { seedTenantMembership } from "../../tenant/seeding";
44
49
  // kumiko-lint-ignore cross-feature-import existence-check
@@ -143,6 +148,10 @@ export function createInviteSignupCompleteHandler() {
143
148
  // Membership-Add via seed-helper (gleiches Pattern wie
144
149
  // provisionSignupAccount — bypassed addMember access-check
145
150
  // weil createSystemUser nicht ["SystemAdmin"] matcht).
151
+ const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
152
+ if (forbiddenInviteRole !== undefined) {
153
+ return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
154
+ }
146
155
  await seedTenantMembership(dbConn, {
147
156
  userId,
148
157
  tenantId: invitationTenantId,
@@ -21,7 +21,7 @@
21
21
  // suppressing the link on the request side would be defense-in-depth, but
22
22
  // with an enumeration risk of its own (separate concern).
23
23
 
24
- import { generateToken, requestContext } from "@cosmicdrift/kumiko-framework/api";
24
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
25
25
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import { Temporal } from "temporal-polyfill";
@@ -29,7 +29,7 @@ import { z } from "zod";
29
29
  import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
30
30
  import type { AuthMailLocale } from "../email-templates";
31
31
  import { renderActivationEmail } from "../email-templates";
32
- import { dispatchMagicLinkMail } from "../magic-link-mail";
32
+ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
33
33
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
34
34
  import {
35
35
  invalidateExistingSignupToken,
@@ -133,15 +133,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
133
133
  // get the same format.
134
134
  const normalizedEmail = normalizeEmail(email);
135
135
 
136
- // ctx.locale always resolves to something (falls back to the app's
137
- // boot default, then "en" — dispatch-shared.ts), so it can't tell us
138
- // whether THIS request actually carried a locale signal. Read the raw
139
- // request-layer value instead: present → the browser's active
140
- // language wins over opts.locale (this handler's static config);
141
- // absent → opts.locale is the real, still-relevant fallback
142
- // (backwards compatibility for callers that configured it and never
143
- // send X-Locale), then ctx.locale's own resolved default.
144
- const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
136
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
145
137
 
146
138
  await dispatchMagicLinkMail(
147
139
  ctx.notify,
@@ -12,7 +12,6 @@
12
12
  // default TTL, extra skip condition) + two error codes — encoded on the
13
13
  // spec rather than duplicated across two near-identical handler bodies.
14
14
 
15
- import { requestContext } from "@cosmicdrift/kumiko-framework/api";
16
15
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
17
16
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
18
17
  import type { Temporal } from "temporal-polyfill";
@@ -20,7 +19,7 @@ import { z } from "zod";
20
19
  import { UserQueries } from "../../user";
21
20
  import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
22
21
  import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "../email-templates";
23
- import { dispatchMagicLinkMail } from "../magic-link-mail";
22
+ import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail";
24
23
 
25
24
  const RequestTokenSchema = z.object({
26
25
  email: z.email(),
@@ -124,14 +123,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
124
123
 
125
124
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
126
125
 
127
- // ctx.locale always resolves to something (falls back to the app's
128
- // boot default, then "en"), so it can't tell us whether THIS request
129
- // actually carried a locale signal. Read the raw request-layer value
130
- // instead: present → the requester's active language wins over
131
- // opts.locale (this handler's static config); absent → opts.locale
132
- // is the real, still-relevant fallback, then ctx.locale's own
133
- // resolved default.
134
- const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
126
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
135
127
 
136
128
  await dispatchMagicLinkMail(
137
129
  ctx.notify,
@@ -115,6 +115,7 @@ export const defaultTranslations: TranslationsByLocale = {
115
115
  "auth.signupComplete.submitting": "…",
116
116
  "auth.signupComplete.missingToken":
117
117
  "Activation link is missing a token. Please request a new one.",
118
+ "auth.signupComplete.activatedTitle": "Account activated",
118
119
  "auth.signupComplete.activated": "Your account is active and you're signed in.",
119
120
  "auth.signupComplete.continue": "Continue",
120
121
  "auth.inviteAccept.title": "Accept invitation",
@@ -6,8 +6,10 @@
6
6
  // non-unsubscribable). No r.notification is declared; buildMessage falls back
7
7
  // to the structured `data` for the renderer.
8
8
 
9
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
9
10
  import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
10
11
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
11
13
  import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "./email-templates";
12
14
 
13
15
  // Per-flow constants: which notification type to dispatch and how to render the
@@ -43,6 +45,17 @@ function appendToken(appUrl: string, token: string): string {
43
45
  return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
44
46
  }
45
47
 
48
+ // X-Locale on the request wins over the handler's static opts.locale, then
49
+ // ctx.locale's resolved default (always set — boot default / "en"). Reading
50
+ // requestContext is load-bearing: ctx.locale alone cannot tell whether THIS
51
+ // request carried a locale signal.
52
+ export function resolveHandlerMailLocale(
53
+ ctx: { readonly locale?: string },
54
+ optsLocale: string | undefined,
55
+ ): string | undefined {
56
+ return requestContext.get()?.locale ?? optsLocale ?? ctx.locale;
57
+ }
58
+
46
59
  export async function dispatchMagicLinkMail(
47
60
  notify: NotifyFn | undefined,
48
61
  spec: MagicLinkMailSpec,
@@ -57,8 +70,11 @@ export async function dispatchMagicLinkMail(
57
70
  });
58
71
  }
59
72
  const locale = params.locale ?? "en";
73
+ // mailT already root-falls-back; appUrl must use the same negotiated tag
74
+ // or language-in-path apps 404 on e.g. /de-AT/activate.
75
+ const urlLocale = resolveMailLocale(locale);
60
76
  const content = spec.renderContent({
61
- url: appendToken(resolveAppUrl(params.appUrl, locale), params.token),
77
+ url: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
62
78
  expiresAt: params.expiresAt,
63
79
  ...(params.locale !== undefined && { locale: params.locale }),
64
80
  ...(params.appName !== undefined && { appName: params.appName }),
@@ -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;
@@ -100,7 +100,7 @@ describe("SignupCompleteScreen", () => {
100
100
 
101
101
  // Continue button targets exactly what resolveLoggedInHref computes from
102
102
  // the response's tenantKey, via the app-supplied function-form prop.
103
- const continueLink = screen.getByRole("link", { name: "Continue" }) as HTMLAnchorElement;
103
+ const continueLink = screen.getByRole("link", { name: "Continue" });
104
104
  expect(continueLink.getAttribute("href")).toBe("/acme/");
105
105
  } finally {
106
106
  window.location.assign = assignOrig;