@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
@@ -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,
@@ -11,7 +11,11 @@
11
11
 
12
12
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
13
13
  import { type FormEvent, type ReactNode, useState } from "react";
14
- import { type LoginFailure, requestEmailVerification } from "./auth-client";
14
+ import {
15
+ type KnownLoginFailureReason,
16
+ type LoginFailure,
17
+ requestEmailVerification,
18
+ } from "./auth-client";
15
19
  import { AuthCard } from "./auth-form-primitives";
16
20
  import { useSession } from "./session";
17
21
 
@@ -144,7 +148,7 @@ export function LoginScreen({
144
148
  onMfaChallenge(res.challengeToken);
145
149
  return;
146
150
  }
147
- setError({ reason: "mfa_not_supported" });
151
+ setError({ reason: "mfa_not_supported" satisfies KnownLoginFailureReason });
148
152
  setFailedLoginEmail(email);
149
153
  return;
150
154
  }
@@ -153,7 +157,7 @@ export function LoginScreen({
153
157
  onMfaSetupRequired(res.preauthSetupToken, email);
154
158
  return;
155
159
  }
156
- setError({ reason: "mfa_setup_required" });
160
+ setError({ reason: "mfa_setup_required" satisfies KnownLoginFailureReason });
157
161
  setFailedLoginEmail(email);
158
162
  return;
159
163
  }
@@ -50,7 +50,7 @@ export function RequestAccountUnlockScreen({
50
50
  : undefined;
51
51
  setError(
52
52
  minutes !== undefined
53
- ? t("auth.errors.accountLockedRetry", { minutes })
53
+ ? t("auth.requestUnlock.rateLimited", { minutes })
54
54
  : t("auth.errors.rateLimited"),
55
55
  );
56
56
  } else {
@@ -44,11 +44,9 @@ describe("resolveSessionStore — finds the single registered provider", () => {
44
44
  });
45
45
 
46
46
  describe("validateSessionStoreMultiplicity", () => {
47
- test("0 providers registered → throws (fail-fast, nothing to store sessions in)", () => {
47
+ test("0 providers registered → does not throw (valid: PAT-only / machine-API deployments mount no sessionStore)", () => {
48
48
  const registry = createRegistry([authFoundationFeature]);
49
- expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
50
- /no sessionStore provider registered/,
51
- );
49
+ expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).not.toThrow();
52
50
  });
53
51
 
54
52
  test("exactly 1 provider → no throw", () => {
@@ -134,3 +134,27 @@ describe("resolveAnonymousAccessFromRegistry — fail-closed trust", () => {
134
134
  ).rejects.toThrow(/resolverTrust/);
135
135
  });
136
136
  });
137
+
138
+ describe("resolveAnonymousAccessFromRegistry — opt-in only (#1452)", () => {
139
+ test("a mounted tenantResolver provider alone does not make anonymousAccess defined", async () => {
140
+ const registry = createRegistry([authFoundationFeature, resolverProvider("subdomain")]);
141
+ const result = await resolveAnonymousAccessFromRegistry(undefined, { db: fakeDb, registry });
142
+ expect(result).toBeUndefined();
143
+ });
144
+
145
+ test("a mounted tenantExists provider alone does not make anonymousAccess defined", async () => {
146
+ const registry = createRegistry([authFoundationFeature, existenceProvider("db")]);
147
+ const result = await resolveAnonymousAccessFromRegistry(undefined, { db: fakeDb, registry });
148
+ expect(result).toBeUndefined();
149
+ });
150
+
151
+ test("an app that explicitly opts in still gets providers merged", async () => {
152
+ const registry = createRegistry([authFoundationFeature, resolverProvider("subdomain")]);
153
+ const result = await resolveAnonymousAccessFromRegistry(
154
+ { defaultTenantId: undefined },
155
+ { db: fakeDb, registry },
156
+ );
157
+ expect(result?.tenantResolver).toBeDefined();
158
+ expect(result?.resolverTrust).toBe("authoritative");
159
+ });
160
+ });
@@ -8,7 +8,13 @@ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { validateTokenVerifierMultiplicity } from "../boot-checks";
10
10
  import { authFoundationFeature, resolveTokenVerifier } from "../feature";
11
- import { type AuthProviderPlugin, EXT_TOKEN_VERIFIER } from "../types";
11
+ import {
12
+ type AuthProviderPlugin,
13
+ EXT_SESSION_STORE,
14
+ EXT_TOKEN_VERIFIER,
15
+ type SessionStore,
16
+ type SessionStoreProvider,
17
+ } from "../types";
12
18
 
13
19
  // Mock providers never read `deps.db` — a real provider (personal-access-
14
20
  // tokens/resolver.ts) does a point-read + live-role-resolution against it.
@@ -38,6 +44,23 @@ function jwtProvider(entityName = "jwt-mock"): ReturnType<typeof defineFeature>
38
44
  });
39
45
  }
