@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2

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 (159) 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 +205 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +52 -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__/login-gates.test.ts +39 -0
  9. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  11. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  12. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  13. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  14. package/src/auth-email-password/handlers/login.write.ts +196 -155
  15. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  16. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/seeding.ts +41 -23
  19. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  20. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  21. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  22. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  23. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  24. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  25. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  26. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  28. package/src/auth-email-password/web/auth-client.ts +32 -10
  29. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  30. package/src/auth-email-password/web/client-plugin.ts +15 -2
  31. package/src/auth-email-password/web/index.ts +5 -1
  32. package/src/auth-email-password/web/login-screen.tsx +7 -3
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  34. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  35. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  37. package/src/auth-foundation/boot-checks.ts +74 -69
  38. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  39. package/src/auth-foundation/types.ts +3 -3
  40. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  41. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  42. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
  43. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  44. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  45. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  46. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  47. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  48. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  51. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  52. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  53. package/src/auth-mfa/web/i18n.ts +4 -0
  54. package/src/auth-mfa/web/mfa-client.ts +13 -2
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  56. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  57. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  58. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  59. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  60. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  61. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  62. package/src/config/db/queries/resolver.ts +3 -3
  63. package/src/config/handlers/reencrypt.job.ts +36 -24
  64. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  65. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  66. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  67. package/src/document-ingest-foundation/entity.ts +10 -3
  68. package/src/document-ingest-foundation/feature.ts +26 -8
  69. package/src/document-ingest-foundation/index.ts +1 -0
  70. package/src/document-ingest-foundation/pages.ts +34 -0
  71. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  72. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  73. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  74. package/src/files-provider-s3/s3-provider.ts +12 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  76. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  77. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  78. package/src/inbound-mail-foundation/entities.ts +3 -3
  79. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  80. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  81. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  82. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  83. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  84. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  85. package/src/jobs/feature.ts +6 -5
  86. package/src/jobs/job-instant.ts +9 -0
  87. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  88. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  89. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  90. package/src/page-render/__tests__/markdown.test.ts +29 -0
  91. package/src/page-render/markdown.ts +1 -9
  92. package/src/personal-access-tokens/feature.ts +3 -3
  93. package/src/personal-access-tokens/schema/api-token.ts +3 -3
  94. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  96. package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
  97. package/src/sessions/feature.ts +21 -6
  98. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  99. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  100. package/src/sessions/handlers/revoke.write.ts +13 -0
  101. package/src/sessions/index.ts +7 -0
  102. package/src/sessions/schema/user-session.ts +6 -7
  103. package/src/sessions/session-callbacks.ts +58 -5
  104. package/src/sessions/session-revoked-event.ts +29 -0
  105. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  106. package/src/shared/chunked-entity-migration.ts +2 -2
  107. package/src/shared/grace-period.test.ts +26 -0
  108. package/src/shared/grace-period.ts +20 -0
  109. package/src/shared/index.ts +2 -0
  110. package/src/shared/map-with-concurrency.test.ts +104 -0
  111. package/src/shared/map-with-concurrency.ts +38 -0
  112. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  113. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  114. package/src/subscription-mollie/verify-webhook.ts +1 -0
  115. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  116. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  117. package/src/subscription-stripe/verify-webhook.ts +3 -2
  118. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  119. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  120. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  121. package/src/tenant/handlers/invitations.query.ts +13 -15
  122. package/src/tenant/handlers/members.query.ts +12 -12
  123. package/src/tenant/seeding.ts +110 -16
  124. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  125. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
  126. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
  127. package/src/tenant-settings/config.ts +44 -0
  128. package/src/tenant-settings/constants.ts +16 -0
  129. package/src/tenant-settings/feature.ts +49 -0
  130. package/src/tenant-settings/index.ts +8 -0
  131. package/src/tenant-settings/tenant-defaults.ts +72 -0
  132. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
  133. package/src/text-content/handlers/set.write.ts +2 -2
  134. package/src/text-content/seeding.ts +31 -29
  135. package/src/user/handlers/find-for-auth.query.ts +1 -1
  136. package/src/user/seeding.ts +8 -0
  137. package/src/user-data-rights/COMPLIANCE.md +1 -1
  138. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  139. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  140. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  141. package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
  142. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  143. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  144. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
  145. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  146. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  147. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  148. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  149. package/src/user-data-rights/feature.ts +45 -45
  150. package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
  151. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  152. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  153. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  154. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  155. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  156. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  157. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  158. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  159. 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
 
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { USER_STATUS } from "../../user";
3
+ import type { AuthUserRow } from "../auth-user-row";
4
+ import { gateEnforceAccountStatus, gateEnforceEmailVerified } from "../handlers/login.write";
5
+
6
+ function row(over: Partial<AuthUserRow> = {}): AuthUserRow {
7
+ return { id: "00000000-0000-4000-8000-000000000001", passwordHash: "x", ...over };
8
+ }
9
+
10
+ describe("login.write gates (fw#1284)", () => {
11
+ test("gateEnforceEmailVerified rejects when strict and unverified", () => {
12
+ const g = gateEnforceEmailVerified(row({ emailVerified: false }), true);
13
+ expect(g.ok).toBe(false);
14
+ if (!g.ok) expect(g.result.isSuccess).toBe(false);
15
+ });
16
+
17
+ test("gateEnforceEmailVerified passes when not strict", () => {
18
+ expect(gateEnforceEmailVerified(row({ emailVerified: false }), false).ok).toBe(true);
19
+ });
20
+
21
+ test("gateEnforceAccountStatus rejects restricted", () => {
22
+ const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Restricted }));
23
+ expect(g.ok).toBe(false);
24
+ });
25
+
26
+ test("gateEnforceAccountStatus rejects deletion_requested as invalid_creds shape", () => {
27
+ const g = gateEnforceAccountStatus(row({ status: USER_STATUS.DeletionRequested }));
28
+ expect(g.ok).toBe(false);
29
+ if (!g.ok) {
30
+ expect(g.result.isSuccess).toBe(false);
31
+ // anti-enumeration — same family as invalid credentials
32
+ expect(g.result).toMatchObject({ isSuccess: false });
33
+ }
34
+ });
35
+
36
+ test("gateEnforceAccountStatus passes active", () => {
37
+ expect(gateEnforceAccountStatus(row({ status: USER_STATUS.Active })).ok).toBe(true);
38
+ });
39
+ });
@@ -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 = {
@@ -11,7 +11,7 @@ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
11
11
  import { z } from "zod";
12
12
  import { verifyDummyPassword, verifyPassword } from "../../shared";
13
13
  import { USER_STATUS, UserQueries } from "../../user";
14
- import { parseAuthUserRow } from "../auth-user-row";
14
+ import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
15
15
  import {
16
16
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
17
17
  AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
@@ -72,6 +72,170 @@ type LoginResult =
72
72
  | { readonly kind: "mfa-challenge"; readonly challengeToken: string }
73
73
  | { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
74
74
 
75
+ type Membership = { readonly tenantId: TenantId; readonly roles: readonly string[] };
76
+
77
+ type GateReject = { readonly ok: false; readonly result: WriteResult<LoginResult> };
78
+ type GateOk<T> = { readonly ok: true; readonly value: T };
79
+ type GateOutcome<T> = GateReject | GateOk<T>;
80
+
81
+ function reject(result: WriteResult<LoginResult>): GateReject {
82
+ return { ok: false, result };
83
+ }
84
+
85
+ function ok<T>(value: T): GateOk<T> {
86
+ return { ok: true, value };
87
+ }
88
+
89
+ /** Uniform response on any credential miss — burns argon2 cost (#774). */
90
+ export async function gateResolveAuthUser(
91
+ ctx: HandlerContext,
92
+ systemUser: SessionUser,
93
+ email: string,
94
+ password: string,
95
+ ): Promise<GateOutcome<AuthUserRow>> {
96
+ const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
97
+ if (!found?.passwordHash || found.isDeleted) {
98
+ await verifyDummyPassword(password);
99
+ return reject(invalidCredentials());
100
+ }
101
+ return ok(found);
102
+ }
103
+
104
+ /**
105
+ * Lockout BEFORE password verify — locked accounts can't be password-probed.
106
+ * Fail-open without Redis (IP rate-limit still covers partially).
107
+ */
108
+ export async function gateEnforceLockout(
109
+ ctx: HandlerContext,
110
+ userId: string,
111
+ ): Promise<GateOutcome<undefined>> {
112
+ if (!ctx.redis) return ok(undefined);
113
+ const state = await getLockoutState(ctx.redis, userId);
114
+ if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
115
+ const now = Date.now();
116
+ if (state.lockedUntil > now) {
117
+ const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
118
+ return reject(accountLocked(retryAfterSeconds));
119
+ }
120
+ }
121
+ return ok(undefined);
122
+ }
123
+
124
+ /** Verify password; record miss / clear lockout on hit. */
125
+ export async function gateVerifyPassword(
126
+ ctx: HandlerContext,
127
+ found: AuthUserRow,
128
+ password: string,
129
+ maxFailedAttempts: number,
130
+ lockoutDurationMinutes: number,
131
+ ): Promise<GateOutcome<undefined>> {
132
+ const passwordHash = found.passwordHash;
133
+ if (!passwordHash) return reject(invalidCredentials());
134
+ const passwordOk = await verifyPassword(passwordHash, password);
135
+ if (!passwordOk) {
136
+ if (ctx.redis) {
137
+ await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
138
+ }
139
+ return reject(invalidCredentials());
140
+ }
141
+ // Clear before MFA — MFA has its own attempt-cap; don't password-lock
142
+ // users who occasionally mistype across otherwise-successful logins.
143
+ if (ctx.redis) {
144
+ await clearLockoutState(ctx.redis, found.id);
145
+ }
146
+ return ok(undefined);
147
+ }
148
+
149
+ /** Strict email verification — after password, before session. */
150
+ export function gateEnforceEmailVerified(
151
+ found: AuthUserRow,
152
+ strictVerification: boolean,
153
+ ): GateOutcome<undefined> {
154
+ if (strictVerification && found.emailVerified !== true) {
155
+ return reject(emailNotVerified());
156
+ }
157
+ return ok(undefined);
158
+ }
159
+
160
+ /** DSGVO Art. 18 freeze + forget-path anti-enumeration. */
161
+ export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefined> {
162
+ if (found.status === USER_STATUS.Restricted) {
163
+ return reject(accountRestricted());
164
+ }
165
+ if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
166
+ return reject(invalidCredentials());
167
+ }
168
+ return ok(undefined);
169
+ }
170
+
171
+ /** Pick membership (last-active preferred); merge global + tenant roles. */
172
+ export async function gateResolveMembership(
173
+ ctx: HandlerContext,
174
+ systemUser: SessionUser,
175
+ found: AuthUserRow,
176
+ ): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
177
+ const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
178
+ userId: found.id,
179
+ })) as Array<Membership>; // @cast-boundary db-runner
180
+
181
+ if (memberships.length === 0) {
182
+ return reject(noMembership());
183
+ }
184
+
185
+ const preferred =
186
+ found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
187
+ ? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
188
+ : undefined;
189
+ const chosen = preferred ?? memberships[0];
190
+ if (!chosen) {
191
+ return reject(noMembership());
192
+ }
193
+
194
+ const globalRoles = parseRoles(found.roles ?? null);
195
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
196
+ // roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
197
+ // rebuild-resurrected role.
198
+ const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
199
+ return ok({ chosen, mergedRoles });
200
+ }
201
+
202
+ /** MFA challenge / setup-required / proceed. */
203
+ export async function gateEnforceMfa(
204
+ ctx: HandlerContext,
205
+ opts: LoginHandlerOptions,
206
+ userId: string,
207
+ tenantId: TenantId,
208
+ mergedRoles: readonly string[],
209
+ ): Promise<GateOutcome<LoginResult | undefined>> {
210
+ if (!opts.mfaStatusChecker) return ok(undefined);
211
+ const mfaStatus = await opts.mfaStatusChecker(ctx, userId, tenantId, mergedRoles);
212
+ if ("challengeToken" in mfaStatus) {
213
+ return ok({ kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken });
214
+ }
215
+ if ("setupRequired" in mfaStatus) {
216
+ return ok({ kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken });
217
+ }
218
+ return ok(undefined);
219
+ }
220
+
221
+ /** Auth-claims hooks → session. */
222
+ export async function gateBuildSession(
223
+ ctx: HandlerContext,
224
+ userId: string,
225
+ tenantId: TenantId,
226
+ mergedRoles: readonly string[],
227
+ ): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
228
+ const baseSession: SessionUser = {
229
+ id: userId,
230
+ tenantId,
231
+ roles: mergedRoles,
232
+ };
233
+ const claims = await ctx.resolveAuthClaims(baseSession);
234
+ const session: SessionUser =
235
+ Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
236
+ return ok({ kind: "auth-session", session });
237
+ }
238
+
75
239
  // Login — unauthenticated entry point. The route is wired public (no JWT
