@cosmicdrift/kumiko-bundled-features 0.159.1 → 0.161.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 (38) 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__/session-callbacks.integration.test.ts +5 -0
  4. package/src/auth-email-password/handlers/login.write.ts +6 -3
  5. package/src/auth-email-password/i18n.ts +4 -0
  6. package/src/auth-email-password/web/__tests__/auth-client.test.ts +529 -0
  7. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  8. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  9. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +134 -0
  10. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  11. package/src/auth-email-password/web/auth-client.ts +4 -3
  12. package/src/auth-email-password/web/login-screen.tsx +5 -2
  13. package/src/auth-foundation/__tests__/feature.test.ts +16 -3
  14. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  15. package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
  16. package/src/auth-foundation/boot-checks.ts +54 -0
  17. package/src/auth-foundation/feature.ts +77 -23
  18. package/src/auth-foundation/index.ts +14 -0
  19. package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
  20. package/src/auth-foundation/types.ts +63 -0
  21. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
  22. package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
  23. package/src/auth-mfa/constants.ts +1 -0
  24. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  25. package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
  26. package/src/auth-mfa/mfa-status-checker.ts +13 -4
  27. package/src/config/__tests__/app-overrides.test.ts +11 -0
  28. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  29. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  30. package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
  31. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  32. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  33. package/src/personal-access-tokens/feature.ts +1 -1
  34. package/src/sessions/feature.ts +8 -1
  35. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  36. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
  37. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  38. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
@@ -3,16 +3,17 @@
3
3
  // useSession() throws without a provider, so any consumer mounting this
4
4
  // screen on a public route outside SessionAuthGate crashed at runtime.
5
5
 
6
- import { describe, expect, test } from "bun:test";
6
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
7
7
  import {
8
8
  createStaticLocaleResolver,
9
9
  LocaleProvider,
10
10
  PrimitivesProvider,
11
11
  } from "@cosmicdrift/kumiko-renderer";
12
12
  import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
13
- import { render, screen } from "@testing-library/react";
13
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
14
14
  import { defaultTranslations } from "../../i18n";
15
15
  import { InviteAcceptScreen } from "../invite-accept-screen";
16
+ import { makeSessionApi, renderWithProviders } from "./test-utils";
16
17
 
17
18
  const resolver = createStaticLocaleResolver({ locale: "de" });
18
19
 
@@ -26,6 +27,24 @@ function renderWithoutSessionProvider(token: string) {
26
27
  );
27
28
  }
28
29
 
30
+ beforeEach(() => {
31
+ globalThis.fetch = mock(
32
+ async () => new Response(JSON.stringify({ tenantId: "tenant-new" }), { status: 200 }),
33
+ ) as unknown as typeof fetch;
34
+ });
35
+
36
+ // Capture once — afterEach must restore the real Location, not `{...location}`
37
+ // (plain objects break `new URL(window.location.href)` in useUrlToken → ResetPasswordScreen).
38
+ const originalLocation = window.location;
39
+
40
+ afterEach(() => {
41
+ Object.defineProperty(window, "location", {
42
+ configurable: true,
43
+ writable: true,
44
+ value: originalLocation,
45
+ });
46
+ });
47
+
29
48
  describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
