@cosmicdrift/kumiko-bundled-features 0.160.0 → 0.162.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 (47) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
  3. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  4. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
  5. package/src/auth-email-password/constants.ts +6 -0
  6. package/src/auth-email-password/feature.ts +8 -1
  7. package/src/auth-email-password/handlers/login.write.ts +6 -3
  8. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  9. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  10. package/src/auth-email-password/i18n.ts +4 -0
  11. package/src/auth-email-password/index.ts +5 -1
  12. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  13. package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
  14. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  15. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
  16. package/src/auth-email-password/web/auth-client.ts +4 -3
  17. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  18. package/src/auth-email-password/web/login-screen.tsx +11 -5
  19. package/src/auth-foundation/__tests__/feature.test.ts +16 -3
  20. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  21. package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
  22. package/src/auth-foundation/boot-checks.ts +54 -0
  23. package/src/auth-foundation/feature.ts +77 -23
  24. package/src/auth-foundation/index.ts +14 -0
  25. package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
  26. package/src/auth-foundation/types.ts +63 -0
  27. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  28. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  29. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
  30. package/src/auth-mfa/constants.ts +3 -0
  31. package/src/auth-mfa/feature.ts +15 -0
  32. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  33. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  34. package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
  35. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  36. package/src/auth-mfa/mfa-status-checker.ts +13 -4
  37. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  38. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  39. package/src/auth-mfa/web/i18n.ts +14 -0
  40. package/src/auth-mfa/web/index.ts +8 -2
  41. package/src/auth-mfa/web/mfa-client.ts +113 -0
  42. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  43. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  44. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  45. package/src/feature-toggles/index.ts +1 -0
  46. package/src/feature-toggles/toggle-runtime.ts +14 -0
  47. package/src/sessions/feature.ts +8 -1
