@cosmicdrift/kumiko-bundled-features 0.304.0 → 0.306.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 (75) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +135 -0
  3. package/src/__tests__/use-extension-options.test.ts +278 -0
  4. package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
  5. package/src/auth-email-password/changes.json +7 -0
  6. package/src/auth-email-password/i18n.ts +7 -0
  7. package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
  8. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
  9. package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
  10. package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
  11. package/src/auth-email-password/web/auth-client.ts +34 -2
  12. package/src/auth-email-password/web/auth-gate.tsx +5 -1
  13. package/src/auth-email-password/web/index.ts +4 -1
  14. package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
  15. package/src/auth-email-password/web/session.tsx +32 -3
  16. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  17. package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
  18. package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
  19. package/src/auth-foundation/types.ts +14 -4
  20. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
  21. package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
  22. package/src/billing-foundation/changes.json +6 -0
  23. package/src/billing-foundation/feature.ts +8 -1
  24. package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
  25. package/src/billing-foundation/types.ts +13 -1
  26. package/src/channel-email/feature.ts +2 -1
  27. package/src/channel-in-app/feature.ts +2 -1
  28. package/src/channel-push/feature.ts +2 -1
  29. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
  30. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
  31. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
  32. package/src/config/__tests__/require-system-db.test.ts +3 -3
  33. package/src/config/handlers/reencrypt.job.ts +4 -8
  34. package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
  35. package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
  36. package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
  37. package/src/delivery/constants.ts +4 -0
  38. package/src/delivery/delivery-service.ts +45 -29
  39. package/src/delivery/feature.ts +2 -1
  40. package/src/delivery/index.ts +14 -11
  41. package/src/delivery/types.ts +24 -0
  42. package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
  43. package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
  44. package/src/document-ingest-foundation/changes.json +5 -0
  45. package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
  46. package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
  47. package/src/files/__tests__/files.integration.test.ts +2 -2
  48. package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
  49. package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
  50. package/src/inbound-mail-foundation/changes.json +6 -0
  51. package/src/inbound-mail-foundation/feature.ts +4 -0
  52. package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
  53. package/src/inbound-mail-foundation/types.ts +10 -3
  54. package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
  55. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
  56. package/src/mail-foundation/feature.ts +15 -4
  57. package/src/mail-foundation/index.ts +1 -0
  58. package/src/mail-transport-inmemory/feature.ts +5 -4
  59. package/src/mail-transport-smtp/feature.ts +5 -4
  60. package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
  61. package/src/renderer-foundation/changes.json +8 -1
  62. package/src/renderer-foundation/constants.ts +4 -0
  63. package/src/renderer-foundation/feature.ts +10 -13
  64. package/src/renderer-foundation/index.ts +3 -0
  65. package/src/renderer-foundation/types.ts +23 -1
  66. package/src/renderer-simple/feature.ts +2 -1
  67. package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
  68. package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
  69. package/src/tenant-lifecycle/stages.ts +20 -18
  70. package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
  71. package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
  72. package/src/user-data-rights/lib/default-mailers.ts +2 -1
  73. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
  74. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
  75. package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
@@ -1,7 +1,15 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import { fireEvent, screen, waitFor } from "@testing-library/react";
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
9
  import type { ReactNode } from "react";
10
+ import { defaultTranslations } from "../../i18n";
4
11
  import { createLoginRoute, makeAuthGate } from "../auth-gate";
12
+ import { SessionProvider } from "../session";
5
13
  import { makeSessionApi, renderWithProviders } from "./test-utils";
6
14
 
7
15
  describe("makeAuthGate", () => {
@@ -236,3 +244,117 @@ describe("createLoginRoute", () => {
236
244
  );
237
245
  });
238
246
  });
