@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
@@ -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
  }
@@ -14,11 +14,14 @@
14
14
  import type {
15
15
  SessionChecker,
16
16
  SessionCreator,
17
+ SessionMassRevoker,
17
18
  SessionRevoker,
18
19
  } from "@cosmicdrift/kumiko-framework/api";
19
20
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
20
21
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
21
22
 
23
+ export type { SessionMassRevoker };
24
+
22
25
  export const EXT_TOKEN_VERIFIER = "tokenVerifier";
23
26
 
24
27
  /**
@@ -83,9 +86,6 @@ export function tokenShapeKey(shape: TokenShape): string {
83
86
 
84
87
  export const EXT_SESSION_STORE = "sessionStore";
85
88
 
86
- /** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
87
- export type SessionMassRevoker = (userId: string) => Promise<number>;
88
-
89
89
  /** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
90
90
  export type SessionRevokeAllOthers = (
91
91
  userId: string,
@@ -37,11 +37,16 @@ import { userEntity, userTable } from "../../user/schema/user";
37
37
  import { base32Decode } from "../base32";
38
38
  import { mfaRequiredConfigHandle } from "../config";
39
39
  import { AuthMfaHandlers } from "../constants";
40
- import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
40
+ import {
41
+ bindMfaRevokeAllOtherSessionsFromFeature,
42
+ createAuthMfaFeature,
43
+ mfaStatusCheckerFromFeature,
44
+ } from "../feature";
41
45
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
42
46
  import { currentTotpCode } from "../totp";
43
47
 
44
48
  let stack: TestStack;
49
+ const revokedForUserIds: string[] = [];
45
50
 
46
51
  const CHALLENGE_TOKEN_SECRET = "integration-test-challenge-token-secret-not-real-0001";
47
52
  const SETUP_TOKEN_SECRET = "integration-test-setup-token-secret-not-real-0002";
@@ -84,6 +89,20 @@ beforeAll(async () => {
84
89
  await unsafeCreateEntityTable(stack.db, tenantEntity);
85
90
  await unsafeCreateEntityTable(stack.db, userMfaEntity);
86
91
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
92
+
93
+ // Wire a spy in place of the real session-revoke callback (run-prod-app
94
+ // only binds this once the sessions feature is mounted — not the case in
95
+ // this test stack) so #1467's regression can observe whether
96
+ // revokeAllOtherSessions actually fires, instead of silently no-op'ing.
97
+ const bind = bindMfaRevokeAllOtherSessionsFromFeature(authMfaFeature);
98
+ // If the binder ever returns undefined, the spy below never wires up and
99
+ // the #1467 regression check further down would pass vacuously (nothing
100
+ // to revoke because nothing observed) instead of catching a real re-break.
101
+ expect(bind).toBeDefined();
102
+ bind?.((userId) => {
103
+ revokedForUserIds.push(userId);
104
+ return Promise.resolve(0);
105
+ });
87
106
  });
88
107
 
89
108
  afterAll(async () => {
@@ -93,6 +112,7 @@ afterAll(async () => {
93
112
  beforeEach(async () => {
94
113
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
95
114
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
115
+ revokedForUserIds.length = 0;
96
116
  });
97
117
 
98
118
  async function loginBlockedByEnforcement(
@@ -159,6 +179,10 @@ describe("POST /auth/mfa/preauth-confirm", () => {
159
179
  { Authorization: `Bearer ${body.token}` },
160
180
  );
161
181
  expect(meRes.status).toBe(200);
182
+
183
+ // Positive control for the #1467 regression below — proves the spy
184
+ // wiring actually observes a real revoke call, not just silent no-ops.
185
+ expect(revokedForUserIds).toContain(body.user.id);
162
186
  });
163
187
 
164
188
  test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
@@ -247,6 +271,42 @@ describe("POST /auth/mfa/preauth-confirm", () => {
247
271
  expect(body.error.details.reason).toBe("invalid_setup_token");
248
272
  });
249
273
 
274
+ // #1467: findForAuth's status check + membership lookup used to run AFTER
275
+ // the entity-write and the revokeAllOtherSessions call — an admin
276
+ // restricting/deleting the account between preauth-enable-start and
277
+ // preauth-confirm still got invalid_setup_token back correctly (the
278
+ // handler's whole DB transaction rolls back on a non-success return, so
279
+ // the MFA row itself never actually persisted), but revokeAllOtherSessions
280
+ // is an external side effect outside that transaction — it fired
281
+ // regardless, logging the user's other devices out over an enrollment
282
+ // attempt that was rejected. Regression: with status/membership checked
283
+ // BEFORE the write, the revoke call must not fire at all for this case.
284
+ test("user restricted between start and confirm → invalid_setup_token, no session revoked (#1467)", async () => {
285
+ const email = "restricted-midflight@example.com";
286
+ const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
287
+ const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
288
+
289
+ await asRawClient(stack.db).unsafe(
290
+ `UPDATE "${userTable.tableName}" SET "status" = 'restricted' WHERE "id" = $1`,
291
+ [userId],
292
+ );
293
+
294
+ const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
295
+ setupToken,
296
+ code: currentTotpCode(secret),
297
+ });
298
+
299
+ expect(res.status).not.toBe(200);
300
+ const body = await res.json();
301
+ expect(body.error.details.reason).toBe("invalid_setup_token");
302
+ expect(revokedForUserIds).not.toContain(userId);
303
+ // Enrollment write itself rolls back with the handler's failure return —
304
+ // pinned here too so a future change to that transactional behavior
305
+ // doesn't silently reintroduce the original "row persists" half of the bug.
306
+ const rows = await selectMany(stack.db, userMfaTable, { userId });
307
+ expect(rows).toHaveLength(0);
308
+ });
309
+
250
310
  test("expired/garbage setupToken → invalid_setup_token, no session minted", async () => {
251
311
  const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
252
312
  setupToken: "not-a-real-token",