@cosmicdrift/kumiko-bundled-features 0.304.0 → 0.305.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.304.0",
3
+ "version": "0.305.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.304.0",
137
- "@cosmicdrift/kumiko-framework": "0.304.0",
138
- "@cosmicdrift/kumiko-headless": "0.304.0",
139
- "@cosmicdrift/kumiko-renderer": "0.304.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.304.0",
141
- "@cosmicdrift/kumiko-types": "0.304.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.305.0",
137
+ "@cosmicdrift/kumiko-framework": "0.305.0",
138
+ "@cosmicdrift/kumiko-headless": "0.305.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.305.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.305.0",
141
+ "@cosmicdrift/kumiko-types": "0.305.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.304.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.304.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.305.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.305.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -0,0 +1,116 @@
1
+ // GET /api/auth/tenants is exempt from the L2 auth-endpoint rate limit;
2
+ // credential endpoints on the same /api/auth/* mount (e.g. login) keep the default limit.
3
+
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { randomBytes } from "node:crypto";
6
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ setupTestStack,
9
+ type TestStack,
10
+ TestUsers,
11
+ unsafeCreateEntityTable,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { createConfigFeature } from "../../config";
16
+ import { createConfigResolver } from "../../config/resolver";
17
+ import { configValuesTable } from "../../config/table";
18
+ import { createTenantFeature } from "../../tenant";
19
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
20
+ import { tenantEntity } from "../../tenant/schema/tenant";
21
+ import { seedTenantMembership } from "../../tenant/testing";
22
+ import { UserHandlers } from "../../user";
23
+ import { createUserFeature } from "../../user/feature";
24
+ import { userEntity } from "../../user/schema/user";
25
+ import { AuthHandlers } from "../constants";
26
+ import { createAuthEmailPasswordFeature } from "../feature";
27
+
28
+ let stack: TestStack;
29
+ const encryptionKey = randomBytes(32).toString("base64");
30
+ const membershipTenantId = "00000000-0000-4000-8000-000000000001";
31
+
32
+ beforeAll(async () => {
33
+ const encryption = createTestEnvelopeCipher(encryptionKey);
34
+ const resolver = createConfigResolver({ cipher: encryption });
35
+
36
+ stack = await setupTestStack({
37
+ features: [
38
+ createConfigFeature(),
39
+ createUserFeature(),
40
+ createTenantFeature(),
41
+ createAuthEmailPasswordFeature(),
42
+ ],
43
+ extraContext: { configResolver: resolver, configEncryption: encryption },
44
+ authConfig: {
45
+ membershipQuery: "tenant:query:memberships",
46
+ loginHandler: AuthHandlers.login,
47
+ },
48
+ // No `limit` override — this proves the real framework default (5/60s)
49
+ // exempts GET /api/auth/tenants and still throttles POST /api/auth/login.
50
+ rateLimit: {
51
+ auth: { onFailClosed: () => {} },
52
+ },
53
+ });
54
+
55
+ await unsafeCreateEntityTable(stack.db, userEntity);
56
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
57
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await stack.cleanup();
62
+ });
63
+
64
+ async function getTenants(token: string, ip: string): Promise<Response> {
65
+ return stack.app.request("/api/auth/tenants", {
66
+ method: "GET",
67
+ headers: { Authorization: `Bearer ${token}`, "x-forwarded-for": ip },
68
+ });
69
+ }
70
+
71
+ async function postLogin(ip: string, email: string): Promise<Response> {
72
+ return stack.app.request("/api/auth/login", {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json", "x-forwarded-for": ip },
75
+ body: JSON.stringify({ email, password: "wrong-password" }),
76
+ });
77
+ }
78
+
79
+ describe("GET /api/auth/tenants is exempt from the L2 auth rate limit", () => {
80
+ test("10 GETs from the same IP all succeed — the 6th would 429 without the exemption", async () => {
81
+ const created = await stack.http.writeOk<{ id: string }>(
82
+ UserHandlers.create,
83
+ { email: "tenants-exempt@example.com", passwordHash: "unused", displayName: "exempt-user" },
84
+ TestUsers.systemAdmin,
85
+ );
86
+ await seedTenantMembership(stack.db, {
87
+ userId: created.id,
88
+ tenantId: membershipTenantId,
89
+ roles: ["User"],
90
+ });
91
+ const user: SessionUser = { id: created.id, tenantId: membershipTenantId, roles: ["User"] };
92
+ const token = await stack.jwt.sign(user);
93
+ const ip = "10.70.0.1";
94
+
95
+ for (let i = 0; i < 10; i++) {
96
+ const res = await getTenants(token, ip);
97
+ expect(res.status).toBe(200);
98
+ }
99
+ const body = await (await getTenants(token, ip)).json();
100
+ expect(body.tenants.length).toBe(1);
101
+ });
102
+ });
103
+
104
+ describe("POST /api/auth/login keeps the default L2 limit (5/60s)", () => {
105
+ test("6th login attempt from the same IP is 429 via the L2 bucket, not just the route's own limiter", async () => {
106
+ const ip = "10.70.0.2";
107
+ for (let i = 0; i < 5; i++) {
108
+ await postLogin(ip, `nope-${i}@example.com`);
109
+ }
110
+ const blocked = await postLogin(ip, "nope-5@example.com");
111
+ expect(blocked.status).toBe(429);
112
+ const body = (await blocked.json()) as { error: { code: string; details: { bucket: string } } };
113
+ expect(body.error.code).toBe("rate_limited");
114
+ expect(body.error.details.bucket).toBe(`l2:${ip}:/api/auth/login`);
115
+ });
116
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.305.0",
4
+ "type": "breaking",
5
+ "title": "Session bootstrap failures now render a retryable error screen instead of hanging on \"loading\"",
6
+ "detail": "fetchTenants()/fetchCurrentUser() failures (rate limit, 5xx, network)\nused to reject out of the session bootstrap effect and leave the UI on\nthe loading placeholder forever. SessionStatus gained an \"error\" value\nand SessionState gained a bootstrapFailure field; the auth gate now\nrenders SessionBootstrapErrorScreen with a Retry-After-aware retry\nbutton for this case.",
7
+ "migration": "Code with an exhaustive switch over SessionStatus, or tests/stories that\nhand-build a SessionState literal, needs to add the \"error\" case and the\nbootstrapFailure field. refresh() no longer rejects on bootstrap\nfailures; it resolves and sets status to \"error\" instead."
8
+ },
2
9
  {
3
10
  "version": "0.300.0",
4
11
  "type": "breaking",
@@ -131,6 +131,13 @@ export const defaultTranslations: TranslationsByLocale = {
131
131
  "auth.inviteAccept.toggleExisting": "I already have an account",
132
132
  "auth.inviteAccept.missingToken": "The invitation link is missing or invalid.",
133
133
  "auth.inviteAccept.goToLogin": "Go to sign in",
134
+ "auth.sessionBootstrap.errorTitle": "Couldn't load your session",
135
+ "auth.sessionBootstrap.errorBody":
136
+ "The server could not be reached or returned an error. Please try again.",
137
+ "auth.sessionBootstrap.rateLimitedBody":
138
+ "Too many requests in a short time. Please wait a moment and try again.",
139
+ "auth.sessionBootstrap.retry": "Try again",
140
+ "auth.sessionBootstrap.retrying": "Retrying…",
134
141
  "auth.user.menu.label": "Account",
135
142
  "auth.user.menu.logout": "Sign out",
136
143
  "auth.tenant.switcher.label": "Tenant",
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { CSRF_HEADER_NAME } from "@cosmicdrift/kumiko-dispatcher-live";
3
3
  import {
4
+ AuthRequestError,
4
5
  confirmAccountUnlock,
5
6
  confirmSignup,
6
7
  csrfHeader,
@@ -390,6 +391,29 @@ describe("fetchTenants", () => {
390
391
 
391
392
  await expect(fetchTenants()).rejects.toThrow("auth/tenants failed: 500");
392
393
  });
394
+
395
+ test("429 with Retry-After → AuthRequestError carries status + retryAfterSeconds", async () => {
396
+ globalThis.fetch = mock(
397
+ async () => new Response(null, { status: 429, headers: { "Retry-After": "7" } }),
398
+ ) as unknown as typeof fetch;
399
+
400
+ const err = await fetchTenants().catch((e: unknown) => e);
401
+
402
+ expect(err).toBeInstanceOf(AuthRequestError);
403
+ expect((err as AuthRequestError).status).toBe(429);
404
+ expect((err as AuthRequestError).retryAfterSeconds).toBe(7);
405
+ });
406
+
407
+ test("non-ok without Retry-After header → retryAfterSeconds is undefined", async () => {
408
+ globalThis.fetch = mock(
409
+ async () => new Response(null, { status: 500 }),
410
+ ) as unknown as typeof fetch;
411
+
412
+ const err = await fetchTenants().catch((e: unknown) => e);
413
+
414
+ expect(err).toBeInstanceOf(AuthRequestError);
415
+ expect((err as AuthRequestError).retryAfterSeconds).toBeUndefined();
416
+ });
393
417
  });
394
418
 
395
419
  describe("switchTenant", () => {
@@ -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