76
240
  // middleware), synthesising a guest SessionUser for the handler's access
77
241
  // check. Everything inside the handler goes through ctx.queryAs(system, ...)
@@ -93,169 +257,46 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
93
257
  handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
94
258
  const systemUser = createSystemUser(SYSTEM_USER_ID);
95
259
 
96
- const found = parseAuthUserRow(
97
- await ctx.queryAs(systemUser, UserQueries.findForAuth, {
98
- email: event.payload.email,
99
- }),
260
+ const userGate = await gateResolveAuthUser(
261
+ ctx,
262
+ systemUser,
263
+ event.payload.email,
264
+ event.payload.password,
100
265
  );
266
+ if (!userGate.ok) return userGate.result;
267
+ const found = userGate.value;
101
268
 
102
- // Uniform response on any credential mismatch (no user, wrong password,
103
- // soft-deleted user) — prevents email enumeration.
104
- if (!found?.passwordHash || found.isDeleted) {
105
- // Burn the same argon2 verify cost as the hit path so response
106
- // latency doesn't reveal whether the email is registered (#774).
107
- await verifyDummyPassword(event.payload.password);
108
- return invalidCredentials();
109
- }
110
-
111
- // Lockout gate — runs BEFORE password verification so a locked account
112
- // can't be bruteforce-probed for passwords (and also can't be probed
113
- // for a timing-oracle on the bcrypt verify). If Redis isn't wired,
114
- // lockout is silently skipped — login still works, brute-force
115
- // protection just degrades to the IP-rate-limiter at the edge.
116
- if (ctx.redis) {
117
- const state = await getLockoutState(ctx.redis, found.id);
118
- if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
119
- const now = Date.now();
120
- if (state.lockedUntil > now) {
121
- const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
122
- return accountLocked(retryAfterSeconds);
123
- }
124
- // lockedUntil in the past — shouldn't normally happen because the
125
- // Redis TTL on the until-key expires the key at the same moment
126
- // as the value. Clock skew / replication lag could surface this;
127
- // fall through to password verification. The counter is NOT
128
- // reset — next miss re-locks immediately (strict-semantic, see
129
- // lockout-store.ts).
130
- }
131
- }
132
-
133
- const passwordOk = await verifyPassword(found.passwordHash, event.payload.password);
134
- if (!passwordOk) {
135
- if (ctx.redis) {
136
- await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
137
- }
138
- return invalidCredentials();
139
- }
140
-
141
- // Clear the lockout state as soon as the password is proven — the
142
- // password itself is the thing this counter guards, and MFA (if
143
- // gated below) has its own separate attempt-cap. Doing this before
144
- // the MFA gate matters: without it, an MFA user who occasionally
145
- // mistypes their password accumulates failures across otherwise-
146
- // successful logins and eventually gets password-locked out even
147
- // though every login they completed was legitimate.
148
- if (ctx.redis) {
149
- await clearLockoutState(ctx.redis, found.id);
150
- }
269
+ const lockoutGate = await gateEnforceLockout(ctx, found.id);
270
+ if (!lockoutGate.ok) return lockoutGate.result;
151
271
 