247
+
248
+ // Real SessionProvider (no mocked SessionContext) — proves the actual
249
+ // bootstrap-failure wiring end to end: hasLikelyAuthSession → fetchTenants
250
+ // throws → doRefresh catches → "error" status → SessionBootstrapErrorScreen.
251
+ // Uses makeAuthGate + SessionProvider directly instead of makeSessionAuthGate:
252
+ // client-plugin.test.tsx mocks makeSessionAuthGate process-wide via bun:test's
253
+ // shared module registry, which would silently no-op it here too.
254
+ describe("makeSessionAuthGate — session bootstrap failure", () => {
255
+ const resolver = createStaticLocaleResolver({ locale: "en" });
256
+ const originalFetch = globalThis.fetch;
257
+
258
+ function renderRealSession(ui: ReactNode) {
259
+ return render(
260
+ <PrimitivesProvider value={defaultPrimitives}>
261
+ <LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
262
+ <SessionProvider>{ui}</SessionProvider>
263
+ </LocaleProvider>
264
+ </PrimitivesProvider>,
265
+ );
266
+ }
267
+
268
+ beforeEach(() => {
269
+ document.cookie = "kumiko_csrf=gate-test-token";
270
+ });
271
+
272
+ afterEach(() => {
273
+ document.cookie = "kumiko_csrf=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
274
+ globalThis.fetch = originalFetch;
275
+ });
276
+
277
+ test("429 on GET /auth/tenants → error screen with data-http-status=429, children not rendered; retry recovers", async () => {
278
+ let tenantsCalls = 0;
279
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
280
+ const url = typeof input === "string" ? input : input.toString();
281
+ if (url === "/api/auth/tenants") {
282
+ tenantsCalls++;
283
+ if (tenantsCalls === 1) return new Response(null, { status: 429 });
284
+ return new Response(JSON.stringify({ tenants: [], activeTenantId: "t1" }), {
285
+ status: 200,
286
+ });
287
+ }
288
+ if (url === "/api/query") {
289
+ return new Response(
290
+ JSON.stringify({
291
+ data: { id: "u1", email: "u@example.com", displayName: "U", roles: "[]" },
292
+ }),
293
+ { status: 200 },
294
+ );
295
+ }
296
+ return new Response(null, { status: 404 });
297
+ }) as unknown as typeof fetch;
298
+
299
+ const Gate = makeAuthGate();
300
+ renderRealSession(
301
+ <Gate>
302
+ <div data-testid="protected">secret</div>
303
+ </Gate>,
304
+ );
305
+
306
+ const errorScreen = await waitFor(() => screen.getByTestId("session-bootstrap-error"));
307
+ expect(errorScreen.getAttribute("data-http-status")).toBe("429");
308
+ expect(screen.queryByTestId("protected")).toBeNull();
309
+
310
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
311
+
312
+ await waitFor(() => {
313
+ expect(screen.getByTestId("protected")).toBeTruthy();
314
+ });
315
+ expect(tenantsCalls).toBe(2);
316
+ });
317
+
318
+ test("500 on POST /api/query (user:me) → error screen with data-http-status=500, children not rendered", async () => {
319
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
320
+ const url = typeof input === "string" ? input : input.toString();
321
+ if (url === "/api/auth/tenants") {
322
+ return new Response(JSON.stringify({ tenants: [], activeTenantId: "t1" }), {
323
+ status: 200,
324
+ });
325
+ }
326
+ if (url === "/api/query") {
327
+ return new Response(null, { status: 500 });
328
+ }
329
+ return new Response(null, { status: 404 });
330
+ }) as unknown as typeof fetch;
331
+
332
+ const Gate = makeAuthGate();
333
+ renderRealSession(
334
+ <Gate>
335
+ <div data-testid="protected">secret</div>
336
+ </Gate>,
337
+ );
338
+
339
+ const errorScreen = await waitFor(() => screen.getByTestId("session-bootstrap-error"));
340
+ expect(errorScreen.getAttribute("data-http-status")).toBe("500");
341
+ expect(screen.queryByTestId("protected")).toBeNull();
342
+ });
343
+
344
+ test("network failure on GET /auth/tenants → error screen with data-http-status=network", async () => {
345
+ globalThis.fetch = mock(async () => {
346
+ throw new TypeError("Failed to fetch");
347
+ }) as unknown as typeof fetch;
348
+
349
+ const Gate = makeAuthGate();
350
+ renderRealSession(
351
+ <Gate>
352
+ <div data-testid="protected">secret</div>
353
+ </Gate>,
354
+ );
355
+
356
+ const errorScreen = await waitFor(() => screen.getByTestId("session-bootstrap-error"));
357
+ expect(errorScreen.getAttribute("data-http-status")).toBe("network");
358
+ expect(screen.queryByTestId("protected")).toBeNull();
359
+ });
360
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SessionBootstrapFailure } from "../session";
3
+ import { retryDelayMs } from "../session-bootstrap-error";
4
+
5
+ describe("retryDelayMs", () => {
6
+ test("Retry-After not yet elapsed → remaining delay in ms", () => {
7
+ const failure: SessionBootstrapFailure = {
8
+ httpStatus: 429,
9
+ retryAfterSeconds: 11,
10
+ failedAtEpochMs: 1000,
11
+ };
12
+ expect(retryDelayMs(failure, 3000)).toBe(9000);
13
+ });
14
+
15
+ test("Retry-After already elapsed → 0, never negative", () => {
16
+ const failure: SessionBootstrapFailure = {
17
+ httpStatus: 429,
18
+ retryAfterSeconds: 11,
19
+ failedAtEpochMs: 1000,
20
+ };
21
+ expect(retryDelayMs(failure, 20_000)).toBe(0);
22
+ });
23
+
24
+ test("no retryAfterSeconds → 0 (retry immediately)", () => {
25
+ const failure: SessionBootstrapFailure = {
26
+ httpStatus: 500,
27
+ retryAfterSeconds: null,
28
+ failedAtEpochMs: 1000,
29
+ };
30
+ expect(retryDelayMs(failure, 1000)).toBe(0);
31
+ });
32
+ });
@@ -41,6 +41,7 @@ export function makeSessionApi(overrides: MakeSessionApiOptions = {}): SessionAp
41
41
  activeTenantId: "tenant-1",