@@ -275,6 +275,7 @@ describe("LoginScreen", () => {
275
275
  { reason: "rate_limited", needle: "Zu viele Login-Versuche" },
276
276
  { reason: "invalid_body", needle: "Ungültige Eingabe" },
277
277
  { reason: "weird_unknown", needle: "Login fehlgeschlagen" },
278
+ { reason: "mfa_setup_required", needle: "Zwei-Faktor-Authentifizierung erforderlich" },
278
279
  ];
279
280
  for (const { reason, needle } of cases) {
280
281
  const session = makeSessionApi({
@@ -335,11 +336,14 @@ describe("LoginScreen", () => {
335
336
  });
336
337
 
337
338
  test("mfa-setup-required with and without onMfaSetupRequired", async () => {
338
- const onMfaSetupRequired = mock<() => void>();
339
+ const onMfaSetupRequired = mock<(preauthSetupToken: string, accountLabel: string) => void>();
339
340
  const sessionOk = makeSessionApi({
340
341
  status: "unauthenticated",
341
342
  user: null,
342
- login: mock<SessionApi["login"]>(async () => ({ kind: "mfa-setup-required" })),
343
+ login: mock<SessionApi["login"]>(async () => ({
344
+ kind: "mfa-setup-required",
345
+ preauthSetupToken: "setup-token-value",
346
+ })),
343
347
  });
344
348
  const { unmount } = renderWithProviders(
345
349
  <LoginScreen onMfaSetupRequired={onMfaSetupRequired} />,
@@ -349,21 +353,26 @@ describe("LoginScreen", () => {
349
353
  fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
350
354
  fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
351
355
  await waitFor(() => {
352
- expect(onMfaSetupRequired).toHaveBeenCalled();
356
+ expect(onMfaSetupRequired).toHaveBeenCalledWith("setup-token-value", "a@b.c");
353
357
  });
354
358
  unmount();
355
359
 
356
360
  const sessionBare = makeSessionApi({
357
361
  status: "unauthenticated",
358
362
  user: null,
359
- login: mock<SessionApi["login"]>(async () => ({ kind: "mfa-setup-required" })),
363
+ login: mock<SessionApi["login"]>(async () => ({
364
+ kind: "mfa-setup-required",
365
+ preauthSetupToken: "setup-token-value",
366
+ })),
360
367
  });
361
368
  renderWithProviders(<LoginScreen />, { session: sessionBare });
362
369
  fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
363
370
  fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
364
371
  fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
365
372
  await waitFor(() => {
366
- expect(screen.getByRole("alert").textContent).toContain("Zwei-Faktor-Bestätigung");
373
+ expect(screen.getByRole("alert").textContent).toContain(
374
+ "Zwei-Faktor-Authentifizierung erforderlich",
375
+ );
367
376
  });
368
377
  });
369
378
 
@@ -53,7 +53,7 @@ export function csrfHeader(): Record<string, string> {
53
53
  export type LoginResult =
54
54
  | { readonly kind: "success"; readonly data: LoginResponse }
55
55
  | { readonly kind: "mfa-challenge"; readonly challengeToken: string }
56
- | { readonly kind: "mfa-setup-required" }
56
+ | { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string }
57
57
  | { readonly kind: "failure"; readonly error: LoginFailure };
58
58
 
59
59
  // POST /api/auth/login. Success → token + user; MFA-enrolled user → a
@@ -80,6 +80,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
80
80
  mfaRequired?: boolean;
81
81
  challengeToken?: string;
82
82
  mfaSetupRequired?: boolean;
83
+ preauthSetupToken?: string;
83
84
  error?:
84
85
  | {
85
86
  code?: string;
@@ -92,8 +93,8 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
92
93
  if (body.mfaRequired === true && typeof body.challengeToken === "string") {
93
94
  return { kind: "mfa-challenge", challengeToken: body.challengeToken };
94
95
  }
95
- if (body.mfaSetupRequired === true) {
96
- return { kind: "mfa-setup-required" };
96
+ if (body.mfaSetupRequired === true && typeof body.preauthSetupToken === "string") {
97
+ return { kind: "mfa-setup-required", preauthSetupToken: body.preauthSetupToken };
97
98
  }
98
99
  if (body.token !== undefined && body.user !== undefined) {
99
100
  return { kind: "success", data: { token: body.token, user: body.user } };
@@ -23,10 +23,21 @@ export type MfaVerifyComponentProps = {
23
23
  readonly onCancel?: () => void;
24
24
  };
25
25
 
26
+ // Same coupling reasoning as MfaVerifyComponentProps above — generic, not
27
+ // auth-mfa's MfaSetupPreauthScreenProps directly. Apps wire auth-mfa's
28
+ // MfaSetupPreauthScreen in here via EmailPasswordClientOptions.
29
+ export type MfaSetupComponentProps = {
30
+ readonly preauthSetupToken: string;
31
+ readonly accountLabel: string;
32
+ readonly onSuccess?: () => void;
33
+ readonly onCancel?: () => void;
34
+ };
35
+
26
36
  export type LoginRouteOptions = {
27
37
  readonly loginScreen?: ComponentType<LoginScreenProps>;
28
38
  readonly loginScreenProps?: LoginScreenProps;
29
39
  readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
40
+ readonly mfaSetupScreen?: ComponentType<MfaSetupComponentProps>;
30
41
  /** Called once the session becomes authenticated. makeAuthGate ignores
31
42
  * this (it renders `children` on its own authenticated branch instead);
32
43
  * standalone routes — no parent gate, e.g. an anonymous apex/marketing
@@ -51,14 +62,19 @@ export function createLoginRoute(
51
62
  ): ComponentType<Record<string, never>> {
52
63
  const LoginComponent = opts.loginScreen ?? LoginScreen;
53
64
  const MfaVerifyComponent = opts.mfaVerifyScreen;
65
+ const MfaSetupComponent = opts.mfaSetupScreen;
54
66
 
55
67
  function LoginRoute(): ReactNode {
56
- const { status } = useSession();
68
+ const { status, refresh } = useSession();
57
69
  const { onAuthenticated } = opts;
58
70
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
59
71
  // (not in SessionState) because it's a UI-only transition — the server
60
72
  // never considers this session authenticated until verify succeeds.
61
73
  const [challengeToken, setChallengeToken] = useState<string | null>(null);
74
+ const [setupRequest, setSetupRequest] = useState<{
75
+ readonly preauthSetupToken: string;
76
+ readonly accountLabel: string;
77
+ } | null>(null);
62
78
 
63
79
  useEffect(() => {
64
80
  if (status === "authenticated") onAuthenticated?.();
@@ -85,12 +101,35 @@ export function createLoginRoute(
85
101
  />
86
102
  );
87
103
  }
104
+ // Pending preauthSetupToken from LoginScreen's onMfaSetupRequired. Same
105
+ // reasoning as challengeToken — a UI-only transition, not session state.
106
+ if (setupRequest !== null && MfaSetupComponent) {
107
+ return (
108
+ <MfaSetupComponent
109
+ preauthSetupToken={setupRequest.preauthSetupToken}
110
+ accountLabel={setupRequest.accountLabel}
111
+ onSuccess={() => {
112
+ setSetupRequest(null);
113
+ // MfaSetupPreauthScreen has no session to refresh itself with
114
+ // (it runs pre-auth) — the gate owns the session, so it refreshes.
115
+ void refresh();
116
+ }}
117
+ onCancel={() => setSetupRequest(null)}
118
+ />
119
+ );
120
+ }
88
121
  return (
89
122
  <LoginComponent
90
123
  {...opts.loginScreenProps}
91
124
  onMfaChallenge={
92
125
  MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge
93
126
  }
127
+ onMfaSetupRequired={
128
+ MfaSetupComponent
129
+ ? (preauthSetupToken, accountLabel) =>
130
+ setSetupRequest({ preauthSetupToken, accountLabel })
131
+ : opts.loginScreenProps?.onMfaSetupRequired
132
+ }
94
133
  />
95
134
  );
96
135
  }
@@ -101,11 +140,13 @@ export function makeAuthGate(
101
140
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
102
141
  loginProps?: LoginScreenProps,
103
142
  MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
143
+ MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
104
144
  ): ComponentType<{ children: ReactNode }> {
105
145
  const LoginRoute = createLoginRoute({
106
146
  loginScreen: LoginComponent,
107
147
  loginScreenProps: loginProps,
108
148
  mfaVerifyScreen: MfaVerifyComponent,
149
+ mfaSetupScreen: MfaSetupComponent,
109
150
  });
110
151
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
111
152
  const { status } = useSession();
@@ -122,8 +163,9 @@ export function makeSessionAuthGate(
122
163
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
123
164
  loginProps?: LoginScreenProps,
124
165
  MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
166
+ MfaSetupComponent?: ComponentType<MfaSetupComponentProps>,
125
167
  ): ComponentType<{ children: ReactNode }> {
126
- const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent);
168
+ const AuthGate = makeAuthGate(LoginComponent, loginProps, MfaVerifyComponent, MfaSetupComponent);
127
169
  function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
128
170
  return (
129
171
  <SessionProvider>
@@ -64,9 +64,13 @@ export type LoginScreenProps = {
64
64
  * hang, but apps mounting auth-mfa must wire this. */
65
65
  readonly onMfaChallenge?: (challengeToken: string) => void;
66
66
  /** Called when the tenant's enforcement policy requires MFA but this
67
- * user has no factor enrolled yet. Apps typically route to an
68
- * enrollment flow. Same fallback behavior as onMfaChallenge when unset. */
69
- readonly onMfaSetupRequired?: () => void;
67
+ * user has no factor enrolled yet. Carries the preauthSetupToken the
68
+ * login response issued plus the typed email (no session to derive it
69
+ * from) — apps typically swap this screen out for auth-mfa's
70
+ * MfaSetupPreauthScreen(preauthSetupToken, accountLabel). Without a
71
+ * handler, the user sees a "setup required, contact your administrator"
72
+ * error. */
73
+ readonly onMfaSetupRequired?: (preauthSetupToken: string, accountLabel: string) => void;
70
74
  };
71
75
 
72
76
  // Map vom Reason-Code des Login-Handlers auf einen i18n-Key plus
@@ -97,6 +101,8 @@ function reasonToKey(failure: LoginFailure): {
97
101
  return { key: "auth.errors.invalidBody" };
98
102
  case "mfa_not_supported":
99
103
  return { key: "auth.errors.mfaNotSupported" };
104
+ case "mfa_setup_required":
105
+ return { key: "auth.errors.mfaSetupRequired" };
100
106
  default:
101
107
  return { key: "auth.errors.loginFailed" };
102
108
  }
@@ -144,10 +150,10 @@ export function LoginScreen({
144
150
  }
145
151
  if (res.kind === "mfa-setup-required") {
146
152
  if (onMfaSetupRequired) {
147
- onMfaSetupRequired();
153
+ onMfaSetupRequired(res.preauthSetupToken, email);
148
154
  return;
149
155
  }
150
- setError({ reason: "mfa_not_supported" });
156
+ setError({ reason: "mfa_setup_required" });
151
157
  setFailedLoginEmail(email);
152
158
  return;
153
159
  }
@@ -4,7 +4,12 @@
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import { authFoundationFeature } from "../feature";
7
- import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "../types";
7
+ import {
8
+ EXT_SESSION_STORE,
9
+ EXT_TENANT_EXISTENCE,
10
+ EXT_TENANT_RESOLVER,
11
+ EXT_TOKEN_VERIFIER,
12
+ } from "../types";
8
13
 
9
14
  describe("authFoundationFeature — shape", () => {
10
15
  test("has the expected name", () => {
@@ -24,10 +29,18 @@ describe("authFoundationFeature — registers extension-point", () => {
24
29
  test("declares the sessionStore extension-point", () => {
25
30
  expect(authFoundationFeature.registrarExtensions[EXT_SESSION_STORE]).toBeDefined();
26
31
  });
32
+
33
+ test("declares the tenantResolver extension-point", () => {
34
+ expect(authFoundationFeature.registrarExtensions[EXT_TENANT_RESOLVER]).toBeDefined();
35
+ });
36
+
37
+ test("declares the tenantExistence extension-point", () => {
38
+ expect(authFoundationFeature.registrarExtensions[EXT_TENANT_EXISTENCE]).toBeDefined();
39
+ });
27
40
  });
28
41
 
29
42
  describe("authFoundationFeature — multiplicity boot-check", () => {
30
- test("registers a bootCheck per extension-point (tokenVerifier + sessionStore)", () => {
31
- expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(2);
43
+ test("registers a bootCheck per extension-point (token/session/tenant×2)", () => {
44
+ expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(4);
32
45
  });
33
46
  });
@@ -17,6 +17,7 @@ function mockStore(): SessionStore {
17
17
  revoker: async () => undefined,
18
18
  checker: async () => "live",
19
19
  massRevoker: async () => 0,
20
+ revokeAllOthers: async () => 0,
20
21
  };
21
22
  }
22
23
 
@@ -0,0 +1,136 @@
1
+ // resolveTenantResolver / resolveTenantExistence + optional multiplicity (#1373).
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
5
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ validateTenantExistenceMultiplicity,
9
+ validateTenantResolverMultiplicity,
10
+ } from "../boot-checks";
11
+ import { authFoundationFeature, resolveTenantExistence, resolveTenantResolver } from "../feature";
12
+ import { resolveAnonymousAccessFromRegistry } from "../resolve-anonymous-access";
13
+ import {
14
+ EXT_TENANT_EXISTENCE,
15
+ EXT_TENANT_RESOLVER,
16
+ type TenantExistenceProvider,
17
+ type TenantResolverProvider,
18
+ } from "../types";
19
+
20
+ const fakeDb = {} as DbConnection;
21
+
22
+ function resolverProvider(
23
+ entityName: string,
24
+ trust: "authoritative" | "fallback-only" = "authoritative",
25
+ ): ReturnType<typeof defineFeature> {
26
+ const plugin: TenantResolverProvider = {
27
+ trust,
28
+ build: () => async () => "00000000-0000-4000-8000-000000000001",
29
+ };
30
+ return defineFeature(`mock-resolver-${entityName}`, (r) => {
31
+ r.useExtension(EXT_TENANT_RESOLVER, entityName, plugin);
32
+ });
33
+ }
34
+
35
+ function existenceProvider(entityName: string): ReturnType<typeof defineFeature> {
36
+ const plugin: TenantExistenceProvider = {
37
+ build: () => async () => true,
38
+ };
39
+ return defineFeature(`mock-existence-${entityName}`, (r) => {
40
+ r.useExtension(EXT_TENANT_EXISTENCE, entityName, plugin);
41
+ });
42
+ }
43
+
44
+ describe("resolveTenantResolver — optional single provider", () => {
45
+ test("no provider → null", async () => {
46
+ const registry = createRegistry([authFoundationFeature]);
47
+ expect(await resolveTenantResolver({ db: fakeDb, registry })).toBeNull();
48
+ });
49
+
50
+ test("one provider → resolve + trust", async () => {
51
+ const registry = createRegistry([authFoundationFeature, resolverProvider("subdomain")]);
52
+ const resolved = await resolveTenantResolver({ db: fakeDb, registry });
53
+ expect(resolved?.trust).toBe("authoritative");
54
+ expect(await resolved?.resolve({})).toBe("00000000-0000-4000-8000-000000000001");
55
+ });
56
+ });
57
+
58
+ describe("resolveTenantExistence — optional single provider", () => {
59
+ test("no provider → null", async () => {
60
+ const registry = createRegistry([authFoundationFeature]);
61
+ expect(await resolveTenantExistence({ db: fakeDb, registry })).toBeNull();
62
+ });
63
+
64
+ test("one provider → existence fn", async () => {
65
+ const registry = createRegistry([authFoundationFeature, existenceProvider("db")]);
66
+ const exists = await resolveTenantExistence({ db: fakeDb, registry });
67
+ expect(await exists?.("00000000-0000-4000-8000-000000000001")).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("validateTenantResolverMultiplicity", () => {
72
+ test("0 providers → no throw", () => {
73
+ const registry = createRegistry([authFoundationFeature]);
74
+ expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).not.toThrow();
75
+ });
76
+
77
+ test("1 provider → no throw", () => {
78
+ const registry = createRegistry([authFoundationFeature, resolverProvider("a")]);
79
+ expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).not.toThrow();
80
+ });
81
+
82
+ test("≥2 providers → throws", () => {
83
+ const registry = createRegistry([
84
+ authFoundationFeature,
85
+ resolverProvider("a"),
86
+ resolverProvider("b"),
87
+ ]);
88
+ expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).toThrow(
89
+ /2 tenantResolver providers/,
90
+ );
91
+ });
92
+
93
+ test("malformed (no trust) → throws", () => {
94
+ const broken = defineFeature("mock-broken-resolver", (r) => {
95
+ r.useExtension(EXT_TENANT_RESOLVER, "broken", { build: () => () => null });
96
+ });
97
+ const registry = createRegistry([authFoundationFeature, broken]);
98
+ expect(() => validateTenantResolverMultiplicity([...registry.features.values()])).toThrow(
99
+ /without a valid TenantResolverProvider/,
100
+ );
101
+ });
102
+ });
103
+
104
+ describe("validateTenantExistenceMultiplicity", () => {
105
+ test("0 providers → no throw", () => {
106
+ const registry = createRegistry([authFoundationFeature]);
107
+ expect(() =>
108
+ validateTenantExistenceMultiplicity([...registry.features.values()]),
109
+ ).not.toThrow();
110
+ });
111
+
112
+ test("≥2 providers → throws", () => {
113
+ const registry = createRegistry([
114
+ authFoundationFeature,
115
+ existenceProvider("a"),
116
+ existenceProvider("b"),
117
+ ]);
118
+ expect(() => validateTenantExistenceMultiplicity([...registry.features.values()])).toThrow(
119
+ /2 tenantExistence providers/,
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("resolveAnonymousAccessFromRegistry — fail-closed trust", () => {
125
+ test("inline tenantResolver without resolverTrust throws", async () => {
126
+ const registry = createRegistry([authFoundationFeature]);
127
+ await expect(
128
+ resolveAnonymousAccessFromRegistry(
129
+ {
130
+ tenantResolver: () => "00000000-0000-4000-8000-000000000001" as TenantId,
131
+ },
132
+ { db: fakeDb, registry },
133
+ ),
134
+ ).rejects.toThrow(/resolverTrust/);
135
+ });
136
+ });
@@ -13,9 +13,13 @@
13
13
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import {
15
15
  EXT_SESSION_STORE,
16
+ EXT_TENANT_EXISTENCE,
17
+ EXT_TENANT_RESOLVER,
16
18
  EXT_TOKEN_VERIFIER,
17
19
  isAuthProviderPlugin,
18
20
  isSessionStoreProvider,
21
+ isTenantExistenceProvider,
22
+ isTenantResolverProvider,
19
23
  tokenShapeKey,
20
24
  } from "./types";
21
25
 
@@ -89,3 +93,53 @@ export function validateSessionStoreMultiplicity(features: readonly FeatureDefin
89
93
  );
90
94
  }
91
95
  }
96
+
97
+ // Optional single-provider (#1373). Zero registrations = OK (single-tenant /
98
+ // header-cookie path). ≥2 or malformed = boot fail.
99
+ 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
+ }
121
+ }
122
+
123
+ 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
+ }
145
+ }
@@ -1,41 +1,52 @@
1
1
  // kumiko-feature-version: 1
2
2
  //
3
- // auth-foundation — declares the `tokenVerifier` extension point for Bearer-
4
- // token verification. Provider-features (auth-provider-jwt, auth-provider-
5
- // pat, ...) register via `r.useExtension(EXT_TOKEN_VERIFIER, "<name>", plugin)`.
3
+ // auth-foundation — declares auth-middleware extension points:
4
+ // - tokenVerifier (multi, shape-routed)
5
+ // - sessionStore (single, required when foundation mounts)
6
+ // - tenantResolver (single, optional)
7
+ // - tenantExistence (single, optional)
6
8
  //
7
- // **Scaffold only:** not wired into `api/auth-middleware.ts` yet —
8
- // personal-access-tokens still uses its own direct `patResolver` callback
9
- // (kumiko-framework#745). A future issue migrates the middleware onto this
10
- // extension point; #1368 only lays the groundwork (PAT-Plan-Doc Phase-1-
11
- // Sub-Step-1).
12
- //
13
- // **Pattern precedent:** file-foundation/mail-foundation for the extension-
14
- // point + plugin-registry shape. Diverges on ROUTING: those pick one active
15
- // provider per tenant via a config-key; auth-foundation has no tenant yet
16
- // at verify-time, so providers are tried in shape-match order instead (see
17
- // types.ts).
9
+ // Provider-features register via `r.useExtension(EXT_*, "<name>", plugin)`.
10
+ // Middleware / runProdApp resolve providers from the registry at boot
11
+ // (tokenVerifier/sessionStore) or request-time (tokenVerifier shape match).
18
12
 
19
13
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
20
14
  import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
21
- import { validateSessionStoreMultiplicity, validateTokenVerifierMultiplicity } from "./boot-checks";
15
+ import {
16
+ validateSessionStoreMultiplicity,
17
+ validateTenantExistenceMultiplicity,
18
+ validateTenantResolverMultiplicity,
19
+ validateTokenVerifierMultiplicity,
20
+ } from "./boot-checks";
22
21
  import {
23
22
  type AuthProviderBuildDeps,
24
23
  EXT_SESSION_STORE,
24
+ EXT_TENANT_EXISTENCE,
25
+ EXT_TENANT_RESOLVER,
25
26
  EXT_TOKEN_VERIFIER,
26
27
  isAuthProviderPlugin,
27
28
  isSessionStoreProvider,
29
+ isTenantExistenceProvider,
30
+ isTenantResolverProvider,
28
31
  type SessionStore,
32
+ type TenantExistsFn,
33
+ type TenantResolverFn,
34
+ type TenantResolverTrust,
29
35
  tokenShapeMatches,
30
36
  } from "./types";
31
37
 
32
- export { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "./types";
38
+ export {
39
+ EXT_SESSION_STORE,
40
+ EXT_TENANT_EXISTENCE,
41
+ EXT_TENANT_RESOLVER,
42
+ EXT_TOKEN_VERIFIER,
43
+ } from "./types";
33
44
 
34
45
  const FEATURE_NAME = "auth-foundation";
35
46
 
36
47
  export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
37
48
  r.describe(
38
- "Declares the `tokenVerifier` (Bearer-token auth) and `sessionStore` extension points. Provider-features register a static `shape` plus a `build()` for tokenVerifier — mount together with at least one auth-provider-* feature (e.g. personal-access-tokens) and call `resolveTokenVerifier(deps, rawToken)` to find the matching one; sessions self-registers as the single sessionStore provider.",
49
+ "Declares auth-middleware extension points: `tokenVerifier` (Bearer), `sessionStore` (revocable JWT sid), optional `tenantResolver` + `tenantExistence` for anonymous multi-tenant. Provider-features register via r.useExtension; mount auth-foundation with the matching provider features.",
39
50
  );
40
51
  r.uiHints({
41
52
  displayLabel: "Auth Provider Foundation",
@@ -49,19 +60,28 @@ export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
49
60
  // resolveTokenVerifier looks it up at request-time.
50
61
  },
51
62
  });
52
-
53
- // Owns EXT_TOKEN_VERIFIER, so its own mount is the trigger — same
54
- // convention as tenant-lifecycle/user-data-rights' self-bootCheck.
55
63
  r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
56
64
 
57
65
  r.extendsRegistrar(EXT_SESSION_STORE, {
58
66
  onRegister: () => {
59
- // No side-effects at register-time — registry stores the usage,
60
- // resolveSessionStore looks it up at request-time.
67
+ // No side-effects at register-time — resolveSessionStore at boot/request.
61
68
  },
62
69
  });
63
-
64
70
  r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
71
+
72
+ r.extendsRegistrar(EXT_TENANT_RESOLVER, {
73
+ onRegister: () => {
74
+ // Optional — zero providers is fine (single-tenant / header-cookie).
75
+ },
76
+ });
77
+ r.bootCheck(({ features }) => validateTenantResolverMultiplicity(features));
78
+
79
+ r.extendsRegistrar(EXT_TENANT_EXISTENCE, {
80
+ onRegister: () => {
81
+ // Optional — zero providers skips the existence check.
82
+ },
83
+ });
84
+ r.bootCheck(({ features }) => validateTenantExistenceMultiplicity(features));
65
85
  });
66
86
 
67
87
  /**
@@ -101,3 +121,37 @@ export async function resolveSessionStore(
101
121
  }
102
122
  return usage.options.build(deps);
103
123
  }
124
+
125
+ export type ResolvedTenantResolver = {
126
+ readonly resolve: TenantResolverFn;
127
+ readonly trust: TenantResolverTrust;
128
+ };
129
+
130
+ /** Optional tenantResolver — null when no provider registered. */
131
+ export async function resolveTenantResolver(
132
+ deps: AuthProviderBuildDeps & { readonly registry: Registry },
133
+ ): Promise<ResolvedTenantResolver | null> {
134
+ const usage = deps.registry.getExtensionUsages(EXT_TENANT_RESOLVER)[0];
135
+ if (!usage) return null;
136
+ if (!isTenantResolverProvider(usage.options)) {
137
+ throw new Error(
138
+ "[auth-foundation] tenantResolver provider registered without a valid TenantResolverProvider",
139
+ );
140
+ }
141
+ const resolve = await usage.options.build(deps);
142
+ return { resolve, trust: usage.options.trust };
143
+ }
144
+
145
+ /** Optional tenantExistence — null when no provider registered. */
146
+ export async function resolveTenantExistence(
147
+ deps: AuthProviderBuildDeps & { readonly registry: Registry },
148
+ ): Promise<TenantExistsFn | null> {
149
+ const usage = deps.registry.getExtensionUsages(EXT_TENANT_EXISTENCE)[0];
150
+ if (!usage) return null;
151
+ if (!isTenantExistenceProvider(usage.options)) {
152
+ throw new Error(
153
+ "[auth-foundation] tenantExistence provider registered without a valid TenantExistenceProvider",
154
+ );
155
+ }
156
+ return usage.options.build(deps);
157
+ }
@@ -2,21 +2,35 @@
2
2
 
3
3
  export {
4
4
  authFoundationFeature,
5
+ type ResolvedTenantResolver,
5
6
  resolveSessionStore,
7
+ resolveTenantExistence,
8
+ resolveTenantResolver,
6
9
  resolveTokenVerifier,
7
10
  } from "./feature";
11
+ export { resolveAnonymousAccessFromRegistry } from "./resolve-anonymous-access";
8
12
  export {
9
13
  type AuthProviderBuildDeps,
10
14
  type AuthProviderPlugin,
11
15
  type AuthVerifier,
12
16
  EXT_SESSION_STORE,
17
+ EXT_TENANT_EXISTENCE,
18
+ EXT_TENANT_RESOLVER,
13
19
  EXT_TOKEN_VERIFIER,
14
20
  isAuthProviderPlugin,
15
21
  isSessionStoreProvider,
22
+ isTenantExistenceProvider,
23
+ isTenantResolverProvider,
24
+ isTenantResolverTrust,
16
25
  isValidTokenShape,
17
26
  type SessionMassRevoker,
18
27
  type SessionStore,
19
28
  type SessionStoreProvider,
29
+ type TenantExistenceProvider,
30
+ type TenantExistsFn,
31
+ type TenantResolverFn,
32
+ type TenantResolverProvider,
33
+ type TenantResolverTrust,
20
34
  type TokenShape,
21
35
  tokenShapeKey,
22
36
  tokenShapeMatches,