152
- // Strict verification gate — runs AFTER password check so an attacker
153
- // probing "email_not_verified" needs valid credentials first. The
154
- // remaining enumeration surface is "valid-cred + unverified" → accepted
155
- // leak because the signup flow already told the user "check your email".
156
- if (strictVerification && found.emailVerified !== true) {
157
- return emailNotVerified();
158
- }
159
-
160
- // S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
161
- // nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
162
- // (siehe lift-restriction.write.ts Header — typisch via Magic-Link
163
- // oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
164
- // requirement; ohne den koennte der User mit Login-Sessions trotz
165
- // Restriction-Flag durchschreiben.
166
- //
167
- // DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
168
- // (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
169
- // distinct error ist hier safe.
170
- if (found.status === USER_STATUS.Restricted) {
171
- return accountRestricted();
172
- }
173
- if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
174
- return invalidCredentials();
175
- }
176
-
177
- // Resolve tenant + roles via the tenant feature's memberships query.
178
- // Returns [] if the user has no memberships — MVP: no login without an
179
- // invitation, so we refuse with a dedicated error.
180
- const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
181
- userId: found.id,
182
- })) as Array<{ tenantId: TenantId; roles: readonly string[] }>; // @cast-boundary db-runner
272
+ const passwordGate = await gateVerifyPassword(
273
+ ctx,
274
+ found,
275
+ event.payload.password,
276
+ maxFailedAttempts,
277
+ lockoutDurationMinutes,
278
+ );
279
+ if (!passwordGate.ok) return passwordGate.result;
183
280
 