40
46
 
47
+ function mockStore(): SessionStore {
48
+ return {
49
+ creator: async () => "sid-1",
50
+ revoker: async () => undefined,
51
+ checker: async () => "live",
52
+ massRevoker: async () => 0,
53
+ revokeAllOthers: async () => 0,
54
+ };
55
+ }
56
+
57
+ function storeProvider(entityName = "mock-store"): ReturnType<typeof defineFeature> {
58
+ const plugin: SessionStoreProvider = { build: () => mockStore() };
59
+ return defineFeature(`mock-${entityName}`, (r) => {
60
+ r.useExtension(EXT_SESSION_STORE, entityName, plugin);
61
+ });
62
+ }
63
+
41
64
  describe("resolveTokenVerifier — middleware finds the matching provider", () => {
42
65
  test("routes a prefixed token to the prefix-shape provider", async () => {
43
66
  const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
@@ -65,13 +88,23 @@ describe("resolveTokenVerifier — middleware finds the matching provider", () =
65
88
  });
66
89
 
67
90
  describe("validateTokenVerifierMultiplicity", () => {
68
- test("0 providers registered → throws (fail-fast, no provider to route to)", () => {
91
+ test("0 tokenVerifier + 0 sessionStore → throws (neither auth path mounted)", () => {
69
92
  const registry = createRegistry([authFoundationFeature]);
70
93
  expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
71
- /no tokenVerifier providers registered/,
94
+ /no tokenVerifier providers and no sessionStore registered/,
72
95
  );
73
96
  });
74
97
 
98
+ test("0 tokenVerifier + sessionStore → no throw (session-only / #1570)", () => {
99
+ const registry = createRegistry([authFoundationFeature, storeProvider()]);
100
+ expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).not.toThrow();
101
+ });
102
+
103
+ test("tokenVerifier alone (PAT-only) → no throw", () => {
104
+ const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
105
+ expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).not.toThrow();
106
+ });
107
+
75
108
  test("1 provider per shape → no throw", () => {
76
109
  const registry = createRegistry([
77
110
  authFoundationFeature,
@@ -1,14 +1,16 @@
1
1
  // Multiplicity boot-check for auth-foundation's `tokenVerifier` extension
2
- // point (#1368). Two static-shape conflicts a boot-check CAN catch (a
2
+ // point (#1368, #1570). Two static-shape conflicts a boot-check CAN catch (a
3
3
  // runtime shape-match predicate's overlap can't be proven at boot): a
4
4
  // malformed plugin registration, and two providers both claiming the same
5
- // shape (resolveTokenVerifier can't tell them apart). Zero registered
6
- // providers is ALSO caught here — fail-fast at boot ("you mounted the
7
- // foundation but forgot a provider") rather than a runtime 401 nobody
8
- // can attribute, unlike file-foundation/mail-foundation which defer that
9
- // check to request-time (their provider is picked from tenant config,
10
- // which doesn't exist at boot; auth-foundation's providers are static
11
- // per-deployment, so boot is the right time to catch it).
5
+ // shape (resolveTokenVerifier can't tell them apart). Zero tokenVerifier
6
+ // providers is OK when ≥1 sessionStore is mounted (session-only cookie
7
+ // apps never use Bearer routing). Zero of both is caught here — fail-fast
8
+ // at boot ("you mounted the foundation but forgot sessions or a provider")
9
+ // rather than a runtime 401 nobody can attribute, unlike
10
+ // file-foundation/mail-foundation which defer that check to request-time
11
+ // (their provider is picked from tenant config, which doesn't exist at
12
+ // boot; auth-foundation's providers are static per-deployment, so boot is
13
+ // the right time to catch it).
12
14
 
13
15
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
14
16
  import {
@@ -43,9 +45,18 @@ export function validateTokenVerifierMultiplicity(features: readonly FeatureDefi
43
45
  }
44
46
 
45
47
  if (namesByShape.size === 0) {
48
+ // Session-only apps (email-password + sessions) never hit tokenVerifier —
49
+ // cookie JWT auth uses sessionStore alone (#1570 / phronexsis#296).
50
+ for (const feature of features) {
51
+ for (const usage of feature.extensionUsages) {
52
+ // skip: sessionStore present — tokenVerifier optional for cookie-session apps.
53
+ if (usage.extensionName === EXT_SESSION_STORE) return;
54
+ }
55
+ }
46
56
  throw new Error(
47
- "[auth-foundation] no tokenVerifier providers registered — mount at least one " +
48
- "auth-provider-* feature (e.g. auth-provider-jwt) alongside auth-foundation.",
57
+ "[auth-foundation] no tokenVerifier providers and no sessionStore registered — mount " +
58
+ "at least one auth-provider-* feature (e.g. auth-provider-jwt / personal-access-tokens) " +
59
+ "or a sessionStore (e.g. sessions) alongside auth-foundation.",
49
60
  );
50
61
  }
51
62
 
@@ -60,86 +71,80 @@ export function validateTokenVerifierMultiplicity(features: readonly FeatureDefi
60
71
  }
61
72
  }
62
73
 
63
- // Multiplicity boot-check for the `sessionStore` extension point (#1370).
64
- // Single-provider, unlike tokenVerifier — no shape to route on, so exactly
65
- // one registration is required; 0 or ≥2 both fail boot.
66
- export function validateSessionStoreMultiplicity(features: readonly FeatureDefinition[]): void {
74
+ // Shared by the three single-provider extension points below: collect every
75
+ // registration for `ext`, validating each usage's options against `guard`,
76
+ // then fail boot on ≥2 (resolve*() has no way to route between them). Zero
77
+ // registrations is NOT checked here — it's valid for all three (a
78
+ // machine-API-only auth-foundation with PAT-bearer auth and no browser
79
+ // sessions, single-tenant deployments with no tenantResolver, etc.); the
80
+ // runtime already models "extension point unmounted" as a legal
81
+ // configuration (run-prod-app.ts only wires sessions when
82
+ // EXT_SESSION_STORE has ≥1 usage), and resolve*() throws its own clear
83
+ // error if a caller requests a provider that was never registered.
84
+ function validateSingleProvider(
85
+ features: readonly FeatureDefinition[],
86
+ opts: {
87
+ readonly ext: string;
88
+ readonly label: string;
89
+ readonly guard: (options: unknown) => boolean;
90
+ readonly shapeHint: string;
91
+ },
92
+ ): void {
93
+ const { ext, label, guard, shapeHint } = opts;
67
94
  const names: string[] = [];
68
95
 
69
96
  for (const feature of features) {
70
97
  for (const usage of feature.extensionUsages) {
71
- if (usage.extensionName !== EXT_SESSION_STORE) continue;
72
- if (!isSessionStoreProvider(usage.options)) {
98
+ if (usage.extensionName !== ext) continue;
99
+ if (!guard(usage.options)) {
73
100
  throw new Error(
74
- `[auth-foundation] sessionStore provider "${usage.entityName}" (feature "${feature.name}") ` +
75
- `registered without a valid SessionStoreProvider — options must have a { build } shape.`,
101
+ `[auth-foundation] ${label} provider "${usage.entityName}" (feature "${feature.name}") ` +
102
+ `registered without a valid ${shapeHint}.`,
76
103
  );
77
104
  }
78
105
  names.push(usage.entityName);
79
106
  }
80
107
  }
81
108
 
82
- if (names.length === 0) {
83
- throw new Error(
84
- "[auth-foundation] no sessionStore provider registered — mount a feature that " +
85
- "registers one via r.useExtension(EXT_SESSION_STORE, ...) alongside auth-foundation.",
86
- );
87
- }
88
-
89
109
  if (names.length >= 2) {
90
110
  throw new Error(
91
- `[auth-foundation] ${names.length} sessionStore providers registered (${names.join(", ")}) — ` +
92
- `only one sessionStore provider may be mounted at a time.`,
111
+ `[auth-foundation] ${names.length} ${label} providers registered (${names.join(", ")}) — ` +
112
+ `only one ${label} provider may be mounted at a time.`,
93
113
  );
94
114
  }
95
115
  }
96
116
 
117
+ // Multiplicity boot-check for the `sessionStore` extension point (#1370).
118
+ // Single-provider, unlike tokenVerifier — no shape to route on, so ≥2 fails
119
+ // boot. Zero registrations is a valid configuration (see
120
+ // validateSingleProvider doc) — a pure machine API using auth-foundation for
121
+ // PAT-bearer auth alone can mount it without also pulling in the whole
122
+ // sessions feature.
123
+ export function validateSessionStoreMultiplicity(features: readonly FeatureDefinition[]): void {
124
+ validateSingleProvider(features, {
125
+ ext: EXT_SESSION_STORE,
126
+ label: "sessionStore",
127
+ guard: isSessionStoreProvider,
128
+ shapeHint: "SessionStoreProvider — options must have a { build } shape",
129
+ });
130
+ }
131
+
97
132
  // Optional single-provider (#1373). Zero registrations = OK (single-tenant /
98
133
  // header-cookie path). ≥2 or malformed = boot fail.
99
134
  export function validateTenantResolverMultiplicity(features: readonly FeatureDefinition[]): void {
100
- const names: string[] = [];
101
-
102
- for (const feature of features) {
103
- for (const usage of feature.extensionUsages) {
104
- if (usage.extensionName !== EXT_TENANT_RESOLVER) continue;
105
- if (!isTenantResolverProvider(usage.options)) {
106
- throw new Error(
107
- `[auth-foundation] tenantResolver provider "${usage.entityName}" (feature "${feature.name}") ` +
108
- `registered without a valid TenantResolverProvider — options must have { trust, build }.`,
109
- );
110
- }
111
- names.push(usage.entityName);
112
- }
113
- }
114
-
115
- if (names.length >= 2) {
116
- throw new Error(
117
- `[auth-foundation] ${names.length} tenantResolver providers registered (${names.join(", ")}) — ` +
118
- `only one tenantResolver provider may be mounted at a time.`,
119
- );
120
- }
135
+ validateSingleProvider(features, {
136
+ ext: EXT_TENANT_RESOLVER,
137
+ label: "tenantResolver",
138
+ guard: isTenantResolverProvider,
139
+ shapeHint: "TenantResolverProvider — options must have { trust, build }",
140
+ });
121
141
  }
122
142
 
123
143
  export function validateTenantExistenceMultiplicity(features: readonly FeatureDefinition[]): void {
124
- const names: string[] = [];
125
-
126
- for (const feature of features) {
127
- for (const usage of feature.extensionUsages) {
128
- if (usage.extensionName !== EXT_TENANT_EXISTENCE) continue;
129
- if (!isTenantExistenceProvider(usage.options)) {
130
- throw new Error(
131
- `[auth-foundation] tenantExistence provider "${usage.entityName}" (feature "${feature.name}") ` +
132
- `registered without a valid TenantExistenceProvider — options must have a { build } shape.`,
133
- );
134
- }
135
- names.push(usage.entityName);
136
- }
137
- }
138
-
139
- if (names.length >= 2) {
140
- throw new Error(
141
- `[auth-foundation] ${names.length} tenantExistence providers registered (${names.join(", ")}) — ` +
142
- `only one tenantExistence provider may be mounted at a time.`,
143
- );
144
- }
144
+ validateSingleProvider(features, {
145
+ ext: EXT_TENANT_EXISTENCE,
146
+ label: "tenantExistence",
147
+ guard: isTenantExistenceProvider,
148
+ shapeHint: "TenantExistenceProvider — options must have a { build } shape",
149
+ });
145
150
  }
@@ -6,8 +6,6 @@
6
6
  import type {
7
7
  AnonymousAccessConfig,
8
8
  AnonymousAccessResolved,
9
- TenantExists,
10
- TenantResolver,
11
9
  } from "@cosmicdrift/kumiko-framework/api";
12
10
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
13
11
  import { resolveTenantExistence, resolveTenantResolver } from "./feature";
@@ -22,7 +20,12 @@ export async function resolveAnonymousAccessFromRegistry(
22
20
  resolveTenantExistence(deps),
23
21
  ]);
24
22
 
25
- if (!base && !tenantResolver && !tenantExists) return undefined;
23
+ // Anonymous access is app opt-in only: a mounted routing provider
24
+ // (EXT_TENANT_RESOLVER/EXT_TENANT_EXISTENCE) alone must never make
25
+ // anonymousAccess become defined for an app that never set it — that
26
+ // would upgrade every roles:["anonymous"] handler to reachable without
27
+ // a JWT the moment such a routing feature is mounted (#1452).
28
+ if (!base) return undefined;
26
29
 
27
30
  // Fail-closed (#1374 security): inline tenantResolver without trust is the
28
31
  // old ambiguous config — providers always carry trust; test stacks must too.
@@ -45,10 +48,15 @@ export async function resolveAnonymousAccessFromRegistry(
45
48
  ...(base ?? {}),
46
49
  ...(tenantResolver
47
50
  ? {
48
- tenantResolver: tenantResolver.resolve as TenantResolver,
51
+ // No cast: TenantResolverFn's `(c: unknown) => string | null`
52
+ // and the framework's TenantResolver `(c: Context) => TenantId
53
+ // | null` are structurally identical (TenantId is a plain
54
+ // string alias, no brand) — `unknown` is a supertype of
55
+ // Context, so this is already sound by parameter contravariance.
56
+ tenantResolver: tenantResolver.resolve,
49
57
  resolverTrust: tenantResolver.trust,
50
58
  }
51
59
  : {}),
52
- ...(tenantExists ? { tenantExists: tenantExists as TenantExists } : {}),
60
+ ...(tenantExists ? { tenantExists } : {}),
53
61
  };
54
62
  }