30
49
  test("renders the anonymous accept-form instead of throwing", () => {
31
50
  renderWithoutSessionProvider("tok-123");
@@ -40,5 +59,110 @@ describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
40
59
  expect(
41
60
  screen.getByText("Der Einladungs-Link enthält keinen Token oder ist ungültig."),
42
61
  ).toBeTruthy();
62
+ expect(screen.getByRole("link", { name: "Zum Login" }).getAttribute("href")).toBe("/login");
63
+ });
64
+ });
65
+
66
+ describe("InviteAcceptScreen — logged-in branch", () => {
67
+ test("authenticated session shows 1-click accept form", () => {
68
+ renderWithProviders(<InviteAcceptScreen token="tok-123" />, {
69
+ session: makeSessionApi({ status: "authenticated" }),
70
+ });
71
+ expect(screen.getByText(/Du bist eingeloggt/)).toBeTruthy();
72
+ expect(screen.getByRole("button", { name: "Annehmen" })).toBeTruthy();
73
+ expect(screen.queryByLabelText(/^E-Mail/)).toBeNull();
74
+ });
75
+
76
+ test("accept logged-in success redirects via loggedInHref function", async () => {
77
+ const assign = mock<(url: string) => void>();
78
+ Object.defineProperty(window, "location", {
79
+ configurable: true,
80
+ writable: true,
81
+ value: { href: originalLocation.href, search: originalLocation.search, assign },
82
+ });
83
+ renderWithProviders(
84
+ <InviteAcceptScreen token="tok-123" loggedInHref={({ tenantId }) => `/${tenantId}/home`} />,
85
+ { session: makeSessionApi({ status: "authenticated" }) },
86
+ );
87
+ fireEvent.click(screen.getByRole("button", { name: "Annehmen" }));
88
+ await waitFor(() => {
89
+ expect(assign).toHaveBeenCalledWith("/tenant-new/home");
90
+ });
91
+ });
92
+
93
+ test("accept logged-in failure shows invalidInviteToken banner", async () => {
94
+ globalThis.fetch = mock(
95
+ async () => new Response(null, { status: 422 }),
96
+ ) as unknown as typeof fetch;
97
+ renderWithProviders(<InviteAcceptScreen token="bad" />, {
98
+ session: makeSessionApi({ status: "authenticated" }),
99
+ });
100
+ fireEvent.click(screen.getByRole("button", { name: "Annehmen" }));
101
+ await waitFor(() => {
102
+ expect(screen.getByRole("alert").textContent).toContain("auth.errors.invalidInviteToken");
103
+ });
104
+ });
105
+
106
+ test("use other account switches to anon-existing form", () => {
107
+ renderWithProviders(<InviteAcceptScreen token="tok-123" />, {
108
+ session: makeSessionApi({ status: "authenticated" }),
109
+ });
110
+ fireEvent.click(screen.getByRole("button", { name: "Mit anderem Account anmelden" }));
111
+ expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
112
+ });
113
+ });
114
+
115
+ describe("InviteAcceptScreen — anonymous branches", () => {
116
+ test("anon-existing submit posts invite-accept-with-login", async () => {
117
+ const fetchMock = mock(
118
+ async () => new Response(JSON.stringify({ tenantId: "t1" }), { status: 200 }),
119
+ );
120
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
121
+ renderWithoutSessionProvider("tok-123");
122
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@example.com" } });
123
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "secret123" } });
124
+ fireEvent.click(screen.getByRole("button", { name: "Annehmen + Anmelden" }));
125
+ await waitFor(() => {
126
+ expect(fetchMock).toHaveBeenCalledWith(
127
+ "/api/auth/invite-accept-with-login",
128
+ expect.objectContaining({
129
+ method: "POST",
130
+ body: JSON.stringify({ token: "tok-123", email: "a@example.com", password: "secret123" }),
131
+ }),
132
+ );
133
+ });
134
+ });
135
+
136
+ test("toggle to anon-new hides email and posts invite-signup-complete", async () => {
137
+ const fetchMock = mock(
138
+ async () => new Response(JSON.stringify({ tenantId: "t1" }), { status: 200 }),
139
+ );
140
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
141
+ renderWithoutSessionProvider("tok-123");
142
+ fireEvent.click(screen.getByRole("button", { name: "Ich habe noch keinen Account" }));
143
+ expect(screen.queryByLabelText(/^E-Mail/)).toBeNull();
144
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "newpass123" } });
145
+ fireEvent.click(screen.getByRole("button", { name: "Annehmen + Anmelden" }));
146
+ await waitFor(() => {
147
+ expect(fetchMock).toHaveBeenCalledWith(
148
+ "/api/auth/invite-signup-complete",
149
+ expect.objectContaining({
150
+ body: JSON.stringify({ token: "tok-123", password: "newpass123" }),
151
+ }),
152
+ );
153
+ });
154
+ });
155
+
156
+ test("anon failure shows invalidInviteToken banner", async () => {
157
+ globalThis.fetch = mock(
158
+ async () => new Response(null, { status: 422 }),
159
+ ) as unknown as typeof fetch;
160
+ renderWithoutSessionProvider("tok-123");
161
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@example.com" } });
162
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "wrong" } });
163
+ fireEvent.click(screen.getByRole("button", { name: "Annehmen + Anmelden" }));
164
+ await waitFor(() => {
165
+ expect(screen.getByRole("alert").textContent).toContain("auth.errors.invalidInviteToken");
166
+ });
43
167
  });