184
- if (memberships.length === 0) {
185
- return noMembership();
186
- }
281
+ const emailGate = gateEnforceEmailVerified(found, strictVerification);
282
+ if (!emailGate.ok) return emailGate.result;
187
283
 
188
- const preferred =
189
- found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
190
- ? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
191
- : undefined;
192
- const chosen = preferred ?? memberships[0];
193
- if (!chosen) {
194
- return noMembership();
195
- }
284
+ const statusGate = gateEnforceAccountStatus(found);
285
+ if (!statusGate.ok) return statusGate.result;
196
286
 
197
- // Globale Rollen aus user.roles + tenant-membership-roles mergen.
198
- // Globale Rollen (SystemAdmin etc.) bleiben so über alle tenants
199
- // gleich; tenant-spezifische Rollen (Admin, User) kommen aus der
200
- // membership. Dedupe via Set damit eine Rolle die in beiden Quellen
201
- // steht nicht doppelt im Session-Roles landet.
202
- //
203
- // Computed BEFORE the MFA gate (moved up from its original spot below
204
- // baseSession) because the gate's "admins" enforcement policy needs
205
- // the MERGED set — a SystemAdmin whose admin-ness lives only in
206
- // globalRoles would be missed if only chosen.roles were passed.
207
- const globalRoles = parseRoles(found.roles ?? null);
208
- // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
209
- // only (globalRoles keeps SystemAdmin) — read-time backstop against a
210
- // rebuild-resurrected role.
211
- const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
287
+ const membershipGate = await gateResolveMembership(ctx, systemUser, found);
288
+ if (!membershipGate.ok) return membershipGate.result;
289
+ const { chosen, mergedRoles } = membershipGate.value;
212
290
 