42
42
  tenants: [{ tenantId: "tenant-1", roles: ["Admin"] }],
43
43
  roles: ["Admin"],
44
+ bootstrapFailure: null,
44
45
  ...stateOverrides,
45
46
  };
46
47
  return {
@@ -62,6 +62,26 @@ export function csrfHeader(): Record<string, string> {
62
62
  return token !== undefined ? { [CSRF_HEADER_NAME]: token } : {};
63
63
  }
64
64
 
65
+ // Thrown by fetchTenants/fetchCurrentUser on a non-401 error status, distinguishable from a plain network failure by its status field.
66
+ export class AuthRequestError extends Error {
67
+ readonly status: number;
68
+ readonly retryAfterSeconds: number | undefined;
69
+
70
+ constructor(message: string, status: number, retryAfterSeconds: number | undefined) {
71
+ super(message);
72
+ this.name = "AuthRequestError";
73
+ this.status = status;
74
+ this.retryAfterSeconds = retryAfterSeconds;
75
+ }
76
+ }
77
+
78
+ // Only a non-negative integer second count is usable; anything else is treated as absent.
79
+ export function parseRetryAfterSeconds(header: string | null): number | undefined {
80
+ if (header === null) return undefined;
81
+ if (!/^\d+$/.test(header)) return undefined;
82
+ return Number(header);
83
+ }
84
+
65
85
  // LoginResult mirrors the two-step login contract auth-routes.ts owns: a
66
86
  // straight success, an MFA challenge (auth-mfa mints the token), an
67
87
  // MFA-setup-required block (enforcement policy blocks an unenrolled user),
@@ -344,7 +364,13 @@ export async function fetchTenants(): Promise<{
344
364
  credentials: "same-origin",
345
365
  });
