@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -362,6 +362,25 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
362
362
  const body = (await res.json()) as { error?: { details?: { reason?: string } } };
363
363
  expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
364
364
  });
365
+
366
+ test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
367
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
368
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
369
+ token,
370
+ email: BOB_EMAIL,
371
+ password: "password1",
372
+ });
373
+ expect(res.status).toBe(400);
374
+ // A 400 for a completely different reason (bad token format, email
375
+ // shape) would also pass a bare status assertion — pin the field-level
376
+ // reason so the test actually proves the breach-list rejection fired.
377
+ const body = (await res.json()) as {
378
+ error?: { details?: { fields?: ReadonlyArray<{ path?: string; code?: string }> } };
379
+ };
380
+ expect(body.error?.details?.fields).toContainEqual(
381
+ expect.objectContaining({ path: "password", code: "custom" }),
382
+ );
383
+ });
365
384
  });
366
385
 
367
386
  describe("invite-signup-complete (Branch 3: anon + new email)", () => {
@@ -397,6 +416,21 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
397
416
  expect(loginRes.status).toBe(200);
398
417
  });
399
418
 
419
+ test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
420
+ const token = await inviteEmail(CAROL_EMAIL, "Editor");
421
+ const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
422
+ token,
423
+ password: "password1",
424
+ });
425
+ expect(res.status).toBe(400);
426
+ const body = (await res.json()) as {
427
+ error?: { details?: { fields?: ReadonlyArray<{ path?: string; code?: string }> } };
428
+ };
429
+ expect(body.error?.details?.fields).toContainEqual(
430
+ expect.objectContaining({ path: "password", code: "custom" }),
431
+ );
432
+ });
433
+
400
434
  test("Existing email → invalid_invite_token (User soll Branch 2 nutzen)", async () => {
401
435
  const token = await inviteEmail(BOB_EMAIL, "Admin");
402
436
 
@@ -239,31 +239,26 @@ describe("auth-self-registration toggle — companion feature NOT composed", ()
239
239
  await bareStack.cleanup();
240
240
  });
241
241
 
242
- // KNOWN GAP (found while writing this regression pin, not yet decided):
243
- // ctx.hasFeature() checks the effective-feature SET, which only ever
244
- // contains registered feature names (computeEffectiveFeatures iterates
245
- // registry.features). A name nothing ever registered is never a member,
246
- // so hasFeature("auth-self-registration") is false here — signup-request
247
- // silently no-ops (200, anti-enumeration success shape, but no mail) the
248
- // moment an app wires a real effectiveFeatures resolver without also
249
- // composing createAuthSelfRegistrationToggleFeature. This pins the
250
- // CURRENT (broken) behavior rather than asserting the desired one —
251
- // flagged for a follow-up decision, not silently fixed here.
252
- test("[KNOWN GAP] signup-request silently drops mail when the companion toggle feature was never composed", async () => {
242
+ // Fixed (#1468): ctx.hasFeature() is fail-closed for a name nothing ever
243
+ // registered — signup-request and the status query now only gate on the
244
+ // toggle when registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) proves
245
+ // the companion feature is actually composed, so an app that mounts
246
+ // auth-email-password without it keeps self-signup working.
247
+ test("signup-request still sends mail when the companion toggle feature was never composed", async () => {
253
248
  const res = await bareStack.http.raw("POST", "/api/auth/signup-request", {
254
249
  email: "not-toggle-aware@example.com",
255
250
  });
256
251
  expect(res.status).toBe(200);
257
- expect(bareEmailTransport.sent).toHaveLength(0);
252
+ expect(bareEmailTransport.sent).toHaveLength(1);
258
253
  });
259
254
 
260
- test("[KNOWN GAP] signup-registration-status reports enabled:false with no toggle feature mounted", async () => {
255
+ test("signup-registration-status reports enabled:true with no toggle feature mounted", async () => {
261
256
  const res = await bareStack.http.raw("POST", "/api/query", {
262
257
  type: "auth-email-password:query:signup-registration-status",
263
258
  payload: {},
264
259
  });
265
260
  expect((await res.json()) as { data?: { enabled: boolean } }).toMatchObject({
266
- data: { enabled: false },
261
+ data: { enabled: true },
267
262
  });
268
263
  });
269
264
  });
@@ -120,6 +120,22 @@ beforeAll(async () => {
120
120
  await unsafeCreateEntityTable(stack.db, userEntity);
121
121
  await unsafeCreateEntityTable(stack.db, tenantEntity);
122
122
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
123
+
124
+ // Prime request-helper's per-user sid cache for systemAdmin (the seed
125
+ // actor every test's seedUser() call authenticates as) here, once,
126
+ // before any test's observation window opens — otherwise whichever
127
+ // test happens to call seedUser() first would observe an extra
128
+ // store.created entry for seed plumbing, not the auth-route behavior
129
+ // under test (#1452: authHeader() now mints one sid per user.id, not
130
+ // one per call, so this only ever fires here).
131
+ await seedUser({
132
+ email: "warmup-seed-actor@example.com",
133
+ password: "warmup-password-not-asserted-on",
134
+ tenants: [],
135
+ });
136
+ store.live.clear();
137
+ store.created.length = 0;
138
+ store.revoked.length = 0;
123
139
  });
124
140
 
125
141
  afterAll(async () => {
@@ -158,11 +174,6 @@ async function seedUser(opts: {
158
174
  roles: t.roles,
159
175
  });
160
176
  }
161
- // request-helper mints a sid for systemAdmin when sessionCreator is wired
162
- // (#1372) — that is seed plumbing, not the auth-route behavior under test.
163
- store.live.clear();
164
- store.created.length = 0;
165
- store.revoked.length = 0;
166
177
  return { userId: created.id };
167
178
  }
168
179
 
@@ -27,6 +27,7 @@
27
27
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
28
28
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
29
29
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
30
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
30
31
  import {
31
32
  setupTestStack,
32
33
  type TestStack,
@@ -57,6 +58,41 @@ import { createAuthEmailPasswordFeature } from "../feature";
57
58
 
58
59
  const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
59
60
 
61
+ // Regression-proof for #1478: provisionSignupAccount only threaded `hooks`
62
+ // into seedTenant, leaving seedUserWithPassword's `user`-entity and
63
+ // seedTenantMembership's `tenantMembership`-entity postSave hooks unfired
64
+ // on self-signup — same bug class as #1463, just for the other two
65
+ // entities in the same call. This vendor feature's hook is the user-side
66
+ // analog of tier-engine's tenant hook above.
67
+ const userPostSaveFired = { count: 0 };
68
+ const userHookVendorFeature = defineFeature("user-hook-vendor-test", (r) => {
69
+ r.requires("user");
70
+ r.hook("postSave", { allOf: "user" }, async (ctx) => {
71
+ if (ctx.isNew) userPostSaveFired.count++;
72
+ });
73
+ });
74
+
75
+ // Regression-proof for #1478/#1526: seedTenantMembership must fire the
76
+ // `tenantMembership` entity's postSave hooks (e.g. a Welcome-notification
77
+ // hook) on self-signup too — pins the `fireEntityPostSave` call added at
78
+ // seeding.ts's seedTenantMembership, not just the `user`/`tenant` ones.
79
+ // #1566: also record ctx._tenantId so a shallow-only rescope regresses.
80
+ const tenantMembershipPostSaveFired = { count: 0, seenTenantIds: [] as string[] };
81
+ const tenantMembershipHookVendorFeature = defineFeature(
82
+ "tenant-membership-hook-vendor-test",
83
+ (r) => {
84
+ r.requires("tenant");
85
+ r.hook("postSave", { allOf: "tenant-membership" }, async (result, context) => {
86
+ if (result.isNew) {
87
+ tenantMembershipPostSaveFired.count++;
88
+ if (context._tenantId !== undefined) {
89
+ tenantMembershipPostSaveFired.seenTenantIds.push(String(context._tenantId));
90
+ }
91
+ }
92
+ });
93
+ },
94
+ );
95
+
60
96
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
61
97
 
62
98
  // Activation mails now go through delivery (ctx.notify → channel-email). The
@@ -92,6 +128,8 @@ beforeAll(async () => {
92
128
  defaultTier: "free",
93
129
  tierMap: { free: { features: [], caps: {} } },
94
130
  }),
131
+ userHookVendorFeature,
132
+ tenantMembershipHookVendorFeature,
95
133
  ],
96
134
  extraContext: (deps) => ({
97
135
  ...createDeliveryTestContext(deps),
@@ -130,6 +168,9 @@ beforeEach(async () => {
130
168
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tierAssignmentTable.tableName}"`);
131
169
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
132
170
  emailTransport.sent.length = 0;
171
+ userPostSaveFired.count = 0;
172
+ tenantMembershipPostSaveFired.count = 0;
173
+ tenantMembershipPostSaveFired.seenTenantIds = [];
133
174
  // Redis-cleanup damit Resend-Tests keine state-leaks haben.
134
175
  const allKeys = await stack.redis.redis.keys("signup:*");
135
176
  if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
@@ -245,6 +286,19 @@ describe("POST /api/auth/signup-confirm", () => {
245
286
  expect(tierRows).toHaveLength(1);
246
287
  expect(tierRows[0]?.["tier"]).toBe("free");
247
288
 
289
+ // #1478 regression: seedUserWithPassword (called from
290
+ // provisionSignupAccount) fires the `user` entity's postSave hooks too —
291
+ // before this fix, only seedTenant's hooks param was wired through.
292
+ expect(userPostSaveFired.count).toBe(1);
293
+
294
+ // #1478/#1526 regression: seedTenantMembership (called from
295
+ // provisionSignupAccount) fires the `tenant-membership` entity's postSave
296
+ // hooks too — before this fix, only seedTenant's hooks param was wired.
297
+ expect(tenantMembershipPostSaveFired.count).toBe(1);
298
+ // #1566: rescope must put the new tenant on the hook context, not the
299
+ // anonymous/system caller's tenantId.
300
+ expect(tenantMembershipPostSaveFired.seenTenantIds).toEqual([body.user?.tenantId ?? ""]);
301
+
248
302
  // Authority-Beweis: Login mit dem gesetzten Password funktioniert.
249
303
  const loginRes = await postLogin(email, password);
250
304
  expect(loginRes.status).toBe(200);
@@ -46,11 +46,12 @@ import {
46
46
  getInvitationIdForToken,
47
47
  unburnInviteToken,
48
48
  } from "../invite-token-store";
49
+ import { passwordSchema } from "../password-policy";
49
50
 
50
51
  const InviteAcceptWithLoginSchema = z.object({
51
52
  token: z.string().min(1),
52
53
  email: z.email(),
53
- password: z.string().min(8).max(200),
54
+ password: passwordSchema,
54
55
  });
55
56
 
56
57
  export type InviteAcceptWithLoginData = {
@@ -50,12 +50,13 @@ import {
50
50
  getInvitationIdForToken,
51
51
  unburnInviteToken,
52
52
  } from "../invite-token-store";
53
+ import { passwordSchema } from "../password-policy";
53
54
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
54
55
  import { seedUserWithPassword } from "../seeding";
55
56
 
56
57
  const InviteSignupCompleteSchema = z.object({
57
58
  token: z.string().min(1),
58
- password: z.string().min(8).max(200),
59
+ password: passwordSchema,
59
60
  });
60
61
 
61
62
  export type InviteSignupCompleteData = {
@@ -113,6 +113,14 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
113
113
  // for a timing-oracle on the bcrypt verify). If Redis isn't wired,
114
114
  // lockout is silently skipped — login still works, brute-force
115
115
  // protection just degrades to the IP-rate-limiter at the edge.
116
+ //
117
+ // Deliberately fail-open here, unlike auth-mfa's enable-confirm-preauth
118
+ // (which fails closed without Redis): the secret guarded by THIS gate
119
+ // is a full password, not a 6-digit code — locking out every login
120
+ // app-wide because Redis is briefly unavailable is a self-inflicted
121
+ // outage across every tenant, for a backstop that the IP-rate-limiter
122
+ // still partially covers. auth-mfa's blast radius is one user's MFA
123
+ // enrollment, not global login availability — different tradeoff.
116
124
  if (ctx.redis) {
117
125
  const state = await getLockoutState(ctx.redis, found.id);
118
126
  if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
@@ -14,7 +14,10 @@ export const selfRegistrationStatusQuery = defineQueryHandler({
14
14
  name: "signup-registration-status",
15
15
  schema: z.object({}),
16
16
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
17
- handler: async (_query, ctx) => ({
18
- enabled: await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE),
19
- }),
17
+ handler: async (_query, ctx) => {
18
+ // Same fail-open reasoning as signup-request.write.ts (#1468): only
19
+ // report disabled when the toggle feature is actually composed.
20
+ const gated = ctx.registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) !== undefined;
21
+ return { enabled: gated ? await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE) : true };
22
+ },
20
23
  });
@@ -76,7 +76,18 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
76
76
  // email" either way. The client-visible signal is the `status` query
77
77
  // on auth-self-registration, which the signup page uses to hide its
78
78
  // own link/form instead of collecting input that silently no-ops.
79
- if (!(await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))) {
79
+ // Fail-open when the companion toggle feature isn't composed at all —
80
+ // ctx.hasFeature() is fail-closed for *unregistered* names the moment
81
+ // feature-toggles/tier-engine is wired, which silently kills
82
+ // self-signup for every existing app that mounts auth-email-password
83
+ // without also composing createAuthSelfRegistrationToggleFeature()
84
+ // (#1468). Only gate when the toggle actually exists.
85
+ const gated = ctx.registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) !== undefined;
86
+ if (gated && !(await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))) {
87
+ // No email address here — this fires on every no-op request, PII
88
+ // must not accumulate in logs just for the "was this toggled off"
89
+ // signal a support ticket needs.
90
+ ctx.log?.info("signup-request skipped: self-registration disabled");
80
91
  return { isSuccess: true, data: { kind: "no-op" } };
81
92
  }
82
93
  if (!ctx.redis) {
@@ -89,6 +89,7 @@ export const defaultTranslations: TranslationsByLocale = {
89
89
  "auth.requestUnlock.successBody":
90
90
  "Falls die E-Mail in unserem System existiert und gesperrt ist, ist eine Nachricht mit einem Entsperren-Link unterwegs. Bitte schau in deinen Posteingang.",
91
91
  "auth.requestUnlock.backToLogin": "Zurück zum Login",
92
+ "auth.requestUnlock.rateLimited": "Zu viele Anfragen. Neuer Versuch in {minutes} Minuten.",
92
93
  "auth.unlockAccount.verifying": "Konto wird entsperrt …",
93
94
  "auth.unlockAccount.successTitle": "Konto entsperrt",
94
95
  "auth.unlockAccount.successBody":
@@ -215,6 +216,7 @@ export const defaultTranslations: TranslationsByLocale = {
215
216
  "auth.requestUnlock.successBody":
216
217
  "If that email exists in our system and is locked, a message with an unlock link is on its way. Please check your inbox.",
217
218
  "auth.requestUnlock.backToLogin": "Back to sign in",
219
+ "auth.requestUnlock.rateLimited": "Too many requests. Try again in {minutes} minutes.",
218
220
  "auth.unlockAccount.verifying": "Unlocking account …",
219
221
  "auth.unlockAccount.successTitle": "Account unlocked",
220
222
  "auth.unlockAccount.successBody": "Your account is unlocked again. You can sign in now.",
@@ -55,17 +55,25 @@ export type SeedUserWithPasswordOptions = {
55
55
  export async function seedUserWithPassword(
56
56
  db: DbConnection,
57
57
  options: SeedUserWithPasswordOptions,
58
+ // Optional, append-only — same rationale as provisionSignupAccount's
59
+ // hooks param (#1478): the self-signup path passes it through so `user`'s
60
+ // postSave hooks fire on self-signup same as on the regular create path.
61
+ hooks?: SeedTenantHooks,
58
62
  ): Promise<{ id: string }> {
59
63
  const passwordHash = await hashPassword(options.password);
60
- return seedUser(db, {
61
- email: options.email,
62
- displayName: options.displayName,
63
- passwordHash,
64
- ...(options.locale !== undefined && { locale: options.locale }),
65
- ...(options.roles !== undefined && { roles: options.roles }),
66
- ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
67
- ...(options.by !== undefined && { by: options.by }),
68
- });
64
+ return seedUser(
65
+ db,
66
+ {
67
+ email: options.email,
68
+ displayName: options.displayName,
69
+ passwordHash,
70
+ ...(options.locale !== undefined && { locale: options.locale }),
71
+ ...(options.roles !== undefined && { roles: options.roles }),
72
+ ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
73
+ ...(options.by !== undefined && { by: options.by }),
74
+ },
75
+ hooks,
76
+ );
69
77
  }
70
78
 
71
79
  /** Provisioning-Helper für Self-Signup-Confirm. Legt einen frischen
@@ -117,9 +125,11 @@ export async function provisionSignupAccount(
117
125
  options: ProvisionSignupAccountOptions,
118
126
  // Optional, append-only — existing callers (tests, seed scripts) that
119
127
  // don't pass hooks keep today's behavior. The real signup-confirm
120
- // handler DOES pass it (#1463) so seedTenant's postSave hooks
121
- // (tier-engine's auto-default-tier, app auto-default-compliance) fire
122
- // on self-signup, same as they do on the regular tenant-create path.
128
+ // handler DOES pass it (#1463) so seedTenant/seedUserWithPassword/
129
+ // seedTenantMembership's postSave hooks (tier-engine's auto-default-tier,
130
+ // app auto-default-compliance, a Welcome-notification hook, ...) fire on
131
+ // self-signup for all three entities, same as they do on the regular
132
+ // create paths (#1478 — this was only wired for tenant before).
123
133
  hooks?: SeedTenantHooks,
124
134
  ): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
125
135
  // Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
@@ -139,17 +149,25 @@ export async function provisionSignupAccount(
139
149
  },
140
150
  hooks,
141
151
  );
142
- const { id: userId } = await seedUserWithPassword(db, {
143
- email: options.email,
144
- password: options.password,
145
- displayName: options.displayName,
146
- emailVerified: true,
147
- });
148
- await seedTenantMembership(db, {
149
- userId,
150
- tenantId: options.tenantId,
151
- roles: options.memberRoles ?? INITIAL_SIGNUP_ROLES,
152
- });
152
+ const { id: userId } = await seedUserWithPassword(
153
+ db,
154
+ {
155
+ email: options.email,
156
+ password: options.password,
157
+ displayName: options.displayName,
158
+ emailVerified: true,
159
+ },
160
+ hooks,
161
+ );
162
+ await seedTenantMembership(
163
+ db,
164
+ {
165
+ userId,
166
+ tenantId: options.tenantId,
167
+ roles: options.memberRoles ?? INITIAL_SIGNUP_ROLES,
168
+ },
169
+ hooks,
170
+ );
153
171
  return { userId, tenantId: options.tenantId };
154
172
  }
155
173
 
@@ -33,6 +33,8 @@ function withoutCsrfDocument(): void {
33
33
  delete (globalThis as { document?: { cookie: string } }).document;
34
34
  }
35
35
 
36
+ const originalFetch = globalThis.fetch;
37
+
36
38
  beforeEach(() => {
37
39
  globalThis.fetch = mock(
38
40
  async () => new Response(null, { status: 200 }),
@@ -41,6 +43,7 @@ beforeEach(() => {
41
43
 
42
44
  afterEach(() => {
43
45
  withoutCsrfDocument();
46
+ globalThis.fetch = originalFetch;
44
47
  });
45
48
 
46
49
  describe("csrfHeader", () => {
@@ -107,6 +110,16 @@ describe("login", () => {
107
110
  expect(res).toEqual({ kind: "mfa-setup-required", preauthSetupToken: "setup-token-value" });
108
111
  });
109
112
 
113
+ test("mfaSetupRequired without preauthSetupToken → explicit mfa_setup_required failure", async () => {
114
+ globalThis.fetch = mock(async () =>
115
+ jsonResponse({ isSuccess: true, mfaSetupRequired: true }),
116
+ ) as unknown as typeof fetch;
117
+
118
+ const res = await login({ email: "a@b.c", password: "secret" });
119
+
120
+ expect(res).toEqual({ kind: "failure", error: { reason: "mfa_setup_required" } });
121
+ });
122
+
110
123
  test("string error → kind:failure with reason from string", async () => {
111
124
  globalThis.fetch = mock(async () =>
112
125
  jsonResponse({ isSuccess: false, error: "invalid_credentials" }),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { fireEvent, screen } from "@testing-library/react";
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
4
  import { createLoginRoute, makeAuthGate } from "../auth-gate";
5
5
  import { makeSessionApi, renderWithProviders } from "./test-utils";
@@ -10,7 +10,7 @@ describe("makeAuthGate", () => {
10
10
  }
11
11
 
12
12
  test("loading → renders placeholder, not children, not login", () => {
13
- const Gate = makeAuthGate(CustomLogin);
13
+ const Gate = makeAuthGate({ loginScreen: CustomLogin });
14
14
  const session = makeSessionApi({ status: "loading", user: null });
15
15
  const { container } = renderWithProviders(
16
16
  <Gate>
@@ -25,7 +25,7 @@ describe("makeAuthGate", () => {
25
25
  });
26
26
 
27
27
  test("unauthenticated → renders LoginComponent, not children", () => {
28
- const Gate = makeAuthGate(CustomLogin);
28
+ const Gate = makeAuthGate({ loginScreen: CustomLogin });
29
29
  const session = makeSessionApi({ status: "unauthenticated", user: null });
30
30
  renderWithProviders(
31
31
  <Gate>
@@ -38,7 +38,7 @@ describe("makeAuthGate", () => {
38
38
  });
39
39
 
40
40
  test("authenticated → renders children, not login", () => {
41
- const Gate = makeAuthGate(CustomLogin);
41
+ const Gate = makeAuthGate({ loginScreen: CustomLogin });
42
42
  renderWithProviders(
43
43
  <Gate>
44
44
  <div data-testid="protected">secret</div>
@@ -134,8 +134,30 @@ describe("createLoginRoute", () => {
134
134
  expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
135
135
  });
136
136
 
137
+ test("no mfaVerifyScreen → onMfaChallenge passes through to loginScreenProps.onMfaChallenge (#266 footgun)", () => {
138
+ // The silent-no-MFA-fallback path: an app that wires its own
139
+ // onMfaChallenge via loginScreenProps but forgets mfaVerifyScreen still
140
+ // gets that callback invoked (createLoginRoute can't detect the
141
+ // omission) instead of the internal setChallengeToken/MfaVerifyComponent
142
+ // machinery kicking in. Covered here so a future refactor of that
143
+ // ternary can't silently swap the fallback direction.
144
+ const onMfaChallenge = mock<(challengeToken: string) => void>();
145
+ const LoginRoute = createLoginRoute({
146
+ loginScreen: LoginWithMfaTrigger,
147
+ loginScreenProps: { onMfaChallenge },
148
+ });
149
+ const session = makeSessionApi({ status: "unauthenticated" });
150
+ renderWithProviders(<LoginRoute />, { session });
151
+ fireEvent.click(screen.getByTestId("trigger-mfa"));
152
+ expect(onMfaChallenge).toHaveBeenCalledWith("token-123");
153
+ expect(screen.queryByTestId("mfa-verify")).toBeNull();
154
+ });
155
+
137
156
  test("makeAuthGate delegates mfaVerifyScreen wiring to createLoginRoute", () => {
138
- const Gate = makeAuthGate(LoginWithMfaTrigger, undefined, CustomMfaVerify);
157
+ const Gate = makeAuthGate({
158
+ loginScreen: LoginWithMfaTrigger,
159
+ mfaVerifyScreen: CustomMfaVerify,
160
+ });
139
161
  const session = makeSessionApi({ status: "unauthenticated" });
140
162
  renderWithProviders(
141
163
  <Gate>
@@ -160,7 +182,7 @@ describe("createLoginRoute", () => {
160
182
  );
161
183
  });
162
184
 
163
- test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", () => {
185
+ test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", async () => {
164
186
  const LoginRoute = createLoginRoute({
165
187
  loginScreen: LoginWithMfaSetupTrigger,
166
188
  mfaSetupScreen: CustomMfaSetup,
@@ -171,11 +193,36 @@ describe("createLoginRoute", () => {
171
193
  expect(screen.getByTestId("mfa-setup")).toBeTruthy();
172
194
  fireEvent.click(screen.getByTestId("complete-mfa-setup"));
173
195
  expect(session.refresh).toHaveBeenCalledTimes(1);
174
- expect(screen.queryByTestId("mfa-setup")).toBeNull();
196
+ await waitFor(() => {
197
+ expect(screen.queryByTestId("mfa-setup")).toBeNull();
198
+ });
199
+ });
200
+
201
+ test("MfaSetupComponent onSuccess → keeps setup screen when refresh rejects", async () => {
202
+ const LoginRoute = createLoginRoute({
203
+ loginScreen: LoginWithMfaSetupTrigger,
204
+ mfaSetupScreen: CustomMfaSetup,
205
+ });
206
+ const session = makeSessionApi({
207
+ status: "unauthenticated",
208
+ refresh: mock(async () => {
209
+ throw new Error("refresh failed");
210
+ }),
211
+ });
212
+ renderWithProviders(<LoginRoute />, { session });
213
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
214
+ fireEvent.click(screen.getByTestId("complete-mfa-setup"));
215
+ expect(session.refresh).toHaveBeenCalledTimes(1);
216
+ await Promise.resolve();
217
+ await Promise.resolve();
218
+ expect(screen.getByTestId("mfa-setup")).toBeTruthy();
175
219
  });
176
220
 
177
221
  test("makeAuthGate delegates mfaSetupScreen wiring to createLoginRoute", () => {
178
- const Gate = makeAuthGate(LoginWithMfaSetupTrigger, undefined, undefined, CustomMfaSetup);
222
+ const Gate = makeAuthGate({
223
+ loginScreen: LoginWithMfaSetupTrigger,
224
+ mfaSetupScreen: CustomMfaSetup,
225
+ });
179
226
  const session = makeSessionApi({ status: "unauthenticated" });
180
227
  renderWithProviders(
181
228
  <Gate>
@@ -0,0 +1,34 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // #1472: emailPasswordClient() built the session gate without threading
4
+ // options.mfaSetupScreen through to makeSessionAuthGate — the option was
5
+ // typed and documented but silently dropped on the standard mount path.
6
+ // makeSessionAuthGate wraps its own real SessionProvider (unlike
7
+ // makeAuthGate, which auth-gate.test.tsx exercises directly), so the
8
+ // cheapest live check is spying on the call args instead of rendering
9
+ // through a real session fetch.
10
+ const makeSessionAuthGateCalls: unknown[][] = [];
11
+ mock.module("../auth-gate", () => ({
12
+ makeSessionAuthGate: (...args: unknown[]) => {
13
+ makeSessionAuthGateCalls.push(args);
14
+ return () => null;
15
+ },
16
+ }));
17
+
18
+ const { emailPasswordClient } = await import("../client-plugin");
19
+
20
+ describe("emailPasswordClient — mfaSetupScreen wiring", () => {
21
+ test("passes options.mfaSetupScreen through to makeSessionAuthGate", () => {
22
+ makeSessionAuthGateCalls.length = 0;
23
+ function CustomMfaSetup() {
24
+ return null;
25
+ }
26
+
27
+ emailPasswordClient({ mfaSetupScreen: CustomMfaSetup });
28
+
29
+ expect(makeSessionAuthGateCalls).toHaveLength(1);
30
+ expect((makeSessionAuthGateCalls[0]?.[0] as { mfaSetupScreen?: unknown })?.mfaSetupScreen).toBe(
31
+ CustomMfaSetup,
32
+ );
33
+ });
34
+ });
@@ -3,12 +3,16 @@ import { screen, waitFor } from "@testing-library/react";
3
3
  import { ConfirmAccountUnlockScreen } from "../confirm-account-unlock-screen";
4
4
  import { renderWithProviders } from "./test-utils";
5
5
 
6
+ const originalFetch = globalThis.fetch;
7
+
6
8
  beforeEach(() => {
7
9
  globalThis.fetch = mock(
8
10
  async () => new Response(null, { status: 200 }),
9
11
  ) as unknown as typeof fetch;
10
12
  });
11
- afterEach(() => {});
13
+ afterEach(() => {
14
+ globalThis.fetch = originalFetch;
15
+ });
12
16
 
13
17
  describe("ConfirmAccountUnlockScreen", () => {
14
18
  test("ohne Token → missing-token page", () => {
@@ -28,6 +32,8 @@ describe("ConfirmAccountUnlockScreen", () => {
28
32
  "/api/auth/confirm-account-unlock",
29
33
  expect.objectContaining({
30
34
  method: "POST",
35
+ headers: expect.objectContaining({ "Content-Type": "application/json" }),
36
+ credentials: "same-origin",
31
37
  body: JSON.stringify({ token: "unlock-tok" }),
32
38
  }),
33
39
  );
@@ -53,5 +59,6 @@ describe("ConfirmAccountUnlockScreen", () => {
53
59
  test("custom title on missing-token uses errorTitle slot only when set", () => {
54
60
  renderWithProviders(<ConfirmAccountUnlockScreen title="Unlock kaputt" />);
55
61
  expect(screen.getByText("Unlock kaputt")).toBeTruthy();
62
+ expect(screen.queryByText("Entsperren fehlgeschlagen")).toBeNull();
56
63
  });
57
64
  });
@@ -3,12 +3,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { ForgotPasswordScreen } from "../forgot-password-screen";
4
4
  import { renderWithProviders } from "./test-utils";
5
5
 
6
+ const originalFetch = globalThis.fetch;
7
+
6
8
  beforeEach(() => {
7
9
  globalThis.fetch = mock(
8
10
  async () => new Response(null, { status: 200 }),
9
11
  ) as unknown as typeof fetch;
10
12
  });
11
- afterEach(() => {});
13
+ afterEach(() => {
14
+ globalThis.fetch = originalFetch;
15
+ });
12
16
 
13
17
  describe("ForgotPasswordScreen", () => {
14
18
  test("rendert title + email-input + submit-button (de)", () => {
@@ -27,6 +27,8 @@ function renderWithoutSessionProvider(token: string) {
27
27
  );
28
28
  }
29
29
 
30
+ const originalFetch = globalThis.fetch;
31
+
30
32
  beforeEach(() => {
31
33
  globalThis.fetch = mock(
32
34
  async () => new Response(JSON.stringify({ tenantId: "tenant-new" }), { status: 200 }),
@@ -43,6 +45,7 @@ afterEach(() => {
43
45
  writable: true,
44
46
  value: originalLocation,
45
47
  });
48
+ globalThis.fetch = originalFetch;
46
49
  });
47
50
 
48
51
  describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {