@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.
- package/package.json +9 -9
- package/src/__tests__/extension-points-typed.test.ts +135 -0
- package/src/__tests__/use-extension-options.test.ts +278 -0
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/i18n.ts +7 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
- package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
- package/src/auth-email-password/web/auth-client.ts +34 -2
- package/src/auth-email-password/web/auth-gate.tsx +5 -1
- package/src/auth-email-password/web/index.ts +4 -1
- package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
- package/src/auth-email-password/web/session.tsx +32 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
- package/src/auth-foundation/types.ts +14 -4
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
- package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +8 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
- package/src/billing-foundation/types.ts +13 -1
- package/src/channel-email/feature.ts +2 -1
- package/src/channel-in-app/feature.ts +2 -1
- package/src/channel-push/feature.ts +2 -1
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
- package/src/config/__tests__/require-system-db.test.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +4 -8
- package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
- package/src/delivery/constants.ts +4 -0
- package/src/delivery/delivery-service.ts +45 -29
- package/src/delivery/feature.ts +2 -1
- package/src/delivery/index.ts +14 -11
- package/src/delivery/types.ts +24 -0
- package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
- package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
- package/src/document-ingest-foundation/changes.json +5 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
- package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/feature.ts +4 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
- package/src/inbound-mail-foundation/types.ts +10 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
- package/src/mail-foundation/feature.ts +15 -4
- package/src/mail-foundation/index.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +5 -4
- package/src/mail-transport-smtp/feature.ts +5 -4
- package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
- package/src/renderer-foundation/changes.json +8 -1
- package/src/renderer-foundation/constants.ts +4 -0
- package/src/renderer-foundation/feature.ts +10 -13
- package/src/renderer-foundation/index.ts +3 -0
- package/src/renderer-foundation/types.ts +23 -1
- package/src/renderer-simple/feature.ts +2 -1
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
- package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
- package/src/tenant-lifecycle/stages.ts +20 -18
- package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
- package/src/user-data-rights/lib/default-mailers.ts +2 -1
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
- 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 {
|
|
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
|
+
});
|
|
@@ -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)
|
|
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)
|
|
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
|
-
|
|
128
|
-
|
|
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 {
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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 () => {
|