44
168
  });
@@ -247,4 +247,138 @@ describe("LoginScreen", () => {
247
247
  expect(screen.queryByRole("button", { name: /erneut senden/i })).toBeNull();
248
248
  });
249
249
  });
250
+
251
+ test("account_locked without retry → unlock link when href set", async () => {
252
+ const session = makeSessionApi({
253
+ status: "unauthenticated",
254
+ user: null,
255
+ login: mock<SessionApi["login"]>(async () => ({
256
+ kind: "failure",
257
+ error: { reason: "account_locked" },
258
+ })),
259
+ });
260
+ renderWithProviders(<LoginScreen unlockAccountHref="/unlock" />, { session });
261
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
262
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
263
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
264
+ await waitFor(() => {
265
+ expect(screen.getByRole("alert").textContent).toContain("vorübergehend gesperrt");
266
+ });
267
+ expect(screen.getByRole("link", { name: /Konto entsperren/i }).getAttribute("href")).toBe(
268
+ "/unlock",
269
+ );
270
+ });
271
+
272
+ test("reasonToKey covers no_membership, rate_limited, invalid_body, default", async () => {
273
+ const cases: Array<{ reason: string; needle: string }> = [
274
+ { reason: "no_membership", needle: "keinen Tenant-Zugang" },
275
+ { reason: "rate_limited", needle: "Zu viele Login-Versuche" },
276
+ { reason: "invalid_body", needle: "Ungültige Eingabe" },
277
+ { reason: "weird_unknown", needle: "Login fehlgeschlagen" },
278
+ { reason: "mfa_setup_required", needle: "Zwei-Faktor-Authentifizierung erforderlich" },
279
+ ];
280
+ for (const { reason, needle } of cases) {
281
+ const session = makeSessionApi({
282
+ status: "unauthenticated",
283
+ user: null,
284
+ login: mock<SessionApi["login"]>(async () => ({
285
+ kind: "failure",
286
+ error: { reason },
287
+ })),
288
+ });
289
+ const { unmount } = renderWithProviders(<LoginScreen />, { session });
290
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
291
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
292
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
293
+ await waitFor(() => {
294
+ expect(screen.getByRole("alert").textContent).toContain(needle);
295
+ });
296
+ unmount();
297
+ }
298
+ });
299
+
300
+ test("mfa-challenge with onMfaChallenge fires token", async () => {
301
+ const onMfaChallenge = mock<(t: string) => void>();
302
+ const session = makeSessionApi({
303
+ status: "unauthenticated",
304
+ user: null,
305
+ login: mock<SessionApi["login"]>(async () => ({
306
+ kind: "mfa-challenge",
307
+ challengeToken: "chal-1",
308
+ })),
309
+ });
310
+ renderWithProviders(<LoginScreen onMfaChallenge={onMfaChallenge} />, { session });
311
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
312
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
313
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
314
+ await waitFor(() => {
315
+ expect(onMfaChallenge).toHaveBeenCalledWith("chal-1");
316
+ });
317
+ expect(screen.queryByRole("alert")).toBeNull();
318
+ });
319
+
320
+ test("mfa-challenge without callback → mfa_not_supported", async () => {
321
+ const session = makeSessionApi({
322
+ status: "unauthenticated",
323
+ user: null,
324
+ login: mock<SessionApi["login"]>(async () => ({
325
+ kind: "mfa-challenge",
326
+ challengeToken: "chal-1",
327
+ })),
328
+ });
329
+ renderWithProviders(<LoginScreen />, { session });
330
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
331
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
332
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
333
+ await waitFor(() => {
334
+ expect(screen.getByRole("alert").textContent).toMatch(/MFA|Zwei-Faktor|nicht unterstützt/i);
335
+ });
336
+ });
337
+
338
+ test("mfa-setup-required with and without onMfaSetupRequired", async () => {
339
+ const onMfaSetupRequired = mock<() => void>();
340
+ const sessionOk = makeSessionApi({
341
+ status: "unauthenticated",
342
+ user: null,
343
+ login: mock<SessionApi["login"]>(async () => ({
344
+ kind: "mfa-setup-required",
345
+ preauthSetupToken: "setup-token-value",
346
+ })),
347
+ });
348
+ const { unmount } = renderWithProviders(
349
+ <LoginScreen onMfaSetupRequired={onMfaSetupRequired} />,
350
+ { session: sessionOk },
351
+ );
352
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
353
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
354
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
355
+ await waitFor(() => {
356
+ expect(onMfaSetupRequired).toHaveBeenCalled();
357
+ });
358
+ unmount();
359
+
360
+ const sessionBare = makeSessionApi({
361
+ status: "unauthenticated",
362
+ user: null,
363
+ login: mock<SessionApi["login"]>(async () => ({
364
+ kind: "mfa-setup-required",
365
+ preauthSetupToken: "setup-token-value",
366
+ })),
367
+ });
368
+ renderWithProviders(<LoginScreen />, { session: sessionBare });
369
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
370
+ fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
371
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
372
+ await waitFor(() => {
373
+ expect(screen.getByRole("alert").textContent).toContain(
374
+ "Zwei-Faktor-Authentifizierung erforderlich",
375
+ );
376
+ });
377
+ });
378
+
379
+ test("signupHref renders signup link", () => {
380
+ renderWithProviders(<LoginScreen signupHref="/signup" />);
381
+ const link = screen.getByRole("link", { name: "Account erstellen" });
382
+ expect(link.getAttribute("href")).toBe("/signup");
383
+ });
250
384
  });
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import { RequestAccountUnlockScreen } from "../request-account-unlock-screen";
4
+ import { renderWithProviders } from "./test-utils";
5
+
6
+ beforeEach(() => {
7
+ globalThis.fetch = mock(
8
+ async () => new Response(null, { status: 200 }),
9
+ ) as unknown as typeof fetch;
10
+ });
11
+ afterEach(() => {});
12
+
13
+ describe("RequestAccountUnlockScreen", () => {
14
+ test("renders title + email input + submit (de)", () => {
15
+ renderWithProviders(<RequestAccountUnlockScreen />);
16
+ expect(screen.getByText("Konto entsperren")).toBeTruthy();
17
+ expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
18
+ expect(screen.getByRole("button", { name: "Link anfordern" })).toBeTruthy();
19
+ });
20
+
21
+ test("submit posts /api/auth/request-account-unlock with email", async () => {
22
+ const fetchMock = mock(async () => new Response(null, { status: 200 }));
23
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
24
+
25
+ renderWithProviders(<RequestAccountUnlockScreen />);
26
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
27
+ target: { value: "user@example.com" },
28
+ });
29
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
30
+
31
+ await waitFor(() => {
32
+ expect(fetchMock).toHaveBeenCalledWith(
33
+ "/api/auth/request-account-unlock",
34
+ expect.objectContaining({
35
+ method: "POST",
36
+ body: JSON.stringify({ email: "user@example.com" }),
37
+ }),
38
+ );
39
+ });
40
+ });
41
+
42
+ test("success → silent-success banner + back-to-login link", async () => {
43
+ renderWithProviders(<RequestAccountUnlockScreen loginHref="/signin" />);
44
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
45
+ target: { value: "user@example.com" },
46
+ });
47
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
48
+
49
+ await waitFor(() => {
50
+ expect(screen.getByText("Mail gesendet")).toBeTruthy();
51
+ });
52
+ expect(screen.getByRole("link", { name: /Zurück zum Login/i }).getAttribute("href")).toBe(
53
+ "/signin",
54
+ );
55
+ });
56
+
57
+ test("429 with retryAfterSeconds → accountLockedRetry banner", async () => {
58
+ globalThis.fetch = mock(
59
+ async () =>
60
+ new Response(JSON.stringify({ error: { details: { retryAfterSeconds: 180 } } }), {
61
+ status: 429,
62
+ }),
63
+ ) as unknown as typeof fetch;
64
+
65
+ renderWithProviders(<RequestAccountUnlockScreen />);
66
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
67
+ target: { value: "user@example.com" },
68
+ });
69
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
70
+
71
+ await waitFor(() => {
72
+ expect(screen.getByRole("alert").textContent).toContain("3 Minuten");
73
+ });
74
+ expect(screen.queryByText("Mail gesendet")).toBeNull();
75
+ });
76
+
77
+ test("429 without retryAfterSeconds → generic rateLimited banner", async () => {
78
+ globalThis.fetch = mock(
79
+ async () => new Response(null, { status: 429 }),
80
+ ) as unknown as typeof fetch;
81
+
82
+ renderWithProviders(<RequestAccountUnlockScreen />);
83
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
84
+ target: { value: "user@example.com" },
85
+ });
86
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
87
+
88
+ await waitFor(() => {
89
+ expect(screen.getByRole("alert").textContent).toContain("Zu viele Login-Versuche");
90
+ });
91
+ });
92
+
93
+ test("server 5xx → unknownError banner, stays on form", async () => {
94
+ globalThis.fetch = mock(
95
+ async () => new Response(null, { status: 500 }),
96
+ ) as unknown as typeof fetch;
97
+
98
+ renderWithProviders(<RequestAccountUnlockScreen />);
99
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
100
+ target: { value: "user@example.com" },
101
+ });
102
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
103
+
104
+ await waitFor(() => {
105
+ expect(screen.getByRole("alert").textContent).toContain("schief");
106
+ });
107
+ expect(screen.queryByText("Mail gesendet")).toBeNull();
108
+ expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
109
+ });
110
+
111
+ test("fetch throw → unknownError banner", async () => {
112
+ globalThis.fetch = mock(async () => {
113
+ throw new Error("network down");
114
+ }) as unknown as typeof fetch;
115
+
116
+ renderWithProviders(<RequestAccountUnlockScreen />);
117
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
118
+ target: { value: "user@example.com" },
119
+ });
120
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
121
+
122
+ await waitFor(() => {
123
+ expect(screen.getByRole("alert").textContent).toContain("schief");
124
+ });
125
+ });
126
+
127
+ test("custom title prop overrides default", () => {
128
+ renderWithProviders(<RequestAccountUnlockScreen title="Custom Unlock" />);
129
+ expect(screen.getByText("Custom Unlock")).toBeTruthy();
130
+ });
131
+ });
@@ -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 } };
@@ -65,7 +65,8 @@ export type LoginScreenProps = {
65
65
  readonly onMfaChallenge?: (challengeToken: string) => void;
66
66
  /** Called when the tenant's enforcement policy requires MFA but this
67
67
  * user has no factor enrolled yet. Apps typically route to an
68
- * enrollment flow. Same fallback behavior as onMfaChallenge when unset. */
68
+ * enrollment flow. Without a handler, the user sees a "setup required,
69
+ * contact your administrator" error. */
69
70
  readonly onMfaSetupRequired?: () => void;
70
71
  };
71
72
 
@@ -97,6 +98,8 @@ function reasonToKey(failure: LoginFailure): {
97
98
  return { key: "auth.errors.invalidBody" };
98
99
  case "mfa_not_supported":
99
100
  return { key: "auth.errors.mfaNotSupported" };
101
+ case "mfa_setup_required":
102
+ return { key: "auth.errors.mfaSetupRequired" };
100
103
  default:
101
104
  return { key: "auth.errors.loginFailed" };
102
105
  }
@@ -147,7 +150,7 @@ export function LoginScreen({
147
150
  onMfaSetupRequired();
148
151
  return;
149
152
  }
150
- setError({ reason: "mfa_not_supported" });
153
+ setError({ reason: "mfa_setup_required" });
151
154
  setFailedLoginEmail(email);
152
155
  return;
153
156
  }
@@ -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
+ });