213
- // Second-factor gate. Runs AFTER password verification (correct
214
- // credentials proven) and AFTER tenant resolution (need chosen.tenantId
215
- // to scope the MFA-enabled check). Three outcomes: enrolled → mint a
216
- // challenge instead of a session (/auth/mfa/verify completes the
217
- // login); policy demands MFA but the user never enrolled → block with
218
- // mfa-setup-required (no session, no challenge — see auth-mfa's
219
- // config.ts for why this deliberately hard-blocks); neither → proceed.
220
- if (opts.mfaStatusChecker) {
221
- const mfaStatus = await opts.mfaStatusChecker(ctx, found.id, chosen.tenantId, mergedRoles);
222
- if ("challengeToken" in mfaStatus) {
223
- return {
224
- isSuccess: true,
225
- data: { kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken },
226
- };
227
- }
228
- if ("setupRequired" in mfaStatus) {
229
- return {
230
- isSuccess: true,
231
- data: { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken },
232
- };
233
- }
291
+ const mfaGate = await gateEnforceMfa(ctx, opts, found.id, chosen.tenantId, mergedRoles);
292
+ if (!mfaGate.ok) return mfaGate.result;
293
+ if (mfaGate.value !== undefined) {
294
+ return { isSuccess: true, data: mfaGate.value };
234
295
  }
235
296
 
236
- const baseSession: SessionUser = {
237
- id: found.id,
238
- tenantId: chosen.tenantId,
239
- roles: mergedRoles,
240
- };
241
-
242
- // Features can contribute identity facts (team IDs, feature flags, ...)
243
- // via r.authClaims(). ctx.resolveAuthClaims is a thin pass-through to
244
- // dispatcher.resolveAuthClaims — same impl also used by the switch-tenant
245
- // route, so login + tenant-switch stay in sync.
246
- //
247
- // Best-effort: if no feature registered a hook, we get an empty record
248
- // back and simply omit the `claims` field from the session (keeps the
249
- // shape clean for the JWT layer, which already spreads claims
250
- // conditionally based on presence).
251
- const claims = await ctx.resolveAuthClaims(baseSession);
252
- const session: SessionUser =
253
- Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
254
-
255
- return {
256
- isSuccess: true,
257
- data: { kind: "auth-session", session },
258
- };
297
+ const sessionGate = await gateBuildSession(ctx, found.id, chosen.tenantId, mergedRoles);
298
+ if (!sessionGate.ok) return sessionGate.result;
299
+ return { isSuccess: true, data: sessionGate.value };
259
300
  },
260
301
  });
261
302
  }