346
366
  if (res.status === 401) return null;
347
- if (!res.ok) throw new Error(`auth/tenants failed: ${res.status}`);
367
+ if (!res.ok) {
368
+ throw new AuthRequestError(
369
+ `auth/tenants failed: ${res.status}`,
370
+ res.status,
371
+ parseRetryAfterSeconds(res.headers.get("Retry-After")),
372
+ );
373
+ }
348
374
  // @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
349
375
  return (await res.json()) as {
350
376
  tenants: readonly TenantSummary[];
@@ -392,7 +418,13 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
392
418
  body: JSON.stringify({ type: "user:query:user:me", payload: {} }),
393
419
  });
394
420
  if (res.status === 401) return null;
395
- if (!res.ok) throw new Error(`user:me failed: ${res.status}`);
421
+ if (!res.ok) {
422
+ throw new AuthRequestError(
423
+ `user:me failed: ${res.status}`,
424
+ res.status,
425
+ parseRetryAfterSeconds(res.headers.get("Retry-After")),
426
+ );
427
+ }
396
428
  // @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
397
429
  const body = (await res.json()) as {
398
430
  data?: {
@@ -12,6 +12,7 @@
12
12
  import { type ComponentType, type ReactNode, useEffect, useState } from "react";
13
13
  import { LoginScreen, type LoginScreenProps } from "./login-screen";
14
14
  import { SessionProvider, useSession } from "./session";
15
+ import { SessionBootstrapErrorScreen } from "./session-bootstrap-error";
15
16
 
16
17
  // Generic — NOT auth-mfa's MfaVerifyScreenProps directly, so this feature
17
18
  // stays unaware of auth-mfa's concrete shape (same coupling direction as
@@ -65,7 +66,7 @@ export function createLoginRoute(
65
66
  const MfaSetupComponent = opts.mfaSetupScreen;
66
67
 
67
68
  function LoginRoute(): ReactNode {
68
- const { status, refresh } = useSession();
69
+ const { status, refresh, bootstrapFailure } = useSession();
69
70
  const { onAuthenticated } = opts;
70
71
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
71
72
  // (not in SessionState) because it's a UI-only transition — the server
@@ -86,6 +87,9 @@ export function createLoginRoute(
86
87
  <div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm" />
87
88
  );
88
89
  }
90
+ if (status === "error" && bootstrapFailure !== null) {
91
+ return <SessionBootstrapErrorScreen failure={bootstrapFailure} onRetry={refresh} />;
92
+ }
89
93
  // A standalone mount (no parent gate, no onAuthenticated wired — e.g. an
90
94
  // apex/marketing surface that just places <LoginRoute /> directly) has no
91
95
  // way to navigate away once authenticated. Blanking the page there is a
@@ -18,6 +18,7 @@ export type {
18
18
  VerifyEmailFailure,
19
19
  } from "./auth-client";
20
20
  export {
21
+ AuthRequestError,
21
22
  confirmAccountUnlock,
22
23
  confirmSignup,
23
24
  requestAccountUnlock,
@@ -54,8 +55,10 @@ export type { RequestAccountUnlockScreenProps } from "./request-account-unlock-s
54
55
  export { RequestAccountUnlockScreen } from "./request-account-unlock-screen";
55
56
  export type { ResetPasswordScreenProps } from "./reset-password-screen";
56
57
  export { ResetPasswordScreen } from "./reset-password-screen";
57
- export type { SessionApi, SessionState, SessionStatus } from "./session";
58
+ export type { SessionApi, SessionBootstrapFailure, SessionState, SessionStatus } from "./session";
58
59
  export { hasLikelyAuthSession, SessionContext, SessionProvider, useSession } from "./session";
60
+ export type { SessionBootstrapErrorScreenProps } from "./session-bootstrap-error";
61
+ export { retryDelayMs, SessionBootstrapErrorScreen } from "./session-bootstrap-error";
59
62
  export type { SignupCompleteScreenProps } from "./signup-complete-screen";
60
63
  export { SignupCompleteScreen } from "./signup-complete-screen";
61
64
  export type { SignupScreenProps } from "./signup-screen";
@@ -0,0 +1,56 @@
1
+ // @runtime client
2
+ // Rendered by auth-gate's LoginRoute when the session bootstrap failed for a reason other than "not logged in".
3
+
4
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import { type ReactNode, useState } from "react";
6
+ import { AuthCard } from "./auth-form-primitives";
7
+ import type { SessionBootstrapFailure } from "./session";
8
+
9
+ export type SessionBootstrapErrorScreenProps = {
10
+ readonly failure: SessionBootstrapFailure;
11
+ readonly onRetry: () => Promise<void>;
12
+ };
13
+
14
+ /** Retry delay measured from failure time, not click time. */
15
+ export function retryDelayMs(failure: SessionBootstrapFailure, nowEpochMs: number): number {
16
+ const retryAfterMs = (failure.retryAfterSeconds ?? 0) * 1000;
17
+ return Math.max(0, failure.failedAtEpochMs + retryAfterMs - nowEpochMs);
18
+ }
19
+
20
+ function wait(ms: number): Promise<void> {
21
+ if (ms <= 0) return Promise.resolve();
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+
25
+ export function SessionBootstrapErrorScreen({
26
+ failure,
27
+ onRetry,
28
+ }: SessionBootstrapErrorScreenProps): ReactNode {
29
+ const t = useTranslation();
30
+ const { Banner, Button } = usePrimitives();
31
+ const [retrying, setRetrying] = useState(false);
32
+
33
+ const handleRetry = (): void => {
34
+ setRetrying(true);
35
+ void wait(retryDelayMs(failure, Date.now()))
36
+ .then(onRetry)
37
+ .then(() => setRetrying(false));
38
+ };
39
+
40
+ return (
41
+ <div data-testid="session-bootstrap-error" data-http-status={failure.httpStatus ?? "network"}>
42
+ <AuthCard title={t("auth.sessionBootstrap.errorTitle")}>
43
+ <div className="flex flex-col gap-4 px-6 pb-4">
44
+ <Banner variant="error">
45
+ {failure.httpStatus === 429
46
+ ? t("auth.sessionBootstrap.rateLimitedBody")
47
+ : t("auth.sessionBootstrap.errorBody")}
48
+ </Banner>
49
+ <Button variant="primary" onClick={handleRetry} disabled={retrying}>
50
+ {retrying ? t("auth.sessionBootstrap.retrying") : t("auth.sessionBootstrap.retry")}
51
+ </Button>
52
+ </div>
53
+ </AuthCard>
54
+ </div>
55
+ );
56
+ }
@@ -12,6 +12,7 @@
12
12
  import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
13
13
  import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
14
14
  import {
15
+ AuthRequestError,
15
16
  type CurrentUserProfile,
16
17
  fetchCurrentUser,
17
18
  fetchTenants,
@@ -23,7 +24,14 @@ import {
23
24
  type TenantSummary,
24
25
  } from "./auth-client";
25
26
 
26
- export type SessionStatus = "loading" | "unauthenticated" | "authenticated";
27
+ export type SessionStatus = "loading" | "unauthenticated" | "authenticated" | "error";
28
+
29
+ // httpStatus null means the fetch itself threw (offline/DNS/CORS), not that the server answered.
30
+ export type SessionBootstrapFailure = {
31
+ readonly httpStatus: number | null;
32
+ readonly retryAfterSeconds: number | null;
33
+ readonly failedAtEpochMs: number;
34
+ };
27
35
 
28
36
  export type SessionState = {
29
37
  readonly status: SessionStatus;
@@ -36,6 +44,7 @@ export type SessionState = {
36
44
  * Client computed dieselbe merge-Logik aus user.globalRoles +
37
45
  * tenants[active].roles damit nav-filtering greift. Dedupliziert. */
38
46
  readonly roles: readonly string[];
47
+ readonly bootstrapFailure: SessionBootstrapFailure | null;
39
48
  };
40
49
 
41
50
  export type SessionApi = SessionState & {
@@ -58,6 +67,7 @@ export const UNAUTHENTICATED: SessionState = {
58
67
  activeTenantId: null,
59
68
  tenants: [],
60
69
  roles: [],
70
+ bootstrapFailure: null,
61
71
  };
62
72
 
63
73
  const INITIAL: SessionState = {
@@ -66,6 +76,7 @@ const INITIAL: SessionState = {
66
76
  activeTenantId: null,
67
77
  tenants: [],
68
78
  roles: [],
79
+ bootstrapFailure: null,
69
80
  };
70
81
 
71
82
  // kumiko_auth ist HttpOnly — kumiko_csrf wird beim Login gemeinsam gesetzt.
@@ -117,15 +128,33 @@ async function refresh(): Promise<SessionState> {
117
128
  activeTenantId: tenants.activeTenantId,
118
129
  tenants: tenants.tenants,
119
130
  roles: computeActiveRoles(user, tenants.activeTenantId, tenants.tenants),
131
+ bootstrapFailure: null,
120
132
  };
121
133
  }
122
134
 
135
+ // Non-AuthRequestError throws (offline, DNS, CORS) have no status, so httpStatus stays null.
136
+ function toBootstrapFailure(err: unknown): SessionBootstrapFailure {
137
+ if (err instanceof AuthRequestError) {
138
+ return {
139
+ httpStatus: err.status,
140
+ retryAfterSeconds: err.retryAfterSeconds ?? null,
141
+ failedAtEpochMs: Date.now(),
142
+ };
143
+ }
144
+ return { httpStatus: null, retryAfterSeconds: null, failedAtEpochMs: Date.now() };
145
+ }
146
+
123
147
  export function SessionProvider({ children }: { readonly children: ReactNode }): ReactNode {
124
148
  const [state, setState] = useState<SessionState>(INITIAL);
125
149
 
150
+ // Never rejects, so bootstrap can't strand the UI on "loading" forever.
126
151
  const doRefresh = useCallback(async () => {
127
- const next = await refresh();
128
- setState(next);
152
+ try {
153
+ const next = await refresh();
154
+ setState(next);
155
+ } catch (err) {
156
+ setState({ ...UNAUTHENTICATED, status: "error", bootstrapFailure: toBootstrapFailure(err) });
157
+ }
129
158
  }, []);
130
159
 
131
160
  // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
@@ -67,6 +67,7 @@ describe("validateSessionStoreMultiplicity", () => {
67
67
 
68
68
  test("malformed registration (no build) → throws a malformed-plugin error", () => {
69
69
  const broken = defineFeature("mock-broken", (r) => {
70
+ // @ts-expect-error malformed plugin (no build) — proves the runtime guard rejects it too.
70
71
  r.useExtension(EXT_SESSION_STORE, "broken", {});
71
72
  });
72
73
  const registry = createRegistry([authFoundationFeature, broken]);
@@ -92,6 +92,7 @@ describe("validateTenantResolverMultiplicity", () => {
92
92
 
93
93
  test("malformed (no trust) → throws", () => {
94
94
  const broken = defineFeature("mock-broken-resolver", (r) => {
95
+ // @ts-expect-error malformed plugin (no trust) — proves the runtime guard rejects it too.
95
96
  r.useExtension(EXT_TENANT_RESOLVER, "broken", { build: () => () => null });
96
97
  });
97
98
  const registry = createRegistry([authFoundationFeature, broken]);
@@ -127,6 +127,7 @@ describe("validateTokenVerifierMultiplicity", () => {
127
127
 
128
128
  test("wrong-shape registration → throws a malformed-plugin error", () => {
129
129
  const broken = defineFeature("mock-broken", (r) => {
130
+ // @ts-expect-error malformed plugin (no build) — proves the runtime guard rejects it too.
130
131
  r.useExtension(EXT_TOKEN_VERIFIER, "broken", { shape: { kind: "prefix" } });
131
132
  });
132
133
  const registry = createRegistry([authFoundationFeature, broken]);
@@ -138,6 +139,7 @@ describe("validateTokenVerifierMultiplicity", () => {
138
139
  test("malformed shape (build present, prefix missing) → throws a malformed-plugin error", () => {
139
140
  const broken = defineFeature("mock-broken-shape", (r) => {
140
141
  r.useExtension(EXT_TOKEN_VERIFIER, "broken-shape", {
142
+ // @ts-expect-error malformed shape (prefix missing) — proves the runtime guard rejects it too.
141
143
  shape: { kind: "prefix" },
142
144
  build: () => async () => null,
143
145
  });
@@ -22,7 +22,7 @@ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
22
22
 
23
23
  export type { SessionMassRevoker };
24
24
 
25
- export const EXT_TOKEN_VERIFIER = "tokenVerifier";
25
+ export const EXT_TOKEN_VERIFIER = "tokenVerifier" as const;
26
26
 
27
27
  /**
28
28
  * Deps available to a provider's `build()`. DB access only — a hot-path
@@ -84,7 +84,7 @@ export function tokenShapeKey(shape: TokenShape): string {
84
84
  // single-provider — sessions have no per-provider shape to route on, so
85
85
  // exactly one implementation must be registered (boot-fails on 0 or ≥2).
86
86
 
87
- export const EXT_SESSION_STORE = "sessionStore";
87
+ export const EXT_SESSION_STORE = "sessionStore" as const;
88
88
 
89
89
  /** Keep current sid (if any); revoke every other live session. Used by auth-mfa. */
90
90
  export type SessionRevokeAllOthers = (
@@ -117,8 +117,8 @@ export function isSessionStoreProvider(o: unknown): o is SessionStoreProvider {
117
117
  // optional — zero registered means today's single-tenant / header-cookie
118
118
  // path (no subdomain resolver, no existence check). ≥2 fails boot.
119
119
 
120
- export const EXT_TENANT_RESOLVER = "tenantResolver";
121
- export const EXT_TENANT_EXISTENCE = "tenantExistence";
120
+ export const EXT_TENANT_RESOLVER = "tenantResolver" as const;
121
+ export const EXT_TENANT_EXISTENCE = "tenantExistence" as const;
122
122
 
123
123
  /** Trust mode for a registered tenantResolver — mirrors AnonymousAccessConfig.resolverTrust. */
124
124
  export type TenantResolverTrust = "authoritative" | "fallback-only";
@@ -168,3 +168,13 @@ export function isTenantExistenceProvider(o: unknown): o is TenantExistenceProvi
168
168
  typeof (o as { build: unknown }).build === "function"
169
169
  );
170
170
  }
171
+
172
+ // r.useExtension options-shape for auth-foundation's four extension points.
173
+ declare module "@cosmicdrift/kumiko-framework/engine" {
174
+ interface KumikoExtensionOptionsMap {
175
+ [EXT_TOKEN_VERIFIER]: AuthProviderPlugin;
176
+ [EXT_SESSION_STORE]: SessionStoreProvider;
177
+ [EXT_TENANT_RESOLVER]: TenantResolverProvider;
178
+ [EXT_TENANT_EXISTENCE]: TenantExistenceProvider;
179
+ }
180
+ }
@@ -23,7 +23,7 @@ import {
23
23
  } from "@cosmicdrift/kumiko-framework/crypto";
24
24
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
25
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
26
- import { isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
27
27
  import {
28
28
  createTestUser,
29
29
  setupTestStack,
@@ -33,7 +33,6 @@ import {
33
33
  } from "@cosmicdrift/kumiko-framework/stack";
34
34
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
35
35
  import {
36
- ComplianceProfileHandlers,
37
36
  createComplianceProfilesFeature,
38
37
  tenantComplianceProfileEntity,
39
38
  } from "../../compliance-profiles";
@@ -50,7 +49,6 @@ import {
50
49
  } from "../constants";
51
50
  import { billingFoundationFeature } from "../feature";
52
51
  import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
53
- import { subscriptionTenantDestroyHook } from "../tenant-destroy-hook";
54
52
  import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
55
53
  import { createSubscriptionWebhookRoute } from "../webhook-handler";
56
54
 
@@ -669,66 +667,9 @@ describe("scenario 8: cancel-event setzt status auf canceled, behält subscripti
669
667
  });
670
668
  });
671
669
 
672
- describe("scenario 9: subscriptionTenantDestroyHook (#800 tenant-destroy PII erasure)", () => {
673
- test("default profile → row hard-deleted, stream archived", async () => {
674
- const admin = adminFor(3009);
675
- await stack.http.writeOk(
676
- SubscriptionFoundationHandlers.processEvent,
677
- buildEvent({
678
- providerEventId: "evt_3009_create",
679
- providerCustomerId: "cus_3009",
680
- providerSubscriptionId: "sub_3009",
681
- }),
682
- admin,
683
- );
684
-
685
- await subscriptionTenantDestroyHook({ db, tenantId: admin.tenantId });
686
-
687
- const rows = await selectMany(db, subscriptionsProjectionTable, {
688
- id: subscriptionAggregateId(admin.tenantId),
689
- });
690
- expect(rows).toHaveLength(0);
691
- expect(
692
- await isStreamArchived(db, admin.tenantId, subscriptionAggregateId(admin.tenantId)),
693
- ).toBe(true);
694
- });
695
-
696
- test("HGB profile → PII redacted, accounting fields + row survive, stream archived", async () => {
697
- const admin = adminFor(3010);
698
- await stack.http.writeOk(
699
- ComplianceProfileHandlers.setProfile,
700
- { profileKey: "de-hr-dsgvo-hgb" },
701
- admin,
702
- );
703
- await stack.http.writeOk(
704
- SubscriptionFoundationHandlers.processEvent,
705
- buildEvent({
706
- providerEventId: "evt_3010_create",
707
- providerCustomerId: "cus_3010",
708
- providerSubscriptionId: "sub_3010",
709
- tier: "pro",
710
- }),
711
- admin,
712
- );
713
-
714
- await subscriptionTenantDestroyHook({ db, tenantId: admin.tenantId });
715
-
716
- const rows = await selectMany<{
717
- providerCustomerId: string;
718
- providerSubscriptionId: string;
719
- tier: string;
720
- status: string;
721
- }>(db, subscriptionsProjectionTable, { id: subscriptionAggregateId(admin.tenantId) });
722
- expect(rows).toHaveLength(1);
723
- expect(rows[0]?.providerCustomerId).toBe("[erased]");
724
- expect(rows[0]?.providerSubscriptionId).toBe("[erased]");
725
- // accounting facts survive — that's the point of the HGB retention branch
726
- expect(rows[0]?.tier).toBe("pro");
727
- expect(
728
- await isStreamArchived(db, admin.tenantId, subscriptionAggregateId(admin.tenantId)),
729
- ).toBe(true);
730
- });
731
- });
670
+ // scenario 9 (subscriptionTenantDestroyHook direct-call, default+HGB branches) removed:
671
+ // covered end-to-end by billing-foundation/__tests__/tenant-destroy.integration.test.ts,
672
+ // which drives the real tenant-lifecycle sweep instead of hand-feeding the hook.
732
673
 
733
674
  describe("scenario 10: PII is encrypted at rest, not just erasable on destroy", () => {
734
675
  test("raw event-log payload and raw projection row both hold ciphertext, not the plaintext provider id", async () => {