@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
@@ -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)", () => {
@@ -3,12 +3,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { RequestAccountUnlockScreen } from "../request-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("RequestAccountUnlockScreen", () => {
14
18
  test("renders title + email input + submit (de)", () => {
@@ -33,6 +37,8 @@ describe("RequestAccountUnlockScreen", () => {
33
37
  "/api/auth/request-account-unlock",
34
38
  expect.objectContaining({
35
39
  method: "POST",
40
+ headers: expect.objectContaining({ "Content-Type": "application/json" }),
41
+ credentials: "same-origin",
36
42
  body: JSON.stringify({ email: "user@example.com" }),
37
43
  }),
38
44
  );
@@ -54,7 +60,7 @@ describe("RequestAccountUnlockScreen", () => {
54
60
  );
55
61
  });
56
62
 
57
- test("429 with retryAfterSeconds → accountLockedRetry banner", async () => {
63
+ test("429 with retryAfterSeconds → requestUnlock.rateLimited banner (#1333)", async () => {
58
64
  globalThis.fetch = mock(
59
65
  async () =>
60
66
  new Response(JSON.stringify({ error: { details: { retryAfterSeconds: 180 } } }), {
@@ -3,12 +3,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { ResetPasswordScreen } from "../reset-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("ResetPasswordScreen", () => {
14
18
  test("ohne Token in URL UND ohne token-Prop → missing-token-Page", () => {
@@ -3,12 +3,16 @@ import { screen, waitFor } from "@testing-library/react";
3
3
  import { VerifyEmailScreen } from "../verify-email-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("VerifyEmailScreen", () => {
14
18
  test("ohne Token → missing-token-Page", () => {
@@ -34,8 +34,24 @@ export type LoginResponse = {
34
34
  };
35
35
  };
36
36
 
37
+ // Reason codes the login-handler is known to emit today (login-screen.tsx's
38
+ // reasonToKey maps every one of these to an i18n key). The server can also
39
+ // send a reason this client doesn't know about yet (rolling deploys, a new
40
+ // enforcement policy) — `string & {}` keeps that open while still giving
41
+ // autocomplete/typo-checking on the known set, instead of collapsing both
42
+ // to plain `string` (coding-standards: no magic strings without a union).
43
+ export type KnownLoginFailureReason =
44
+ | "invalid_credentials"
45
+ | "no_membership"
46
+ | "account_locked"
47
+ | "email_not_verified"
48
+ | "rate_limited"
49
+ | "invalid_body"
50
+ | "mfa_not_supported"
51
+ | "mfa_setup_required";
52
+
37
53
  export type LoginFailure = {
38
- readonly reason: string;
54
+ readonly reason: KnownLoginFailureReason | (string & {});
39
55
  readonly message?: string;
40
56
  readonly retryAfterSeconds?: number;
41
57
  };
@@ -93,8 +109,14 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
93
109
  if (body.mfaRequired === true && typeof body.challengeToken === "string") {
94
110
  return { kind: "mfa-challenge", challengeToken: body.challengeToken };
95
111
  }
96
- if (body.mfaSetupRequired === true && typeof body.preauthSetupToken === "string") {
97
- return { kind: "mfa-setup-required", preauthSetupToken: body.preauthSetupToken };
112
+ if (body.mfaSetupRequired === true) {
113
+ // Older servers / mounts may set the flag without minting a preauth token —
114
+ // surface an explicit failure (login-screen maps mfa_setup_required) instead
115
+ // of falling through to generic login_failed.
116
+ if (typeof body.preauthSetupToken === "string") {
117
+ return { kind: "mfa-setup-required", preauthSetupToken: body.preauthSetupToken };
118
+ }
119
+ return { kind: "failure", error: { reason: "mfa_setup_required" } };
98
120
  }
99
121
  if (body.token !== undefined && body.user !== undefined) {
100
122
  return { kind: "success", data: { token: body.token, user: body.user } };
@@ -167,10 +189,10 @@ async function parseTokenFailure(res: Response): Promise<AuthTokenFailure> {
167
189
  return { reason };
168
190
  }
169
191
 
170
- // POST /api/auth/request-password-reset. 200 silent-success: auch wenn
171
- // die Email nicht existiert, sieht der caller `{ ok: true }` — kein
172
- // account-enumeration. Server triggert Mail nur intern wenn user
173
- // gefunden. 429 → typed rate-limit-Failure. 5xx → unknown-error.
192
+ // POST /api/auth/request-password-reset. 200 silent-success: even if the
193
+ // email doesn't exist, the caller sees `{ ok: true }` — no account
194
+ // enumeration. Server only triggers the mail internally when a user is
195
+ // found. 429 → typed rate-limit failure. 5xx → unknown-error.
174
196
  export async function requestPasswordReset(
175
197
  email: string,
176
198
  ): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
@@ -204,7 +226,7 @@ export async function resetPassword(
204
226
  }
205
227
 
206
228
  // POST /api/auth/request-email-verification. Same silent-success
207
- // semantik wie request-password-reset. 429 → rate-limit-Failure.
229
+ // semantics as request-password-reset. 429 → rate-limit failure.
208
230
  export async function requestEmailVerification(
209
231
  email: string,
210
232
  ): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
@@ -234,8 +256,8 @@ export async function verifyEmail(
234
256
  return { ok: false, error: await parseTokenFailure(res) };
235
257
  }
236
258
 
237
- // POST /api/auth/request-account-unlock. Same silent-success semantik wie
238
- // request-password-reset. 429 → rate-limit-Failure.
259
+ // POST /api/auth/request-account-unlock. Same silent-success semantics as
260
+ // request-password-reset. 429 → rate-limit failure.
239
261
  export async function requestAccountUnlock(
240
262
  email: string,
241
263
  ): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
@@ -109,10 +109,17 @@ export function createLoginRoute(
109
109
  preauthSetupToken={setupRequest.preauthSetupToken}
110
110
  accountLabel={setupRequest.accountLabel}
111
111
  onSuccess={() => {
112
- setSetupRequest(null);
113
112
  // MfaSetupPreauthScreen has no session to refresh itself with
114
113
  // (it runs pre-auth) — the gate owns the session, so it refreshes.
115
- void refresh();
114
+ // Keep setupRequest until refresh settles: clearing first would
115
+ // dump the user back on the login form with no retry if refresh
116
+ // rejects (cookie+JWT already minted server-side).
117
+ void refresh().then(
118
+ () => setSetupRequest(null),
119
+ () => {
120
+ // leave setupRequest set so the user can cancel/retry
121
+ },
122
+ );
116
123
  }}
117
124
  onCancel={() => setSetupRequest(null)}
118
125
  />
@@ -136,18 +143,10 @@ export function createLoginRoute(
136
143
  return LoginRoute;
137
144
  }
138
145
 
139
- export function makeAuthGate(
140
- LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
141
- loginProps?: LoginScreenProps,
142
- MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
143
- MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
144
- ): ComponentType<{ children: ReactNode }> {
145
- const LoginRoute = createLoginRoute({
146
- loginScreen: LoginComponent,
147
- loginScreenProps: loginProps,
148
- mfaVerifyScreen: MfaVerifyComponent,
149
- mfaSetupScreen: MfaSetupComponent,
150
- });
146
+ export function makeAuthGate(opts: LoginRouteOptions = {}): ComponentType<{
147
+ children: ReactNode;
148
+ }> {
149
+ const LoginRoute = createLoginRoute(opts);
151
150
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
152
151
  const { status } = useSession();
153
152
  if (status === "authenticated") return <>{children}</>;
@@ -159,13 +158,10 @@ export function makeAuthGate(
159
158
  // SessionProvider + AuthGate as one gate, so a public gate in front (e.g.
160
159
  // /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
161
160
  // providers outside all gates, so SessionProvider can't be a provider anymore.
162
- export function makeSessionAuthGate(
163
- LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
164
- loginProps?: LoginScreenProps,
165
- MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
166
- MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
167
- ): ComponentType<{ children: ReactNode }> {
168
- const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent, MfaSetupComponent);
161
+ export function makeSessionAuthGate(opts: LoginRouteOptions = {}): ComponentType<{
162
+ children: ReactNode;
163
+ }> {
164
+ const AuthGate = makeAuthGate(opts);
169
165
  function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
170
166
  return (
171
167
  <SessionProvider>
@@ -8,7 +8,11 @@
8
8
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
9
  import type { ComponentType, ReactNode } from "react";
10
10
  import { defaultTranslations, mergeTranslations } from "../i18n";
11
- import { type MfaVerifyComponentProps, makeSessionAuthGate } from "./auth-gate";
11
+ import {
12
+ type MfaSetupComponentProps,
13
+ type MfaVerifyComponentProps,
14
+ makeSessionAuthGate,
15
+ } from "./auth-gate";
12
16
  import type { LoginScreenProps } from "./login-screen";
13
17
 
14
18
  export type EmailPasswordClientOptions = {
@@ -24,6 +28,10 @@ export type EmailPasswordClientOptions = {
24
28
  * auth-mfa simply don't pass this — LoginScreen's built-in fallback
25
29
  * error covers that case. */
26
30
  readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
31
+ /** auth-mfa's MfaSetupPreauthScreen, wired in from the app the same way
32
+ * as mfaVerifyScreen above. Apps not mounting auth-mfa simply don't
33
+ * pass this. */
34
+ readonly mfaSetupScreen?: ComponentType<MfaSetupComponentProps>;
27
35
  /** Key-Overrides pro Locale. Wird mit den Default-Bundles (de/en)
28
36
  * aus `translations.ts` gemerged — jeder hier gesetzte Key gewinnt.
29
37
  * Für Branding ("Sign in" → "Login to Acme") oder weitere Sprachen
@@ -49,7 +57,12 @@ export function emailPasswordClient(
49
57
  name: "auth-email-password",
50
58
  providers: [],
51
59
  gates: [
52
- makeSessionAuthGate(options.loginScreen, options.loginScreenProps, options.mfaVerifyScreen),
60
+ makeSessionAuthGate({
61
+ loginScreen: options.loginScreen,
62
+ loginScreenProps: options.loginScreenProps,
63
+ mfaVerifyScreen: options.mfaVerifyScreen,
64
+ mfaSetupScreen: options.mfaSetupScreen,
65
+ }),
53
66
  ],
54
67
  translations,
55
68
  };
@@ -29,7 +29,11 @@ export {
29
29
  } from "./auth-client";
30
30
  export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives";
31
31
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives";
32
- export type { LoginRouteOptions, MfaVerifyComponentProps } from "./auth-gate";
32
+ export type {
33
+ LoginRouteOptions,
34
+ MfaSetupComponentProps,
35
+ MfaVerifyComponentProps,
36
+ } from "./auth-gate";
33
37
  export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate";
34
38
  export type {
35
39
  EmailPasswordClientFeature,