diff --git a/.changeset/steady-react-sessions.md b/.changeset/steady-react-sessions.md
new file mode 100644
index 0000000..2205e7e
--- /dev/null
+++ b/.changeset/steady-react-sessions.md
@@ -0,0 +1,5 @@
+---
+"@devalexllc/interlock-auth": minor
+---
+
+Add the React session provider, contract-aware fetch and notification socket, catalog client, and Compose demo.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a4cc42..1e4cd4a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,7 +58,7 @@ jobs:
cd "$consumer"
npm init -y >/dev/null
npm pkg set type=module
- npm install --silent ./*.tgz
+ npm install --silent ./*.tgz react
for sub in "" "/next" "/react"; do
echo "import @devalexllc/interlock-auth$sub"
node --input-type=module -e "await import('@devalexllc/interlock-auth$sub')"
diff --git a/README.md b/README.md
index 9a57eda..9d06fbc 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Next.js and React applications use. One package, one session model, released on
| ----------- | ---------------------------------------------------------------------------------------------- | ----------------- |
| `.` | Better Auth options for the Keycloak provider, session-scoped grants, and claim normalization | Since 0.1.0 (3.5) |
| `./next` | The Next.js session behavior: session reads, the session route handler, provider and monitor | Empty until 8.1 |
-| `./react` | A session provider over `better-auth/react` and a fetch wrapper that applies the auth contract | Empty until 3.6 |
+| `./react` | A session provider over `better-auth/react` and a fetch wrapper that applies the auth contract | Implemented (3.6) |
The root entry stays free of React and Next.js, so a server or a test process can import it
without either.
@@ -197,3 +197,119 @@ npm-side organization name matching GitHub's canonical casing exactly.
## License
Apache License 2.0 — see [LICENSE](LICENSE). Copyright 2026 Devalex LLC.
+
+## The `./react` entry
+
+Install React 18.3 or 19 alongside the package. React is an optional peer so hosts using
+only the server entry do not install it. The browser entry imports no server authentication
+code and can also be imported in plain Node.
+
+```tsx
+import {
+ InterlockProvider,
+ useInterlockSession,
+ useInterlockFetch,
+ useCatalogClient,
+ useNotificationSocket,
+ useSocketEvent,
+} from "@devalexllc/interlock-auth/react";
+
+function Session() {
+ const { phase, user, signIn, signOut, retry } = useInterlockSession();
+ if (phase.kind === "loading") return
Loading…
;
+ if (phase.kind === "unavailable") return Retry ;
+ if (phase.kind !== "signed_in") return signIn()}>Sign in ;
+ return Sign out {user?.name} ;
+}
+
+// Clear application data synchronously here, before subscribers render signed out.
+ clearUserCache()}>
+
+ ;
+```
+
+Provider configuration is fixed at mount; remount to change it. An absolute `uri` selects
+that origin; omitted, empty, and relative values select the page origin. The auth and socket
+paths are always `/api/auth` and `/ws/irongate`.
+
+| Prop | Default | Purpose |
+| ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------- |
+| `uri` | Page origin | Interlock origin |
+| `callbackURL` | Page URL at sign-in time | Return URL for explicit and automatic login; a function is read at each sign-in |
+| `onSessionEnded` | None | Synchronously clear user caches before publishing an ended state |
+| `refetchIntervalSeconds` | `60` | Better Auth session polling; `0` disables polling |
+| `unavailableRetryMs` | `5000` | Retry first-load session failures; `0` disables the timer |
+| `socket.enabled` | `true` | Connect notifications while signed in |
+| `socket.backoff` | `{ baseMs: 1000, capMs: 30000 }` | Full-jitter reconnect delay |
+| `fetch`, `WebSocket`, `navigate`, `timers` | Browser implementations | Injectable ports for hosts and tests |
+
+`useInterlockSession()` returns `{ phase, generation, reLoginUsedFor, dependency, user,
+isSignedIn, signIn, signOut, retry }`. `phase.kind` is `loading`, `unavailable`, `signed_in`,
+`signing_out`, `re_entering_login`, or `signed_out`. Better Auth reads the session on mount,
+on refetch, when the page becomes visible, and periodically. A get-session failure preserves
+an existing session; a failure before any session is known shows `unavailable`.
+
+`useInterlockFetch()` returns `(path, { json?, query?, ...RequestInit }) => Promise`.
+It always sends `credentials: "include"` to the configured origin. It never adds
+`x-application-id`; Interlock derives application identity from the browser's Origin.
+
+- Every 401 ends the session atomically and throws `SessionEndedError`. A plain 401 shows
+ login without navigating. A current-generation `refresh_indeterminate` while signed in
+ enters login once for that session ID. `onSessionEnded({ sessionId, reason, generation,
+ willReEnterLogin })` runs before subscribers see the new state.
+- A 401 sent before logout cannot trigger automatic login after logout. Repeated 401s and
+ Better Auth's stale copy of the ended session cannot restore it. A different session ID
+ can sign in again. Provider-discovered session loss never redirects.
+- A contract 503 `dependency_failure`, one of the three contract 502 codes, or a network
+ failure throws `DependencyFailureError` and sets `dependency` without ending the session.
+ The next successful BFF read clears that failure. Ordinary upstream errors pass through
+ as Responses; caller cancellation is rethrown without declaring a dependency failure.
+- `signOut()` is global and never retries automatically. Its 200 or 503
+ `sign_out_incomplete` response leaves `signed_out` with `keycloakLogout` and
+ `signOutIncomplete`. Display `unconfirmed` as **“Signed out of this application, SSO may
+ still be active.”** A network failure retains the last session and refetches it.
+
+`useNotificationSocket()` returns `{ phase, attempt, failure, send, subscribe, reconnectNow }`.
+`subscribe` receives `{ event, data }`; `useSocketEvent(event, handler)` subscribes to one
+named event. `send({ event, data })` returns false unless open. There are no client pings.
+The connector closes with 1000 as soon as the session leaves `signed_in`.
+
+An established socket's 4401 uses the same session-ended transition. A 4503 preserves the
+session, reports `database` or `upstream_unavailable`, and retries with backoff. Failed opens
+and abnormal 1006 closes revalidate through **GET `/api/irongate/notifications/count`** using
+the same fetch wrapper. Get-session does not run the grant refresher, so it cannot classify
+these failures. Ordinary disconnects, including shutdown and backpressure, reconnect.
+
+`useCatalogClient()` provides `listApplications()`, `groupsGranting(appId)`, `myGroups()`,
+`listMyRequests(username)`, `createMemberRequest({ group, user, clientId })`,
+`cancelRequest(id)`, `listNotifications(clientId)`, and `notificationCount()`. Every route
+uses `/api/irongate`. A duplicate request returns `{ kind: "already_requested" }`; a created
+request returns `{ kind: "created", request }`, with `request.id` normalized from `_id ?? id`.
+Other non-contract upstream failures throw `UpstreamError` carrying the status and body.
+
+The entry also exports the framework-free factories, reducers, error classes, and types:
+`resolveEndpoints`, `classifyResponse`, `reduceSession`, `createSessionController`,
+`createInterlockFetch`, `reduceSocket`, `createSocketConnector`, `createCatalogClient`, and
+`backoffDelay`. These can be reused by another framework adapter without importing the
+server entry.
+
+### Compose demo
+
+The unpublished `demo/` exercises username, catalog/groups, request/cancel, socket messages,
+and the dependency and logout states. From this package checkout:
+
+```sh
+npm run demo
+```
+
+Open `https://demo.dev.home.arpa:5173`. The default Interlock origin is
+`https://interlock.dev.home.arpa`; override it with `VITE_INTERLOCK_URI`. TLS defaults to
+`../infralytics-stack/certs/dev.home.arpa.crt` and `.key`; override with `DEMO_TLS_CERT` and
+`DEMO_TLS_KEY` when using a worktree elsewhere. No private key is copied into this package.
+
+The Compose Interlock service must trust `https://demo.dev.home.arpa:5173`; this is
+Compose-only development tooling, not a cluster-template setting. The browser needs the
+local CA trust, hostname mapping, and Ann's client certificate. The host must run Better
+Auth's migrator for `user.username` before serving this package version, and an existing user
+must sign in again to populate it. Demo results are local Compose evidence only, never
+cluster or enclave evidence.
diff --git a/demo/App.tsx b/demo/App.tsx
new file mode 100644
index 0000000..81c762b
--- /dev/null
+++ b/demo/App.tsx
@@ -0,0 +1,223 @@
+import { useEffect, useState } from "react";
+
+import {
+ InterlockProvider,
+ useCatalogClient,
+ useInterlockSession,
+ useNotificationSocket,
+ useSocketEvent,
+ type Application,
+ type KeycloakGroup,
+ type UserRequest,
+} from "../src/react/index.js";
+
+type CatalogRow = { application: Application; groups: KeycloakGroup[] };
+function Dashboard() {
+ const session = useInterlockSession();
+ const catalog = useCatalogClient();
+ const socket = useNotificationSocket();
+ const [rows, setRows] = useState([]);
+ const [requests, setRequests] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [message, setMessage] = useState("");
+ const [lastEvent, setLastEvent] = useState(null);
+ const [revision, setRevision] = useState(0);
+ const username = session.user?.username;
+ const id = session.phase.kind === "signed_in" ? session.phase.session.session.id : null;
+ useSocketEvent("notifications", setLastEvent);
+ useEffect(() => {
+ if (!id) return undefined;
+ let active = true;
+ const load = async () => {
+ const [applications, ownGroups, ownRequests] = await Promise.all([
+ catalog.listApplications(),
+ catalog.myGroups(),
+ username ? catalog.listMyRequests(username) : Promise.resolve([]),
+ ]);
+ const nextRows = await Promise.all(
+ applications.map(async (application) => ({
+ application,
+ groups: await catalog.groupsGranting(application.id),
+ })),
+ );
+ if (active) {
+ setRows(nextRows);
+ setGroups(ownGroups);
+ setRequests(ownRequests);
+ }
+ };
+ void load().catch((error: unknown) => {
+ if (active) setMessage(error instanceof Error ? error.message : "Request failed");
+ });
+ return () => {
+ active = false;
+ };
+ }, [id, username, catalog, revision]);
+ const act = (action: () => Promise) => {
+ void action()
+ .then(() => setRevision((value) => value + 1))
+ .catch((error: unknown) =>
+ setMessage(error instanceof Error ? error.message : "Request failed"),
+ );
+ };
+ const signOut = session.phase.kind === "signed_out" ? session.phase : null;
+ return (
+
+
+ {session.dependency && (
+
+ Dependency unavailable: {session.dependency.source} / {session.dependency.reason}.{" "}
+ {
+ void session.retry();
+ }}
+ >
+ Retry session
+
+
+ )}
+
+ {
+ void session.signIn();
+ }}
+ >
+ Sign in
+
+ {
+ void session.signOut();
+ }}
+ >
+ Sign out everywhere
+
+ {
+ void session.retry();
+ }}
+ >
+ Revalidate session
+
+
+ {signOut && (
+
+ Signed out
+
+ {signOut.keycloakLogout === "unconfirmed"
+ ? "Signed out of this application, SSO may still be active"
+ : signOut.keycloakLogout === "ended"
+ ? "Your Keycloak session has ended."
+ : "Sign in to continue."}
+
+ {signOut.signOutIncomplete && (
+
+ Sign-out cleanup is incomplete. This request will not be retried automatically.
+
+ )}
+
+ )}
+ {session.isSignedIn && (
+ <>
+
+ {session.user?.name}
+ Username: {username ?? "Not provided yet; sign in again after the migration"}
+
+ Email:{" "}
+ {session.user?.email.endsWith("@keycloak.invalid")
+ ? "Not provided (placeholder)"
+ : session.user?.email}
+
+ My groups: {groups.map((group) => group.path).join(", ") || "None"}
+
+
+ Applications
+ {rows.map(({ application, groups: granting }) => (
+
+
+ {application.description}
+ {application.requestable ? "Requestable" : "Not requestable"}
+ {granting.map((group) => (
+
+ {group.path}{" "}
+
+ act(async () => {
+ if (!username) return;
+ const result = await catalog.createMemberRequest({
+ group: group.path,
+ user: username,
+ clientId: application.id,
+ });
+ setMessage(
+ result.kind === "already_requested"
+ ? "Already requested"
+ : "Request created",
+ );
+ })
+ }
+ >
+ Request access
+
+
+ ))}
+
+ ))}
+
+
+ My requests
+ {requests.map((request) => (
+
+ {request.group} · {request.clientId}{" "}
+ act(async () => catalog.cancelRequest(request.id))}
+ >
+ Cancel
+
+
+ ))}
+
+ >
+ )}
+ {message && {message}
}
+
+ Notification socket
+
+ {socket.phase} · attempt {socket.attempt}
+
+ Last failure: {socket.failure?.reason ?? "None"}
+
+ Reconnect now
+
+ {JSON.stringify(lastEvent, null, 2)}
+
+
+ This page validates the local Compose stack. It provides no cluster or enclave evidence.
+
+
+ );
+}
+export function App() {
+ return (
+
+
+
+ );
+}
diff --git a/demo/env.d.ts b/demo/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/demo/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000..b1b42e5
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Interlock client demo
+
+
+
+
+
+
diff --git a/demo/main.tsx b/demo/main.tsx
new file mode 100644
index 0000000..524cd15
--- /dev/null
+++ b/demo/main.tsx
@@ -0,0 +1,14 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App.js";
+
+import "./style.css";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("Missing root element");
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/demo/style.css b/demo/style.css
new file mode 100644
index 0000000..dc8bcb1
--- /dev/null
+++ b/demo/style.css
@@ -0,0 +1,54 @@
+:root {
+ font-family: system-ui, sans-serif;
+ color: #19313c;
+ background: #f3f6f8;
+}
+body {
+ margin: 0;
+}
+main {
+ max-width: 960px;
+ margin: auto;
+ padding: 2rem;
+}
+h1 {
+ margin: 0.4rem 0;
+}
+.eyebrow {
+ letter-spacing: 0.12em;
+ font-size: 0.75rem;
+}
+section,
+aside {
+ background: white;
+ padding: 1.5rem;
+ margin: 1rem 0;
+ border: 1px solid #cedbe0;
+ border-radius: 0.5rem;
+}
+aside {
+ border-color: #c68224;
+ background: #fff4dc;
+}
+button {
+ padding: 0.55rem 0.8rem;
+ margin: 0.25rem;
+ cursor: pointer;
+}
+button:disabled {
+ cursor: default;
+}
+article {
+ border-top: 1px solid #cedbe0;
+ padding: 0.5rem 0;
+}
+a {
+ color: #155c79;
+}
+pre {
+ overflow: auto;
+}
+footer {
+ color: #52656d;
+ font-size: 0.85rem;
+}
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
new file mode 100644
index 0000000..10b7cd6
--- /dev/null
+++ b/demo/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": { "types": [] },
+ "include": ["./**/*.ts", "./**/*.tsx", "../src"]
+}
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
new file mode 100644
index 0000000..d90850f
--- /dev/null
+++ b/demo/vite.config.ts
@@ -0,0 +1,22 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath, URL } from "node:url";
+
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ root: fileURLToPath(new URL(".", import.meta.url)),
+ plugins: [react()],
+ server: {
+ host: true,
+ port: 5173,
+ strictPort: true,
+ https: {
+ cert: readFileSync(
+ process.env.DEMO_TLS_CERT ?? "../infralytics-stack/certs/dev.home.arpa.crt",
+ ),
+ key: readFileSync(process.env.DEMO_TLS_KEY ?? "../infralytics-stack/certs/dev.home.arpa.key"),
+ },
+ allowedHosts: ["demo.dev.home.arpa"],
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index 749adac..59d5468 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@devalexllc/interlock-auth",
- "version": "0.0.1",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@devalexllc/interlock-auth",
- "version": "0.0.1",
+ "version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"jose": "6.2.12"
@@ -32,6 +32,7 @@
"pg": "8.23.1",
"react": "19.2.0",
"react-dom": "19.2.0",
+ "vite": "8.3.4",
"vitest": "4.1.11"
},
"engines": {
@@ -39,7 +40,13 @@
},
"peerDependencies": {
"better-auth": "1.7.7",
- "kysely": "^0.29.0"
+ "kysely": "^0.29.0",
+ "react": ">=18.3.0 <20"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
}
},
"node_modules/@acemir/cssom": {
diff --git a/package.json b/package.json
index 7fee17a..3c51a3a 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,8 @@
"test:ui": "vitest --coverage --ui",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit",
- "prepublishOnly": "npm run clean && npm run build && npm test"
+ "prepublishOnly": "npm run clean && npm run build && npm test",
+ "demo": "vite --config demo/vite.config.ts"
},
"dependencies": {
"jose": "6.2.12"
@@ -79,11 +80,18 @@
"pg": "8.23.1",
"react": "19.2.0",
"react-dom": "19.2.0",
+ "vite": "8.3.4",
"vitest": "4.1.11"
},
"peerDependencies": {
"better-auth": "1.7.7",
- "kysely": "^0.29.0"
+ "kysely": "^0.29.0",
+ "react": ">=18.3.0 <20"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
},
"engines": {
"node": ">=22.18.0"
diff --git a/src/__tests__/entry-points.test.ts b/src/__tests__/entry-points.test.ts
index 0ed86a1..bbcf729 100644
--- a/src/__tests__/entry-points.test.ts
+++ b/src/__tests__/entry-points.test.ts
@@ -10,11 +10,27 @@ describe("entry points", () => {
expect(mod.normalizeClaims).toBeInstanceOf(Function);
});
- it.each([
- ["./next", async () => import("../next/index.js")],
- ["./react", async () => import("../react/index.js")],
- ])("%s is empty", async (_entry, load) => {
- const mod: object = await load();
- expect(Object.keys(mod)).toEqual([]);
- });
+ it.each([["./next", async () => import("../next/index.js")]])(
+ "%s is empty",
+ async (_entry, load) => {
+ const mod: object = await load();
+ expect(Object.keys(mod)).toEqual([]);
+ },
+ );
+});
+
+it("./react exports the provider, hooks, and framework-free factories", async () => {
+ const mod = await import("../react/index.js");
+ for (const name of [
+ "InterlockProvider",
+ "useInterlockSession",
+ "useInterlockFetch",
+ "useCatalogClient",
+ "useNotificationSocket",
+ "useSocketEvent",
+ "createSessionController",
+ "createSocketConnector",
+ "classifyResponse",
+ ] as const)
+ expect(mod[name]).toBeInstanceOf(Function);
});
diff --git a/src/client/__tests__/helpers/fake-ports.ts b/src/client/__tests__/helpers/fake-ports.ts
new file mode 100644
index 0000000..48cbc68
--- /dev/null
+++ b/src/client/__tests__/helpers/fake-ports.ts
@@ -0,0 +1,41 @@
+import { vi } from "vitest";
+
+import { createSessionController, type SessionPorts } from "../../session-controller.js";
+import type { InterlockSession } from "../../types.js";
+
+export const session = (id = "s1"): InterlockSession => ({
+ user: {
+ id: "u1",
+ name: "Ann",
+ username: "ann",
+ email: "ann@example.com",
+ emailVerified: true,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ },
+ session: { id, userId: "u1", token: "opaque", expiresAt: new Date(9999999999999) },
+});
+export const fakePorts = () => ({
+ signIn: vi.fn().mockResolvedValue(undefined),
+ signOut: vi
+ .fn()
+ .mockResolvedValue({ keycloakLogout: "ended", incomplete: false }),
+ refetchSession: vi.fn().mockResolvedValue(undefined),
+ onSessionEnded: vi.fn>(),
+ callbackURL: "https://demo.test/",
+});
+export function signedIn() {
+ const ports = fakePorts();
+ const controller = createSessionController(ports);
+ controller.dispatch({ type: "session_resolved", session: session() });
+ return { ports, controller };
+}
+export function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
diff --git a/src/client/__tests__/helpers/fake-web-socket.ts b/src/client/__tests__/helpers/fake-web-socket.ts
new file mode 100644
index 0000000..5c2dfe3
--- /dev/null
+++ b/src/client/__tests__/helpers/fake-web-socket.ts
@@ -0,0 +1,33 @@
+import type { WebSocketPort } from "../../types.js";
+
+export class FakeWebSocket implements WebSocketPort {
+ static instances: FakeWebSocket[] = [];
+ readyState = 0;
+ onopen: WebSocketPort["onopen"] = null;
+ onclose: WebSocketPort["onclose"] = null;
+ onmessage: WebSocketPort["onmessage"] = null;
+ onerror: WebSocketPort["onerror"] = null;
+ sent: string[] = [];
+ closeCalls: (number | undefined)[] = [];
+ constructor(readonly url: string) {
+ FakeWebSocket.instances.push(this);
+ }
+ send(data: string) {
+ this.sent.push(data);
+ }
+ close(code?: number) {
+ this.closeCalls.push(code);
+ this.readyState = 3;
+ }
+ simulateOpen() {
+ this.readyState = 1;
+ this.onopen?.(new Event("open"));
+ }
+ simulateClose(code: number, reason = "") {
+ this.readyState = 3;
+ this.onclose?.({ code, reason } as CloseEvent);
+ }
+ simulateMessage(data: unknown) {
+ this.onmessage?.({ data } as MessageEvent);
+ }
+}
diff --git a/src/client/__tests__/helpers/manual-timers.ts b/src/client/__tests__/helpers/manual-timers.ts
new file mode 100644
index 0000000..5d69454
--- /dev/null
+++ b/src/client/__tests__/helpers/manual-timers.ts
@@ -0,0 +1,32 @@
+import type { Timers } from "../../types.js";
+
+export class ManualTimers implements Timers {
+ private time = 0;
+ private sequence = 0;
+ private tasks = new Map void }>();
+ now = () => this.time;
+ setTimeout = (callback: () => void, ms: number) => {
+ this.sequence += 1;
+ this.tasks.set(this.sequence, { at: this.time + ms, callback });
+ return this.sequence as unknown as ReturnType;
+ };
+ clearTimeout = (id: ReturnType) => {
+ this.tasks.delete(id as unknown as number);
+ };
+ pending = () => this.tasks.size;
+ advance(ms: number) {
+ const until = this.time + ms;
+ let next = [...this.tasks]
+ .filter(([, task]) => task.at <= until)
+ .toSorted((a, b) => a[1].at - b[1].at)[0];
+ while (next) {
+ this.time = next[1].at;
+ this.tasks.delete(next[0]);
+ next[1].callback();
+ next = [...this.tasks]
+ .filter(([, task]) => task.at <= until)
+ .toSorted((a, b) => a[1].at - b[1].at)[0];
+ }
+ this.time = until;
+ }
+}
diff --git a/src/client/__tests__/helpers/responses.ts b/src/client/__tests__/helpers/responses.ts
new file mode 100644
index 0000000..3600d9d
--- /dev/null
+++ b/src/client/__tests__/helpers/responses.ts
@@ -0,0 +1,6 @@
+export const unauthorized = (reason?: "refresh_indeterminate") =>
+ Response.json({ code: "session_invalid", reason }, { status: 401 });
+export const unavailable = (reason = "database") =>
+ Response.json({ code: "dependency_failure", reason }, { status: 503 });
+export const rejected = (code = "upstream_rejected_token") =>
+ Response.json({ code }, { status: 502 });
diff --git a/src/client/backoff.test.ts b/src/client/backoff.test.ts
new file mode 100644
index 0000000..febf1f2
--- /dev/null
+++ b/src/client/backoff.test.ts
@@ -0,0 +1,17 @@
+import { expect, it } from "vitest";
+
+import { backoffDelay } from "./backoff.js";
+
+it.each([
+ [0, 500],
+ [1, 1000],
+ [2, 2000],
+ [20, 15000],
+])("backoff attempt %i yields %i at half jitter", (attempt, expected) => {
+ expect(backoffDelay(attempt, {}, () => 0.5)).toBe(expected);
+});
+it("jitter ranges from zero to its capped ceiling", () => {
+ expect(backoffDelay(100, {}, () => 0)).toBe(0);
+ expect(backoffDelay(100, {}, () => 1)).toBe(30000);
+ expect(backoffDelay(2, { baseMs: 100, capMs: 300 }, () => 1)).toBe(300);
+});
diff --git a/src/client/backoff.ts b/src/client/backoff.ts
new file mode 100644
index 0000000..796beed
--- /dev/null
+++ b/src/client/backoff.ts
@@ -0,0 +1,14 @@
+export interface BackoffOptions {
+ baseMs?: number;
+ capMs?: number;
+}
+export function backoffDelay(
+ attempt: number,
+ { baseMs = 1000, capMs = 30_000 }: BackoffOptions = {},
+ random = Math.random,
+) {
+ return Math.floor(
+ Math.min(capMs, baseMs * 2 ** Math.min(Math.max(0, attempt), 30)) *
+ Math.min(1, Math.max(0, random())),
+ );
+}
diff --git a/src/client/catalog.test.ts b/src/client/catalog.test.ts
new file mode 100644
index 0000000..a0c6193
--- /dev/null
+++ b/src/client/catalog.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createCatalogClient } from "./catalog.js";
+import { SessionEndedError, UpstreamError } from "./errors.js";
+import type { InterlockFetch } from "./fetch.js";
+
+describe("catalog routes", () => {
+ it.each([
+ ["listApplications", [], "/applications", undefined, []],
+ [
+ "groupsGranting",
+ ["illumina"],
+ "/keycloak/groups/all",
+ { query: { "attributes.apps": "illumina" } },
+ [],
+ ],
+ ["myGroups", [], "/keycloak/users/me/groups", undefined, []],
+ ["listMyRequests", ["ann"], "/user-requests", { query: { user: "ann" } }, []],
+ ["listNotifications", ["irongate"], "/notifications", { query: { clientId: "irongate" } }, []],
+ ["notificationCount", [], "/notifications/count", undefined, { count: 0 }],
+ ["cancelRequest", ["a/b"], "/user-requests/a%2Fb", { method: "DELETE" }, {}],
+ ] as const)("%s uses its BFF route", async (method, args, path, init, body) => {
+ const fetch = vi.fn().mockResolvedValue(Response.json(body));
+ const client = createCatalogClient(fetch);
+ const invoke = client[method] as (...args: string[]) => Promise;
+ await invoke(...args);
+ expect(fetch).toHaveBeenCalledWith(`/api/irongate${path}`, init);
+ });
+ it.each(["_id", "id"])("member request accepts %s and sets type and action", async (idField) => {
+ const fetch = vi.fn().mockResolvedValue(Response.json({ [idField]: "r1" }));
+ const input = { group: "/Apps/Illumina", user: "ann", clientId: "illumina" };
+ expect(await createCatalogClient(fetch).createMemberRequest(input)).toMatchObject({
+ kind: "created",
+ request: { id: "r1" },
+ });
+ expect(fetch).toHaveBeenCalledWith("/api/irongate/user-requests", {
+ method: "POST",
+ json: { ...input, type: "keycloak-member", action: "add" },
+ });
+ });
+ it("409 is already_requested; upstream 500 is an UpstreamError", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json({}, { status: 409 }))
+ .mockResolvedValueOnce(Response.json({}, { status: 500 }));
+ const client = createCatalogClient(fetch);
+ expect(await client.createMemberRequest({ group: "g", user: "u", clientId: "c" })).toEqual({
+ kind: "already_requested",
+ });
+ await expect(client.listApplications()).rejects.toBeInstanceOf(UpstreamError);
+ });
+ it("SessionEndedError passes through unchanged", async () => {
+ const error = new SessionEndedError(2);
+ await expect(
+ createCatalogClient(vi.fn().mockRejectedValue(error)).listApplications(),
+ ).rejects.toBe(error);
+ });
+});
diff --git a/src/client/catalog.ts b/src/client/catalog.ts
new file mode 100644
index 0000000..64f622e
--- /dev/null
+++ b/src/client/catalog.ts
@@ -0,0 +1,91 @@
+import { UpstreamError } from "./errors.js";
+import type { InterlockFetch, InterlockRequestInit } from "./fetch.js";
+
+export interface Application {
+ id: string;
+ name: string;
+ url: string;
+ description: string;
+ requestable: boolean;
+ legacyClientIds?: string[];
+}
+export interface KeycloakGroup {
+ id: string;
+ name: string;
+ path: string;
+ attributes?: Record;
+}
+export interface UserRequest {
+ id: string;
+ group: string;
+ user: string;
+ clientId: string;
+ type: string;
+ action: string;
+ [key: string]: unknown;
+}
+export interface Notification {
+ _id?: string;
+ id?: string;
+ [key: string]: unknown;
+}
+const requestId = (body: Record) =>
+ ({
+ ...body,
+ id: typeof body._id === "string" ? body._id : typeof body.id === "string" ? body.id : "",
+ }) as UserRequest;
+export function createCatalogClient(fetch: InterlockFetch) {
+ const request = async (path: string, init?: InterlockRequestInit) => {
+ const response = await fetch(`/api/irongate${path}`, init);
+ let body: unknown = null;
+ if (response.status !== 204) {
+ try {
+ body = await response.json();
+ } catch {
+ /* Preserve status for errors without JSON. */
+ }
+ }
+ if (!response.ok) throw new UpstreamError(response.status, body);
+ return body;
+ };
+ return {
+ listApplications: async () => (await request("/applications")) as Application[],
+ groupsGranting: async (appId: string) =>
+ (await request("/keycloak/groups/all", {
+ query: { "attributes.apps": appId },
+ })) as KeycloakGroup[],
+ myGroups: async () => (await request("/keycloak/users/me/groups")) as KeycloakGroup[],
+ listMyRequests: async (user: string) =>
+ ((await request("/user-requests", { query: { user } })) as Record[]).map(
+ requestId,
+ ),
+ createMemberRequest: async (input: {
+ group: string;
+ user: string;
+ clientId: string;
+ }): Promise<{ kind: "created"; request: UserRequest } | { kind: "already_requested" }> => {
+ try {
+ return {
+ kind: "created",
+ request: requestId(
+ (await request("/user-requests", {
+ method: "POST",
+ json: { ...input, type: "keycloak-member", action: "add" },
+ })) as Record,
+ ),
+ };
+ } catch (error) {
+ if (error instanceof UpstreamError && error.status === 409)
+ return { kind: "already_requested" };
+ throw error;
+ }
+ },
+ cancelRequest: async (id: string) => {
+ await request(`/user-requests/${encodeURIComponent(id)}`, { method: "DELETE" });
+ },
+ listNotifications: async (clientId: string) =>
+ (await request("/notifications", { query: { clientId } })) as Notification[],
+ notificationCount: async () => (await request("/notifications/count")) as { count: number },
+ };
+}
+export type CatalogClient = ReturnType;
diff --git a/src/client/classify.test.ts b/src/client/classify.test.ts
new file mode 100644
index 0000000..abfeecb
--- /dev/null
+++ b/src/client/classify.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+
+import { rejected, unauthorized, unavailable } from "./__tests__/helpers/responses.js";
+import { classifyResponse } from "./classify.js";
+
+describe("response contract", () => {
+ it.each([undefined, "refresh_indeterminate"] as const)(
+ "401 %s ends the session",
+ async (reason) => {
+ expect(await classifyResponse(unauthorized(reason))).toEqual({
+ kind: "session_ended",
+ ...(reason ? { reason } : {}),
+ });
+ },
+ );
+ it("an unreadable 401 still ends the session", async () => {
+ expect(await classifyResponse(new Response("not json", { status: 401 }))).toEqual({
+ kind: "session_ended",
+ });
+ });
+ it.each([
+ "refresh_in_progress",
+ "keycloak_unreachable",
+ "database",
+ "upstream_unreachable",
+ "upstream_timeout",
+ "upstream_busy",
+ "upstream_unavailable",
+ ])("503 dependency_failure %s", async (reason) => {
+ expect(await classifyResponse(unavailable(reason), () => 42)).toMatchObject({
+ kind: "dependency_failure",
+ failure: { reason, source: "bff", status: 503, at: 42 },
+ });
+ });
+ it.each(["upstream_rejected_token", "upstream_redirect", "upstream_handshake_failed"])(
+ "502 %s preserves the session",
+ async (code) => {
+ expect(await classifyResponse(rejected(code))).toMatchObject({
+ kind: "dependency_failure",
+ failure: { reason: code, source: "upstream" },
+ });
+ },
+ );
+ it.each([403, 404, 409, 500, 502, 503])("upstream %i passes through", async (status) => {
+ const response = Response.json({ statusCode: status }, { status });
+ expect(await classifyResponse(response)).toEqual({ kind: "pass_through" });
+ expect(await response.json()).toEqual({ statusCode: status });
+ });
+ it.each([200, 204])("%i is ok", async (status) => {
+ expect(await classifyResponse(new Response(null, { status }))).toEqual({ kind: "ok" });
+ });
+});
diff --git a/src/client/classify.ts b/src/client/classify.ts
new file mode 100644
index 0000000..fd79e67
--- /dev/null
+++ b/src/client/classify.ts
@@ -0,0 +1,57 @@
+import {
+ DEPENDENCY_FAILURE,
+ SESSION_INVALID,
+ type DependencyFailureBody,
+ type SessionInvalidBody,
+} from "../core/grants/refresh-responses.js";
+import { dependencyFailure, type DependencyFailure } from "./dependency-failure.js";
+
+export type ResponseClassification =
+ | { kind: "ok" | "pass_through" }
+ | { kind: "session_ended"; reason?: SessionInvalidBody["reason"] }
+ | { kind: "dependency_failure"; failure: DependencyFailure };
+
+/** Clone only contract responses: callers retain the original stream. */
+export async function classifyResponse(
+ response: Response,
+ now = Date.now,
+): Promise {
+ if (response.ok) return { kind: "ok" };
+ if (![401, 502, 503].includes(response.status)) return { kind: "pass_through" };
+ let body: Partial & {
+ code?: string;
+ reason?: string;
+ } = {};
+ try {
+ body = (await response.clone().json()) ?? {};
+ } catch {
+ /* An unreadable 401 still ends the session. */
+ }
+ if (response.status === SESSION_INVALID.status)
+ return {
+ kind: "session_ended",
+ ...(body.code === SESSION_INVALID.code && body.reason === "refresh_indeterminate"
+ ? { reason: body.reason }
+ : {}),
+ };
+ const reason = typeof body.reason === "string" ? body.reason : "unknown";
+ const message = typeof body.message === "string" ? body.message : undefined;
+ if (response.status === DEPENDENCY_FAILURE.status && body.code === DEPENDENCY_FAILURE.code) {
+ return {
+ kind: "dependency_failure",
+ failure: dependencyFailure("bff", reason, { status: 503, message }, now),
+ };
+ }
+ if (
+ response.status === 502 &&
+ ["upstream_rejected_token", "upstream_redirect", "upstream_handshake_failed"].includes(
+ body.code ?? "",
+ )
+ ) {
+ return {
+ kind: "dependency_failure",
+ failure: dependencyFailure("upstream", body.code ?? "unknown", { status: 502, message }, now),
+ };
+ }
+ return { kind: "pass_through" };
+}
diff --git a/src/client/dependency-failure.ts b/src/client/dependency-failure.ts
new file mode 100644
index 0000000..8609e13
--- /dev/null
+++ b/src/client/dependency-failure.ts
@@ -0,0 +1,15 @@
+export interface DependencyFailure {
+ source: "bff" | "upstream" | "network" | "socket" | "session";
+ reason: string;
+ status?: number;
+ message?: string;
+ at: number;
+}
+export function dependencyFailure(
+ source: DependencyFailure["source"],
+ reason: string,
+ details: { status?: number; message?: string } = {},
+ now = Date.now,
+): DependencyFailure {
+ return { source, reason, ...details, at: now() };
+}
diff --git a/src/client/endpoints.test.ts b/src/client/endpoints.test.ts
new file mode 100644
index 0000000..7ac2553
--- /dev/null
+++ b/src/client/endpoints.test.ts
@@ -0,0 +1,22 @@
+import { expect, it } from "vitest";
+
+import { resolveEndpoints } from "./endpoints.js";
+
+it.each([undefined, "", "/", "/relative"])("%s selects the page origin", (uri) => {
+ expect(resolveEndpoints(uri, { origin: "https://demo.test" })).toEqual({
+ origin: "https://demo.test",
+ authBaseURL: "https://demo.test/api/auth",
+ socketURL: "wss://demo.test/ws/irongate",
+ });
+});
+it.each([
+ ["http", "ws"],
+ ["https", "wss"],
+])("absolute %s selects Interlock", (http, ws) => {
+ expect(resolveEndpoints(`${http}://interlock.test/path`).socketURL).toBe(
+ `${ws}://interlock.test/ws/irongate`,
+ );
+});
+it("rejects unsupported schemes", () => {
+ expect(() => resolveEndpoints("ftp://interlock.test")).toThrow(TypeError);
+});
diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts
new file mode 100644
index 0000000..fe5fe15
--- /dev/null
+++ b/src/client/endpoints.ts
@@ -0,0 +1,21 @@
+import { AUTH_BASE_PATH } from "../core/constants.js";
+
+export interface Endpoints {
+ origin: string;
+ authBaseURL: string;
+ socketURL: string;
+}
+
+/** Relative URIs select the page origin; Interlock's routes are fixed. */
+export function resolveEndpoints(uri?: string, location?: { origin: string }): Endpoints {
+ const absolute = uri && /^[a-z][a-z\d+.-]*:/i.test(uri);
+ const url = new URL(absolute ? uri : (location?.origin ?? "http://localhost"));
+ if (!["http:", "https:"].includes(url.protocol))
+ throw new TypeError("Interlock needs HTTP or HTTPS");
+ const { origin } = url;
+ return {
+ origin,
+ authBaseURL: `${origin}${AUTH_BASE_PATH}`,
+ socketURL: `${origin.replace(/^http/, "ws")}/ws/irongate`,
+ };
+}
diff --git a/src/client/errors.ts b/src/client/errors.ts
new file mode 100644
index 0000000..684bbca
--- /dev/null
+++ b/src/client/errors.ts
@@ -0,0 +1,26 @@
+import type { DependencyFailure } from "./dependency-failure.js";
+
+export class SessionEndedError extends Error {
+ constructor(
+ readonly generation: number,
+ readonly reason?: "refresh_indeterminate",
+ ) {
+ super("The session ended");
+ this.name = "SessionEndedError";
+ }
+}
+export class DependencyFailureError extends Error {
+ constructor(readonly failure: DependencyFailure) {
+ super(failure.message ?? failure.reason);
+ this.name = "DependencyFailureError";
+ }
+}
+export class UpstreamError extends Error {
+ constructor(
+ readonly status: number,
+ readonly body: unknown,
+ ) {
+ super(`Upstream returned ${status}`);
+ this.name = "UpstreamError";
+ }
+}
diff --git a/src/client/fetch.test.ts b/src/client/fetch.test.ts
new file mode 100644
index 0000000..c7b58f6
--- /dev/null
+++ b/src/client/fetch.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { deferred, signedIn } from "./__tests__/helpers/fake-ports.js";
+import { rejected, unauthorized, unavailable } from "./__tests__/helpers/responses.js";
+import { resolveEndpoints } from "./endpoints.js";
+import { DependencyFailureError, SessionEndedError } from "./errors.js";
+import { createInterlockFetch } from "./fetch.js";
+
+const endpoints = resolveEndpoints("https://interlock.test");
+describe("Interlock fetch", () => {
+ it("sends credentials, query parameters and JSON without application identity", async () => {
+ const { controller } = signedIn();
+ const fetch = vi.fn().mockResolvedValue(Response.json({}));
+ await createInterlockFetch(
+ endpoints,
+ controller,
+ fetch,
+ )("/api/irongate/user-requests", {
+ credentials: "omit",
+ method: "POST",
+ query: { user: "a b", absent: undefined },
+ json: { group: "/Apps" },
+ });
+ const [url, init] = fetch.mock.calls[0] ?? [];
+ expect((url as URL).href).toBe("https://interlock.test/api/irongate/user-requests?user=a+b");
+ expect(init).toMatchObject({
+ credentials: "include",
+ body: '{"group":"/Apps"}',
+ method: "POST",
+ });
+ expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
+ expect(new Headers(init?.headers).has("x-application-id")).toBe(false);
+ });
+ it.each([undefined, "refresh_indeterminate"] as const)(
+ "401 %s applies one transition",
+ async (reason) => {
+ const { controller, ports } = signedIn();
+ const fetch = createInterlockFetch(
+ endpoints,
+ controller,
+ vi.fn().mockResolvedValue(unauthorized(reason)),
+ );
+ await expect(fetch("/api/irongate/applications")).rejects.toBeInstanceOf(SessionEndedError);
+ expect(controller.getState().phase.kind).toBe(reason ? "re_entering_login" : "signed_out");
+ expect(ports.signIn).toHaveBeenCalledTimes(reason ? 1 : 0);
+ },
+ );
+ it("captures generation before send: logout then delayed indeterminate never redirects", async () => {
+ const { controller, ports } = signedIn();
+ const pending = deferred();
+ const request = createInterlockFetch(
+ endpoints,
+ controller,
+ vi.fn().mockReturnValue(pending.promise),
+ )("/api/irongate/applications");
+ // The assertion is attached before resolving the deliberately delayed response.
+ // oxlint-disable-next-line vitest/valid-expect
+ const assertion = expect(request).rejects.toMatchObject({ generation: 0 });
+ await controller.signOut();
+ pending.resolve(unauthorized("refresh_indeterminate"));
+ await assertion;
+ expect(ports.signIn).not.toHaveBeenCalled();
+ expect(controller.getState().phase.kind).toBe("signed_out");
+ });
+ it.each([unavailable, rejected])(
+ "contract dependency failure preserves session and success clears it",
+ async (response) => {
+ const { controller } = signedIn();
+ const fetch = createInterlockFetch(
+ endpoints,
+ controller,
+ vi.fn().mockResolvedValueOnce(response()).mockResolvedValueOnce(Response.json({})),
+ );
+ await expect(fetch("/read")).rejects.toBeInstanceOf(DependencyFailureError);
+ expect(controller.getState().phase.kind).toBe("signed_in");
+ expect(controller.getState().dependency).not.toBeNull();
+ await fetch("/read");
+ expect(controller.getState().dependency).toBeNull();
+ },
+ );
+ it("TypeError is network dependency failure; abort is not", async () => {
+ const { controller } = signedIn();
+ const fetch = createInterlockFetch(
+ endpoints,
+ controller,
+ vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError("offline"))
+ .mockRejectedValueOnce(new DOMException("aborted", "AbortError")),
+ );
+ await expect(fetch("/read")).rejects.toMatchObject({ failure: { source: "network" } });
+ await expect(fetch("/read")).rejects.toMatchObject({ name: "AbortError" });
+ });
+ it("403 passes through intact", async () => {
+ const { controller } = signedIn();
+ const response = Response.json({ message: "no" }, { status: 403 });
+ expect(
+ await createInterlockFetch(
+ endpoints,
+ controller,
+ vi.fn().mockResolvedValue(response),
+ )("/read"),
+ ).toBe(response);
+ });
+});
diff --git a/src/client/fetch.ts b/src/client/fetch.ts
new file mode 100644
index 0000000..efda223
--- /dev/null
+++ b/src/client/fetch.ts
@@ -0,0 +1,53 @@
+import { classifyResponse } from "./classify.js";
+import { dependencyFailure } from "./dependency-failure.js";
+import type { Endpoints } from "./endpoints.js";
+import { DependencyFailureError, SessionEndedError } from "./errors.js";
+import type { SessionController } from "./session-controller.js";
+
+export interface InterlockRequestInit extends RequestInit {
+ json?: unknown;
+ query?: Record;
+}
+export type InterlockFetch = (path: string, init?: InterlockRequestInit) => Promise;
+export function createInterlockFetch(
+ endpoints: Endpoints,
+ controller: SessionController,
+ fetchImpl: typeof fetch = globalThis.fetch,
+): InterlockFetch {
+ return async (path, { json, query, ...init } = {}) => {
+ const url = new URL(path, endpoints.origin);
+ if (url.origin !== endpoints.origin)
+ throw new TypeError("Interlock fetch requires its configured origin");
+ for (const [key, value] of Object.entries(query ?? {}))
+ if (value !== undefined) url.searchParams.set(key, String(value));
+ const headers = new Headers(init.headers);
+ if (json !== undefined) headers.set("content-type", "application/json");
+ const body = json !== undefined ? JSON.stringify(json) : init.body;
+ const generation = controller.generation();
+ let response: Response;
+ try {
+ response = await fetchImpl(url, {
+ ...init,
+ headers,
+ body,
+ credentials: "include",
+ });
+ } catch (error) {
+ if (!(error instanceof TypeError)) throw error;
+ const failure = dependencyFailure("network", "unreachable");
+ controller.dispatch({ type: "dependency_failed", failure });
+ throw new DependencyFailureError(failure);
+ }
+ const result = await classifyResponse(response);
+ if (result.kind === "session_ended") {
+ controller.dispatch({ type: "unauthorized", generation, reason: result.reason });
+ throw new SessionEndedError(generation, result.reason);
+ }
+ if (result.kind === "dependency_failure") {
+ controller.dispatch({ type: "dependency_failed", failure: result.failure });
+ throw new DependencyFailureError(result.failure);
+ }
+ if (result.kind === "ok") controller.dispatch({ type: "dependency_recovered" });
+ return response;
+ };
+}
diff --git a/src/client/session-controller.test.ts b/src/client/session-controller.test.ts
new file mode 100644
index 0000000..81c230a
--- /dev/null
+++ b/src/client/session-controller.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { fakePorts, session, signedIn } from "./__tests__/helpers/fake-ports.js";
+import { dependencyFailure } from "./dependency-failure.js";
+import { UpstreamError } from "./errors.js";
+import { createSessionController, type SessionPorts } from "./session-controller.js";
+
+describe("session effect ordering", () => {
+ it("notifies before publish and queues re-entrant dispatch until after publish", () => {
+ const ports = fakePorts();
+ const controller = createSessionController(ports);
+ controller.dispatch({ type: "session_resolved", session: session() });
+ const events: string[] = [];
+ ports.onSessionEnded.mockImplementation(() => {
+ events.push("clear");
+ expect(controller.generation()).toBe(1);
+ controller.dispatch({
+ type: "dependency_failed",
+ failure: dependencyFailure("network", "unreachable"),
+ });
+ });
+ controller.subscribe(() => events.push(controller.getState().dependency ? "failure" : "ended"));
+ controller.dispatch({ type: "unauthorized", generation: 0 });
+ expect(events).toEqual(["clear", "ended", "failure"]);
+ });
+ it("automatic login passes callbackURL", () => {
+ const { ports, controller } = signedIn();
+ controller.dispatch({ type: "unauthorized", generation: 0, reason: "refresh_indeterminate" });
+ expect(ports.signIn).toHaveBeenCalledWith({ callbackURL: ports.callbackURL });
+ });
+ it("a callbackURL function is read at each sign-in, not when the controller is built", async () => {
+ let current = "https://demo.test/mounted";
+ const ports = { ...fakePorts(), callbackURL: () => current };
+ const controller = createSessionController(ports);
+ controller.dispatch({ type: "session_resolved", session: session() });
+ current = "https://demo.test/later";
+ await controller.signIn();
+ expect(ports.signIn).toHaveBeenLastCalledWith({ callbackURL: "https://demo.test/later" });
+ current = "https://demo.test/latest";
+ controller.dispatch({ type: "unauthorized", generation: 0, reason: "refresh_indeterminate" });
+ expect(ports.signIn).toHaveBeenLastCalledWith({ callbackURL: "https://demo.test/latest" });
+ await controller.signIn({ callbackURL: "https://demo.test/explicit" });
+ expect(ports.signIn).toHaveBeenLastCalledWith({ callbackURL: "https://demo.test/explicit" });
+ });
+ it("a sign-in refused by Interlock is a bff dependency failure with its status", async () => {
+ const ports = {
+ ...fakePorts(),
+ signIn: vi.fn().mockRejectedValue(new UpstreamError(403, null)),
+ };
+ const controller = createSessionController(ports);
+ await controller.signIn();
+ expect(controller.getState().dependency).toMatchObject({
+ source: "bff",
+ reason: "request_failed",
+ status: 403,
+ });
+ });
+ it.each([false, true])("signOut orchestrates completion, incomplete=%s", async (incomplete) => {
+ const ports = {
+ ...fakePorts(),
+ signOut: vi.fn<() => Promise<{ keycloakLogout: "unconfirmed"; incomplete: boolean }>>(
+ async () => ({ keycloakLogout: "unconfirmed" as const, incomplete }),
+ ),
+ };
+ const controller = createSessionController(ports);
+ await controller.signOut();
+ expect(controller.getState().phase).toMatchObject({
+ kind: "signed_out",
+ keycloakLogout: "unconfirmed",
+ signOutIncomplete: incomplete,
+ });
+ expect(ports.signOut).toHaveBeenCalledTimes(1);
+ });
+ it("signOut failure refetches and never retries signOut", async () => {
+ const { ports, controller } = signedIn();
+ ports.signOut.mockRejectedValue(new TypeError("offline"));
+ await controller.signOut();
+ expect(controller.getState().phase.kind).toBe("signed_in");
+ expect(ports.signOut).toHaveBeenCalledTimes(1);
+ expect(ports.refetchSession).toHaveBeenCalledTimes(1);
+ });
+});
+
+it("re-entrant signOut shares the in-flight operation", async () => {
+ const { controller, ports } = signedIn();
+ controller.subscribe(() => {
+ if (controller.getState().phase.kind === "signing_out") void controller.signOut();
+ });
+ await controller.signOut();
+ expect(ports.signOut).toHaveBeenCalledTimes(1);
+});
diff --git a/src/client/session-controller.ts b/src/client/session-controller.ts
new file mode 100644
index 0000000..c901591
--- /dev/null
+++ b/src/client/session-controller.ts
@@ -0,0 +1,114 @@
+import { dependencyFailure } from "./dependency-failure.js";
+import { DependencyFailureError, UpstreamError } from "./errors.js";
+import {
+ initialSessionState,
+ reduceSession,
+ type SessionEvent,
+ type SessionState,
+} from "./session-machine.js";
+import type { SessionEndedInfo, SignOutResponse } from "./types.js";
+
+export interface SignInOptions {
+ callbackURL?: string;
+}
+export interface SessionPorts {
+ signIn(options: SignInOptions): Promise;
+ signOut(): Promise;
+ refetchSession(): Promise;
+ onSessionEnded?(info: SessionEndedInfo): void;
+ /** A function is read at each sign-in, so an automatic re-login returns to the current route. */
+ callbackURL?: string | (() => string | undefined);
+ now?: () => number;
+}
+export function createSessionController(ports: SessionPorts, initial = initialSessionState()) {
+ let state = initial;
+ let dispatching = false;
+ let signingOut: Promise | undefined;
+ const listeners = new Set<() => void>();
+ const queue: SessionEvent[] = [];
+ const failureOf = (error: unknown) =>
+ error instanceof DependencyFailureError
+ ? error.failure
+ : error instanceof UpstreamError
+ ? dependencyFailure("bff", "request_failed", { status: error.status }, ports.now)
+ : dependencyFailure("network", "unreachable", {}, ports.now);
+ const callbackURL = () =>
+ typeof ports.callbackURL === "function" ? ports.callbackURL() : ports.callbackURL;
+ const retry = async () => {
+ try {
+ await ports.refetchSession();
+ } catch (error) {
+ dispatch({ type: "session_unavailable", failure: failureOf(error) });
+ }
+ };
+ const signIn = async (options: SignInOptions = {}) => {
+ try {
+ await ports.signIn({ callbackURL: callbackURL(), ...options });
+ } catch (error) {
+ dispatch({ type: "login_failed", failure: failureOf(error) });
+ }
+ };
+ function dispatch(event: SessionEvent) {
+ queue.push(event);
+ if (dispatching) return;
+ dispatching = true;
+ try {
+ while (queue.length) {
+ const pending = queue.shift();
+ if (!pending) break;
+ const result = reduceSession(state, pending);
+ // Install before the callback so generation() is synchronous, but publish after it.
+ const changed = result.state !== state;
+ state = result.state;
+ for (const effect of result.effects) {
+ if (effect.type === "notify_ended") {
+ try {
+ ports.onSessionEnded?.(effect.info);
+ } catch (error) {
+ console.error("Interlock onSessionEnded failed", error);
+ }
+ }
+ }
+ if (changed) for (const listener of listeners) listener();
+ for (const effect of result.effects) {
+ if (effect.type === "re_enter_login") void signIn();
+ if (effect.type === "refetch_session") void retry();
+ }
+ }
+ } finally {
+ dispatching = false;
+ }
+ }
+ const signOut = async () => {
+ if (signingOut) return signingOut;
+ signingOut = Promise.resolve()
+ .then(async () => {
+ try {
+ dispatch({ type: "sign_out_completed", result: await ports.signOut() });
+ } catch (error) {
+ dispatch({ type: "sign_out_failed", failure: failureOf(error) });
+ }
+ })
+ .finally(() => {
+ signingOut = undefined;
+ });
+ dispatch({ type: "sign_out_requested" });
+ return signingOut;
+ };
+ return {
+ getState: () => state,
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ dispatch,
+ generation: () => state.generation,
+ signIn,
+ signOut,
+ retry,
+ };
+}
+export type SessionController = ReturnType;
+export type { SessionState };
diff --git a/src/client/session-machine.test.ts b/src/client/session-machine.test.ts
new file mode 100644
index 0000000..c229390
--- /dev/null
+++ b/src/client/session-machine.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from "vitest";
+
+import { session } from "./__tests__/helpers/fake-ports.js";
+import { dependencyFailure } from "./dependency-failure.js";
+import {
+ initialSessionState,
+ reduceSession,
+ type SessionEvent,
+ type SessionState,
+} from "./session-machine.js";
+
+const signedIn = (): SessionState => ({
+ ...initialSessionState(),
+ phase: { kind: "signed_in", session: session() },
+});
+const ended = (generation = 0): SessionEvent => ({
+ type: "unauthorized",
+ reason: "refresh_indeterminate",
+ generation,
+});
+const failure = dependencyFailure("session", "database", { status: 500 }, () => 1);
+const step = (state: SessionState, event: SessionEvent) => reduceSession(state, event).state;
+
+describe("session transitions", () => {
+ it("plain 401 ends, increments generation, notifies, and never redirects", () => {
+ const result = reduceSession(signedIn(), { type: "unauthorized", generation: 0 });
+ expect(result.state).toMatchObject({
+ generation: 1,
+ phase: { kind: "signed_out", cause: "ended", endedSessionId: "s1" },
+ });
+ expect(result.effects).toEqual([
+ {
+ type: "notify_ended",
+ info: { sessionId: "s1", generation: 1, reason: undefined, willReEnterLogin: false },
+ },
+ { type: "refetch_session" },
+ ]);
+ });
+ it("current indeterminate consumes exactly one automatic login", () => {
+ const result = reduceSession(signedIn(), ended());
+ expect(result.state).toMatchObject({
+ generation: 1,
+ reLoginUsedFor: "s1",
+ phase: { kind: "re_entering_login" },
+ });
+ expect(result.effects).toContainEqual({ type: "re_enter_login" });
+ expect(step(result.state, ended(1))).toBe(result.state);
+ const sameSession = { ...result.state, phase: signedIn().phase };
+ expect(step(sameSession, ended(1)).phase.kind).toBe("signed_out");
+ });
+ it("stale-generation indeterminate is a plain end", () => {
+ const result = reduceSession({ ...signedIn(), generation: 1 }, ended());
+ expect(result.state.phase.kind).toBe("signed_out");
+ expect(result.effects.map((effect) => effect.type)).not.toContain("re_enter_login");
+ });
+ it("provider null ends without navigation; late reason and stale session cannot restore it", () => {
+ const result = reduceSession(signedIn(), { type: "session_resolved", session: null });
+ expect(result.state.phase.kind).toBe("signed_out");
+ expect(result.effects.map((effect) => effect.type)).not.toContain("re_enter_login");
+ expect(step(result.state, ended())).toBe(result.state);
+ expect(step(result.state, { type: "session_resolved", session: session() })).toBe(result.state);
+ });
+ it("a new session clears the automatic-login marker", () => {
+ const state = step(signedIn(), ended());
+ expect(step(state, { type: "session_resolved", session: session("s2") })).toMatchObject({
+ phase: { kind: "signed_in" },
+ reLoginUsedFor: null,
+ });
+ });
+ it.each(["ended", "unconfirmed", "no_session"] as const)(
+ "logout %s survives null and late 401",
+ (keycloakLogout) => {
+ const pending = step(signedIn(), { type: "sign_out_requested" });
+ expect(pending.generation).toBe(1);
+ expect(step(pending, ended())).toBe(pending);
+ const done = step(pending, {
+ type: "sign_out_completed",
+ result: { keycloakLogout, incomplete: false },
+ });
+ expect(done.phase).toMatchObject({ kind: "signed_out", cause: "signed_out", keycloakLogout });
+ expect(step(done, { type: "session_resolved", session: null })).toBe(done);
+ expect(step(done, ended())).toBe(done);
+ expect(step(done, { type: "session_resolved", session: session() })).toBe(done);
+ },
+ );
+ it("503 sign_out_incomplete remains signed out and flagged", () => {
+ const state = step(signedIn(), { type: "sign_out_requested" });
+ expect(
+ step(state, {
+ type: "sign_out_completed",
+ result: { keycloakLogout: "unconfirmed", incomplete: true },
+ }).phase,
+ ).toMatchObject({ kind: "signed_out", signOutIncomplete: true });
+ });
+ it("logout network failure restores session, reports dependency, and refetches", () => {
+ const state = step(signedIn(), { type: "sign_out_requested" });
+ const result = reduceSession(state, { type: "sign_out_failed", failure });
+ expect(result.state).toMatchObject({ phase: { kind: "signed_in" }, dependency: failure });
+ expect(result.effects).toEqual([{ type: "refetch_session" }]);
+ });
+ it("dependency failure preserves the session; success clears it", () => {
+ const state = step(signedIn(), { type: "dependency_failed", failure });
+ expect(state.phase.kind).toBe("signed_in");
+ expect(step(state, { type: "dependency_recovered" }).dependency).toBeNull();
+ });
+ it("first-load 500 is unavailable; first-load null is never signed in", () => {
+ expect(step(initialSessionState(), { type: "session_unavailable", failure }).phase.kind).toBe(
+ "unavailable",
+ );
+ expect(step(initialSessionState(), { type: "session_resolved", session: null }).phase).toEqual({
+ kind: "signed_out",
+ cause: "never",
+ });
+ expect(step(signedIn(), { type: "session_unavailable", failure }).phase.kind).toBe("signed_in");
+ });
+ it("sign-out while signed out still increments generation and accepts no_session", () => {
+ const initial = step(initialSessionState(), { type: "session_resolved", session: null });
+ const state = step(initial, { type: "sign_out_requested" });
+ expect(state.generation).toBe(1);
+ expect(
+ step(state, {
+ type: "sign_out_completed",
+ result: { keycloakLogout: "no_session", incomplete: false },
+ }).phase,
+ ).toMatchObject({ kind: "signed_out", keycloakLogout: "no_session" });
+ });
+});
diff --git a/src/client/session-machine.ts b/src/client/session-machine.ts
new file mode 100644
index 0000000..69187b1
--- /dev/null
+++ b/src/client/session-machine.ts
@@ -0,0 +1,166 @@
+import type { DependencyFailure } from "./dependency-failure.js";
+import type { InterlockSession, SessionEndedInfo, SignOutResponse } from "./types.js";
+
+export type SessionPhase =
+ | { kind: "loading" }
+ | { kind: "unavailable"; failure: DependencyFailure }
+ | { kind: "signed_in"; session: InterlockSession }
+ | { kind: "signing_out"; session: InterlockSession | null; endedSessionId?: string }
+ | { kind: "re_entering_login"; endedSessionId: string }
+ | {
+ kind: "signed_out";
+ cause: "never" | "ended" | "signed_out";
+ endedSessionId?: string;
+ keycloakLogout?: SignOutResponse["keycloakLogout"];
+ signOutIncomplete?: boolean;
+ };
+export interface SessionState {
+ phase: SessionPhase;
+ generation: number;
+ reLoginUsedFor: string | null;
+ dependency: DependencyFailure | null;
+}
+export type SessionEvent =
+ | { type: "session_resolved"; session: InterlockSession | null }
+ | { type: "session_unavailable"; failure: DependencyFailure }
+ | { type: "unauthorized"; generation: number; reason?: "refresh_indeterminate" }
+ | { type: "sign_out_requested" }
+ | { type: "sign_out_completed"; result: SignOutResponse }
+ | { type: "sign_out_failed" | "login_failed" | "dependency_failed"; failure: DependencyFailure }
+ | { type: "dependency_recovered" };
+export type SessionEffect =
+ | { type: "notify_ended"; info: SessionEndedInfo }
+ | { type: "re_enter_login" | "refetch_session" };
+export const initialSessionState = (): SessionState => ({
+ phase: { kind: "loading" },
+ generation: 0,
+ reLoginUsedFor: null,
+ dependency: null,
+});
+
+export function reduceSession(
+ state: SessionState,
+ event: SessionEvent,
+): { state: SessionState; effects: SessionEffect[] } {
+ const { phase } = state;
+ const same = { state, effects: [] };
+ const next = (patch: Partial, effects: SessionEffect[] = []) => ({
+ state: { ...state, ...patch },
+ effects,
+ });
+ const end = (reason?: "refresh_indeterminate", reEnter = false) => {
+ if (phase.kind !== "signed_in") return same;
+ const sessionId = phase.session.session.id;
+ const generation = state.generation + 1;
+ return next(
+ {
+ generation,
+ phase: reEnter
+ ? { kind: "re_entering_login", endedSessionId: sessionId }
+ : { kind: "signed_out", cause: "ended", endedSessionId: sessionId },
+ reLoginUsedFor: reEnter ? sessionId : state.reLoginUsedFor,
+ },
+ [
+ {
+ type: "notify_ended",
+ info: { sessionId, reason, generation, willReEnterLogin: reEnter },
+ },
+ { type: reEnter ? "re_enter_login" : "refetch_session" },
+ ],
+ );
+ };
+ switch (event.type) {
+ case "unauthorized":
+ return end(
+ event.reason,
+ phase.kind === "signed_in" &&
+ event.reason === "refresh_indeterminate" &&
+ event.generation === state.generation &&
+ state.reLoginUsedFor !== phase.session.session.id,
+ );
+ case "session_resolved": {
+ if (phase.kind === "signing_out") return same;
+ if (!event.session) {
+ if (phase.kind === "signed_in") return end();
+ if (phase.kind === "loading" || phase.kind === "unavailable")
+ return next({ phase: { kind: "signed_out", cause: "never" }, dependency: null });
+ return same;
+ }
+ const id = event.session.session.id;
+ if (
+ (phase.kind === "signed_out" || phase.kind === "re_entering_login") &&
+ id === phase.endedSessionId
+ )
+ return same;
+ return next({
+ phase: { kind: "signed_in", session: event.session },
+ reLoginUsedFor: id === state.reLoginUsedFor ? state.reLoginUsedFor : null,
+ dependency: state.dependency?.source === "session" ? null : state.dependency,
+ });
+ }
+ case "session_unavailable":
+ return next({
+ dependency: event.failure,
+ ...(["loading", "unavailable"].includes(phase.kind)
+ ? { phase: { kind: "unavailable", failure: event.failure } as SessionPhase }
+ : {}),
+ });
+ case "sign_out_requested":
+ if (phase.kind === "signing_out") return same;
+ return next({
+ generation: state.generation + 1,
+ phase: {
+ kind: "signing_out",
+ session: phase.kind === "signed_in" ? phase.session : null,
+ endedSessionId:
+ phase.kind === "signed_in"
+ ? phase.session.session.id
+ : "endedSessionId" in phase
+ ? phase.endedSessionId
+ : undefined,
+ },
+ });
+ case "sign_out_completed":
+ if (phase.kind !== "signing_out") return same;
+ return next({
+ phase: {
+ kind: "signed_out",
+ cause: "signed_out",
+ endedSessionId: phase.endedSessionId,
+ keycloakLogout: event.result.keycloakLogout,
+ signOutIncomplete: event.result.incomplete,
+ },
+ dependency: null,
+ });
+ case "sign_out_failed":
+ if (phase.kind !== "signing_out") return same;
+ return next(
+ {
+ phase: phase.session
+ ? { kind: "signed_in", session: phase.session }
+ : { kind: "signed_out", cause: "never", endedSessionId: phase.endedSessionId },
+ dependency: event.failure,
+ },
+ [{ type: "refetch_session" }],
+ );
+ case "login_failed":
+ return next({
+ dependency: event.failure,
+ ...(phase.kind === "re_entering_login"
+ ? {
+ phase: {
+ kind: "signed_out",
+ cause: "ended",
+ endedSessionId: phase.endedSessionId,
+ } as SessionPhase,
+ }
+ : {}),
+ });
+ case "dependency_failed":
+ return next({ dependency: event.failure });
+ case "dependency_recovered":
+ return state.dependency ? next({ dependency: null }) : same;
+ default:
+ return same;
+ }
+}
diff --git a/src/client/socket-connector.test.ts b/src/client/socket-connector.test.ts
new file mode 100644
index 0000000..2d1df4b
--- /dev/null
+++ b/src/client/socket-connector.test.ts
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { deferred, signedIn } from "./__tests__/helpers/fake-ports.js";
+import { FakeWebSocket } from "./__tests__/helpers/fake-web-socket.js";
+import { ManualTimers } from "./__tests__/helpers/manual-timers.js";
+import { unauthorized, unavailable } from "./__tests__/helpers/responses.js";
+import { resolveEndpoints } from "./endpoints.js";
+import { createInterlockFetch } from "./fetch.js";
+import { createSocketConnector } from "./socket-connector.js";
+
+const latest = () => {
+ const socket = FakeWebSocket.instances.at(-1);
+ if (!socket) throw new Error("No socket");
+ return socket;
+};
+function setup(revalidate = vi.fn<() => Promise>().mockResolvedValue(undefined)) {
+ const { ports, controller } = signedIn();
+ const timers = new ManualTimers();
+ const connector = createSocketConnector({
+ url: "wss://interlock.test/ws/irongate",
+ controller,
+ WebSocket: FakeWebSocket,
+ revalidate,
+ timers,
+ random: () => 0.5,
+ });
+ connector.start();
+ return { ports, controller, connector, timers, revalidate };
+}
+beforeEach(() => {
+ FakeWebSocket.instances = [];
+});
+describe("notification socket lifecycle", () => {
+ it("starts once, parses envelopes, sends only while open, and stops with 1000", () => {
+ const { connector } = setup();
+ connector.start();
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(connector.send({ event: "read", data: 1 })).toBe(false);
+ const message = vi.fn<(message: unknown) => void>();
+ const unsubscribe = connector.subscribeMessages(message);
+ latest().simulateOpen();
+ expect(connector.send({ event: "read", data: 1 })).toBe(true);
+ expect(latest().sent).toEqual(['{"event":"read","data":1}']);
+ latest().simulateMessage('{"event":"notifications","data":{"count":2}}');
+ latest().simulateMessage("invalid");
+ latest().simulateMessage('{"event":"missing-data"}');
+ latest().simulateMessage("null");
+ expect(message).toHaveBeenCalledExactlyOnceWith({ event: "notifications", data: { count: 2 } });
+ unsubscribe();
+ connector.stop();
+ expect(latest().closeCalls).toEqual([1000]);
+ expect(connector.getState().phase).toBe("idle");
+ });
+ it("4401 ends session without redirect", () => {
+ const { controller, connector, ports } = setup();
+ latest().simulateOpen();
+ latest().simulateClose(4401, "session_invalid");
+ expect(controller.getState().phase.kind).toBe("signed_out");
+ expect(connector.getState().phase).toBe("idle");
+ expect(ports.signIn).not.toHaveBeenCalled();
+ });
+ it.each(["database", "upstream_unavailable"])(
+ "4503 %s preserves session and schedules reconnect",
+ (reason) => {
+ const { controller, connector, timers } = setup();
+ latest().simulateOpen();
+ latest().simulateClose(4503, reason);
+ expect(controller.getState().phase.kind).toBe("signed_in");
+ expect(controller.getState().dependency).toMatchObject({ source: "socket", reason });
+ expect(connector.getState().phase).toBe("waiting");
+ timers.advance(500);
+ expect(FakeWebSocket.instances).toHaveLength(2);
+ latest().simulateOpen();
+ expect(controller.getState().dependency).toBeNull();
+ connector.stop();
+ },
+ );
+ it.each([1000, 1001, 1011])("close %i reconnects and opening resets attempts", (code) => {
+ const { connector, timers } = setup();
+ latest().simulateOpen();
+ latest().simulateClose(code);
+ expect(connector.getState().attempt).toBe(1);
+ timers.advance(500);
+ latest().simulateOpen();
+ expect(connector.getState().attempt).toBe(0);
+ connector.stop();
+ });
+ it.each([false, true])("failed open / abnormal close revalidates (opened=%s)", async (opened) => {
+ const { connector, revalidate, timers } = setup();
+ if (opened) latest().simulateOpen();
+ latest().simulateClose(1006);
+ expect(revalidate).toHaveBeenCalledTimes(1);
+ await vi.waitFor(() => expect(connector.getState().phase).toBe("waiting"));
+ timers.advance(500);
+ expect(FakeWebSocket.instances).toHaveLength(2);
+ connector.stop();
+ });
+ it.each(["ok", "ended", "dependency", "network"])(
+ "BFF revalidation handles %s",
+ async (outcome) => {
+ const { controller } = signedIn();
+ const timers = new ManualTimers();
+ const fetchImpl = vi.fn();
+ if (outcome === "network") fetchImpl.mockRejectedValue(new TypeError("offline"));
+ else
+ fetchImpl.mockResolvedValue(
+ outcome === "ended"
+ ? unauthorized()
+ : outcome === "dependency"
+ ? unavailable()
+ : Response.json({ count: 0 }),
+ );
+ const fetch = createInterlockFetch(
+ resolveEndpoints("https://interlock.test"),
+ controller,
+ fetchImpl,
+ );
+ const connector = createSocketConnector({
+ url: "wss://interlock.test/ws/irongate",
+ controller,
+ WebSocket: FakeWebSocket,
+ timers,
+ revalidate: async () => fetch("/api/irongate/notifications/count"),
+ });
+ connector.start();
+ latest().simulateClose(1006);
+ await vi.waitFor(() =>
+ expect(connector.getState().phase).toBe(outcome === "ended" ? "idle" : "waiting"),
+ );
+ expect((fetchImpl.mock.calls[0]?.[0] as URL | undefined)?.href).toBe(
+ "https://interlock.test/api/irongate/notifications/count",
+ );
+ expect(controller.getState().phase.kind).toBe(
+ outcome === "ended" ? "signed_out" : "signed_in",
+ );
+ connector.stop();
+ expect(timers.pending()).toBe(0);
+ },
+ );
+ it("sign-out closes immediately, cancels reconnect, and ignores late revalidation", async () => {
+ const pending = deferred();
+ const { controller, connector, timers } = setup(vi.fn(async () => pending.promise));
+ const socket = latest();
+ const signOut = controller.signOut();
+ expect(socket.closeCalls).toEqual([1000]);
+ expect(timers.pending()).toBe(0);
+ await signOut;
+ pending.resolve(undefined);
+ expect(connector.getState().phase).toBe("idle");
+ });
+ it("stop while revalidating prevents a late success from scheduling", async () => {
+ const pending = deferred();
+ const { connector, timers } = setup(vi.fn(async () => pending.promise));
+ latest().simulateClose(1006);
+ connector.stop();
+ pending.resolve(undefined);
+ await pending.promise;
+ await Promise.resolve();
+ expect(connector.getState().phase).toBe("idle");
+ expect(timers.pending()).toBe(0);
+ });
+});
+
+it("a subscriber stopping during reconnect prevents a new connection", () => {
+ const { connector, timers } = setup();
+ latest().simulateOpen();
+ latest().simulateClose(1001);
+ connector.subscribe(() => {
+ if (connector.getState().phase === "connecting") connector.stop();
+ });
+ timers.advance(500);
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(connector.getState().phase).toBe("idle");
+});
diff --git a/src/client/socket-connector.ts b/src/client/socket-connector.ts
new file mode 100644
index 0000000..70aede7
--- /dev/null
+++ b/src/client/socket-connector.ts
@@ -0,0 +1,201 @@
+/* The connector exclusively owns each socket and clears its handlers on stop. */
+/* oxlint-disable unicorn/prefer-add-event-listener */
+import { backoffDelay, type BackoffOptions } from "./backoff.js";
+import { dependencyFailure } from "./dependency-failure.js";
+import { DependencyFailureError, SessionEndedError } from "./errors.js";
+import type { SessionController } from "./session-controller.js";
+import { initialSocketState, reduceSocket, type SocketEvent } from "./socket-machine.js";
+import {
+ defaultTimers,
+ type SocketMessage,
+ type Timers,
+ type WebSocketConstructor,
+ type WebSocketPort,
+} from "./types.js";
+
+export interface SocketOptions {
+ url: string;
+ controller: SessionController;
+ WebSocket?: WebSocketConstructor;
+ revalidate: () => Promise;
+ timers?: Timers;
+ random?: () => number;
+ backoff?: BackoffOptions;
+}
+export function createSocketConnector({
+ url,
+ controller,
+ WebSocket,
+ revalidate,
+ timers = defaultTimers,
+ random,
+ backoff,
+}: SocketOptions) {
+ let state = initialSocketState();
+ let socket: WebSocketPort | undefined;
+ let timer: ReturnType | undefined;
+ let unsubscribe: (() => void) | undefined;
+ let revision = 0;
+ let generation = 0;
+ const listeners = new Set<() => void>();
+ const messages = new Set<(message: SocketMessage) => void>();
+ const dispatch = (event: SocketEvent) => {
+ const result = reduceSocket(state, event);
+ state = result.state;
+ for (const listener of listeners) listener();
+ for (const effect of result.effects) {
+ // A subscriber may stop the connector while observing this transition.
+ if (state !== result.state) break;
+ if (effect === "cancel_timer" && timer !== undefined) {
+ timers.clearTimeout(timer);
+ timer = undefined;
+ }
+ if (effect === "close") {
+ revision += 1;
+ const closing = socket;
+ socket = undefined;
+ if (closing) {
+ closing.onopen = null;
+ closing.onclose = null;
+ closing.onmessage = null;
+ closing.onerror = null;
+ closing.close(1000);
+ }
+ }
+ if (effect === "open") open();
+ if (effect === "schedule")
+ timer = timers.setTimeout(
+ () => {
+ timer = undefined;
+ dispatch({ type: "timer" });
+ },
+ backoffDelay(state.attempt - 1, backoff, random),
+ );
+ if (effect === "report_unauthorized")
+ controller.dispatch({ type: "unauthorized", generation });
+ if (effect === "report_dependency" && state.failure)
+ controller.dispatch({ type: "dependency_failed", failure: state.failure });
+ if (effect === "revalidate") void check(revision);
+ }
+ };
+ async function check(at: number) {
+ try {
+ await revalidate();
+ if (at === revision) dispatch({ type: "revalidated", result: "alive" });
+ } catch (error) {
+ if (at !== revision) return;
+ if (error instanceof SessionEndedError) dispatch({ type: "revalidated", result: "ended" });
+ else
+ dispatch({
+ type: "revalidated",
+ result: "dependency",
+ failure:
+ error instanceof DependencyFailureError
+ ? error.failure
+ : dependencyFailure("socket", "revalidation_failed", {}, timers.now),
+ });
+ }
+ }
+ function open() {
+ generation = controller.generation();
+ revision += 1;
+ const at = revision;
+ try {
+ const current = new (WebSocket ?? globalThis.WebSocket)(url);
+ socket = current;
+ current.onopen = () => {
+ if (at === revision) {
+ dispatch({ type: "opened" });
+ controller.dispatch({ type: "dependency_recovered" });
+ }
+ };
+ current.onclose = (event) => {
+ if (at !== revision) return;
+ socket = undefined;
+ dispatch({
+ type: "closed",
+ code: event.code,
+ ...(event.code === 4503
+ ? {
+ failure: dependencyFailure(
+ "socket",
+ event.reason === "database" ? "database" : "upstream_unavailable",
+ {},
+ timers.now,
+ ),
+ }
+ : {}),
+ });
+ };
+ // Browsers report the actionable result in close, including refused handshakes.
+ current.onerror = () => {
+ /* close carries the actionable error. */
+ };
+ current.onmessage = (event) => {
+ if (at !== revision || typeof event.data !== "string") return;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(event.data);
+ } catch {
+ return;
+ }
+ if (
+ !parsed ||
+ typeof parsed !== "object" ||
+ !("event" in parsed) ||
+ typeof parsed.event !== "string" ||
+ !("data" in parsed)
+ )
+ return;
+ const message = { event: parsed.event, data: parsed.data };
+ for (const listener of messages) listener(message);
+ };
+ } catch {
+ dispatch({ type: "closed", code: 1006 });
+ }
+ }
+ const sync = () => {
+ if (controller.getState().phase.kind === "signed_in") {
+ if (state.phase === "idle") dispatch({ type: "start" });
+ } else dispatch({ type: "stop" });
+ };
+ const start = () => {
+ if (unsubscribe) return;
+ unsubscribe = controller.subscribe(sync);
+ sync();
+ };
+ const stop = () => {
+ unsubscribe?.();
+ unsubscribe = undefined;
+ dispatch({ type: "stop" });
+ };
+ return {
+ start,
+ stop,
+ getState: () => state,
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ subscribeMessages: (listener: (message: SocketMessage) => void) => {
+ messages.add(listener);
+ return () => {
+ messages.delete(listener);
+ };
+ },
+ send: (message: SocketMessage) => {
+ if (!socket || socket.readyState !== 1) return false;
+ socket.send(JSON.stringify(message));
+ return true;
+ },
+ reconnectNow: () => {
+ if (controller.getState().phase.kind === "signed_in") {
+ if (state.phase === "idle") dispatch({ type: "start" });
+ else dispatch({ type: "reconnect_now" });
+ }
+ },
+ };
+}
+export type SocketConnector = ReturnType;
diff --git a/src/client/socket-machine.ts b/src/client/socket-machine.ts
new file mode 100644
index 0000000..d5a9dc6
--- /dev/null
+++ b/src/client/socket-machine.ts
@@ -0,0 +1,58 @@
+import type { DependencyFailure } from "./dependency-failure.js";
+
+export interface SocketState {
+ phase: "idle" | "connecting" | "open" | "revalidating" | "waiting";
+ attempt: number;
+ failure: DependencyFailure | null;
+}
+export type SocketEvent =
+ | { type: "start" | "stop" | "opened" | "timer" | "reconnect_now" }
+ | { type: "closed"; code: number; failure?: DependencyFailure }
+ | { type: "revalidated"; result: "alive" | "ended" | "dependency"; failure?: DependencyFailure };
+export type SocketEffect =
+ | "open"
+ | "close"
+ | "cancel_timer"
+ | "schedule"
+ | "revalidate"
+ | "report_unauthorized"
+ | "report_dependency";
+export const initialSocketState = (): SocketState => ({ phase: "idle", attempt: 0, failure: null });
+export function reduceSocket(
+ state: SocketState,
+ event: SocketEvent,
+): { state: SocketState; effects: SocketEffect[] } {
+ const next = (patch: Partial, ...effects: SocketEffect[]) => ({
+ state: { ...state, ...patch },
+ effects,
+ });
+ const same = { state, effects: [] };
+ if (event.type === "stop") return next({ phase: "idle" }, "cancel_timer", "close");
+ if (
+ (event.type === "start" && state.phase === "idle") ||
+ ((event.type === "timer" || event.type === "reconnect_now") && state.phase === "waiting")
+ )
+ return next({ phase: "connecting" }, "cancel_timer", "open");
+ if (event.type === "opened" && state.phase === "connecting")
+ return next({ phase: "open", attempt: 0, failure: null });
+ if (event.type === "closed") {
+ if (state.phase === "connecting" || (state.phase === "open" && event.code === 1006))
+ return next({ phase: "revalidating" }, "revalidate");
+ if (state.phase !== "open") return same;
+ if (event.code === 4401) return next({ phase: "idle" }, "report_unauthorized");
+ return next(
+ { phase: "waiting", attempt: state.attempt + 1, failure: event.failure ?? state.failure },
+ ...(event.code === 4503 ? ["report_dependency" as const] : []),
+ "schedule",
+ );
+ }
+ if (event.type === "revalidated" && state.phase === "revalidating") {
+ if (event.result === "ended") return next({ phase: "idle" });
+ return next(
+ { phase: "waiting", attempt: state.attempt + 1, failure: event.failure ?? state.failure },
+ ...(event.result === "dependency" ? ["report_dependency" as const] : []),
+ "schedule",
+ );
+ }
+ return same;
+}
diff --git a/src/client/types.ts b/src/client/types.ts
new file mode 100644
index 0000000..28c880c
--- /dev/null
+++ b/src/client/types.ts
@@ -0,0 +1,49 @@
+import type { KeycloakLogout } from "../core/logout/keycloak-logout.js";
+
+export interface InterlockSession {
+ user: {
+ id: string;
+ name: string;
+ username?: string | null;
+ email: string;
+ emailVerified: boolean;
+ image?: string | null;
+ createdAt: Date | string;
+ updatedAt: Date | string;
+ };
+ session: { id: string; userId: string; expiresAt: Date | string; token: string };
+}
+export interface SessionEndedInfo {
+ sessionId: string;
+ reason?: "refresh_indeterminate";
+ willReEnterLogin: boolean;
+ generation: number;
+}
+export interface SignOutResponse {
+ keycloakLogout: KeycloakLogout;
+ incomplete: boolean;
+}
+export interface Timers {
+ setTimeout(callback: () => void, ms: number): ReturnType;
+ clearTimeout(id: ReturnType): void;
+ now: () => number;
+}
+export const defaultTimers: Timers = {
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
+ clearTimeout: (id) => clearTimeout(id),
+ now: () => Date.now(),
+};
+export interface SocketMessage {
+ event: string;
+ data: T;
+}
+export interface WebSocketPort {
+ readyState: number;
+ onopen: ((event: Event) => void) | null;
+ onclose: ((event: CloseEvent) => void) | null;
+ onmessage: ((event: MessageEvent) => void) | null;
+ onerror: ((event: Event) => void) | null;
+ send(data: string): void;
+ close(code?: number): void;
+}
+export type WebSocketConstructor = new (url: string) => WebSocketPort;
diff --git a/src/react/auth-client.ts b/src/react/auth-client.ts
new file mode 100644
index 0000000..85342b0
--- /dev/null
+++ b/src/react/auth-client.ts
@@ -0,0 +1,58 @@
+import { createAuthClient } from "better-auth/react";
+
+import { dependencyFailure } from "../client/dependency-failure.js";
+import type { Endpoints } from "../client/endpoints.js";
+import { DependencyFailureError, UpstreamError } from "../client/errors.js";
+import type { SignInOptions } from "../client/session-controller.js";
+import type { SignOutResponse } from "../client/types.js";
+
+export function createInterlockAuthClient(
+ endpoints: Endpoints,
+ {
+ fetch: fetchImpl = globalThis.fetch,
+ refetchIntervalSeconds = 60,
+ navigate = (url: string) => {
+ window.location.assign(url);
+ },
+ }: {
+ fetch?: typeof fetch;
+ refetchIntervalSeconds?: number;
+ navigate?: (url: string) => void;
+ } = {},
+) {
+ const client = createAuthClient({
+ baseURL: endpoints.authBaseURL,
+ disableDefaultFetchPlugins: true,
+ fetchOptions: { customFetchImpl: fetchImpl, credentials: "include", retry: 0 },
+ sessionOptions: { refetchInterval: refetchIntervalSeconds, refetchOnWindowFocus: true },
+ });
+ const sessionAtom = client.$store.atoms.session;
+ if (!sessionAtom) throw new Error("Better Auth has no session atom");
+ return {
+ useSession: client.useSession,
+ refetchSession: () => sessionAtom.get().refetch(),
+ signIn: async ({ callbackURL }: SignInOptions) => {
+ const { data, error } = await client.signIn.social({
+ provider: "keycloak",
+ callbackURL,
+ disableRedirect: true,
+ });
+ if (error) throw new UpstreamError(error.status, error);
+ if (!data?.url) throw new Error("Interlock did not return a login URL");
+ navigate(data.url);
+ },
+ signOut: async (): Promise => {
+ const { data, error } = await client.signOut();
+ const body = (error ?? data) as { code?: string; keycloakLogout?: string } | null;
+ const incomplete = error?.status === 503 && body?.code === "sign_out_incomplete";
+ if (error && !incomplete)
+ throw new DependencyFailureError(
+ dependencyFailure("session", "sign_out_failed", { status: error.status }),
+ );
+ const outcome = body?.keycloakLogout;
+ if (outcome !== "ended" && outcome !== "unconfirmed" && outcome !== "no_session")
+ throw new Error("Interlock returned an invalid sign-out outcome");
+ return { keycloakLogout: outcome, incomplete };
+ },
+ };
+}
diff --git a/src/react/context.ts b/src/react/context.ts
new file mode 100644
index 0000000..33a6a5f
--- /dev/null
+++ b/src/react/context.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext } from "react";
+
+import type { CatalogClient } from "../client/catalog.js";
+import type { InterlockFetch } from "../client/fetch.js";
+import type { SessionController } from "../client/session-controller.js";
+import type { SocketConnector } from "../client/socket-connector.js";
+
+export interface InterlockContextValue {
+ controller: SessionController;
+ fetch: InterlockFetch;
+ catalog: CatalogClient;
+ socket: SocketConnector;
+}
+export const InterlockContext = createContext(null);
+export function useInterlockContext() {
+ const context = useContext(InterlockContext);
+ if (!context) throw new Error("Interlock hooks require an InterlockProvider");
+ return context;
+}
diff --git a/src/react/index.ts b/src/react/index.ts
index 5b23cd2..3fa70a6 100644
--- a/src/react/index.ts
+++ b/src/react/index.ts
@@ -1,4 +1,57 @@
-// `./react` entry: the React session provider and fetch client. Empty until item 3.6 fills it.
-// `export {}` is the only thing that makes an empty file a module; both go when the entry is filled.
-// oxlint-disable-next-line unicorn/require-module-specifiers
-export {};
+export { InterlockProvider, type InterlockProviderProps } from "./provider.js";
+export { useInterlockSession } from "./use-session.js";
+export { useInterlockFetch, useCatalogClient } from "./use-fetch.js";
+export { useNotificationSocket, useSocketEvent } from "./use-socket.js";
+export { resolveEndpoints, type Endpoints } from "../client/endpoints.js";
+export { classifyResponse, type ResponseClassification } from "../client/classify.js";
+export { dependencyFailure, type DependencyFailure } from "../client/dependency-failure.js";
+export { SessionEndedError, DependencyFailureError, UpstreamError } from "../client/errors.js";
+export {
+ reduceSession,
+ initialSessionState,
+ type SessionState,
+ type SessionPhase,
+ type SessionEvent,
+ type SessionEffect,
+} from "../client/session-machine.js";
+export {
+ createSessionController,
+ type SessionController,
+ type SessionPorts,
+ type SignInOptions,
+} from "../client/session-controller.js";
+export {
+ createInterlockFetch,
+ type InterlockFetch,
+ type InterlockRequestInit,
+} from "../client/fetch.js";
+export {
+ reduceSocket,
+ initialSocketState,
+ type SocketState,
+ type SocketEvent,
+ type SocketEffect,
+} from "../client/socket-machine.js";
+export {
+ createSocketConnector,
+ type SocketConnector,
+ type SocketOptions,
+} from "../client/socket-connector.js";
+export {
+ createCatalogClient,
+ type CatalogClient,
+ type Application,
+ type KeycloakGroup,
+ type UserRequest,
+ type Notification,
+} from "../client/catalog.js";
+export { backoffDelay, type BackoffOptions } from "../client/backoff.js";
+export type {
+ InterlockSession,
+ SessionEndedInfo,
+ SignOutResponse,
+ Timers,
+ WebSocketConstructor,
+ WebSocketPort,
+ SocketMessage,
+} from "../client/types.js";
diff --git a/src/react/provider.test.tsx b/src/react/provider.test.tsx
new file mode 100644
index 0000000..4ebb1b3
--- /dev/null
+++ b/src/react/provider.test.tsx
@@ -0,0 +1,299 @@
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { StrictMode, useLayoutEffect } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { deferred, session } from "../client/__tests__/helpers/fake-ports.js";
+import { FakeWebSocket } from "../client/__tests__/helpers/fake-web-socket.js";
+import { ManualTimers } from "../client/__tests__/helpers/manual-timers.js";
+import { unauthorized } from "../client/__tests__/helpers/responses.js";
+import { InterlockProvider, type InterlockProviderProps } from "./provider.js";
+import { useInterlockFetch, useCatalogClient } from "./use-fetch.js";
+import { useInterlockSession } from "./use-session.js";
+import { useNotificationSocket, useSocketEvent } from "./use-socket.js";
+
+const ignore = () => {
+ /* The provider renders the failure. */
+};
+function Consumer({
+ onCommit,
+ onEvent,
+}: {
+ onCommit?: (phase: string) => void;
+ onEvent?: (data: unknown) => void;
+}) {
+ const state = useInterlockSession();
+ const fetch = useInterlockFetch();
+ const socket = useNotificationSocket();
+ useSocketEvent("notifications", onEvent ?? ignore);
+ useLayoutEffect(() => {
+ onCommit?.(state.phase.kind);
+ }, [state.phase.kind, onCommit]);
+ return (
+ <>
+ {state.phase.kind}
+ {socket.phase}
+ {state.user?.username}
+
+ {state.phase.kind === "signed_out"
+ ? `${state.phase.keycloakLogout ?? ""}:${String(state.phase.signOutIncomplete ?? false)}`
+ : ""}
+
+ {
+ void state.signIn();
+ }}
+ >
+ Sign in
+
+ {
+ void state.signOut();
+ }}
+ >
+ Sign out
+
+ {
+ void state.retry();
+ }}
+ >
+ Retry
+
+ {
+ void fetch("/api/irongate/applications").catch(ignore);
+ }}
+ >
+ Read
+
+ >
+ );
+}
+function mount(
+ fetch: typeof globalThis.fetch,
+ props: Partial = {},
+ child = ,
+) {
+ return render(
+
+ {child}
+ ,
+ );
+}
+const phase = async (value: string) =>
+ waitFor(() => expect(screen.getByTestId("phase").textContent).toBe(value));
+const requestPath = (input: Parameters[0]) =>
+ new URL(input instanceof Request ? input.url : input).pathname;
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+beforeEach(() => {
+ FakeWebSocket.instances = [];
+});
+
+describe("InterlockProvider with real Better Auth client", () => {
+ it("mount resolves a session and exposes username", async () => {
+ const fetch = vi
+ .fn()
+ .mockImplementation(async () => Response.json(session()));
+ mount(fetch);
+ await phase("signed_in");
+ expect(screen.getByTestId("user").textContent).toBe("ann");
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+ it("mount null is signed out without navigation", async () => {
+ const navigate = vi.fn<(url: string) => void>();
+ mount(
+ vi.fn().mockImplementation(async () => Response.json(null)),
+ { navigate },
+ );
+ await phase("signed_out");
+ expect(navigate).not.toHaveBeenCalled();
+ });
+ it("mount 500 is unavailable and retries through its timer", async () => {
+ const timers = new ManualTimers();
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json({ message: "database" }, { status: 500 }))
+ .mockResolvedValueOnce(Response.json(session()));
+ mount(fetch, { timers });
+ await phase("unavailable");
+ expect(timers.pending()).toBe(1);
+ act(() => timers.advance(5000));
+ await phase("signed_in");
+ });
+ it("provider refetch null ends without navigation", async () => {
+ const navigate = vi.fn<(url: string) => void>();
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(session()))
+ .mockImplementation(async () => Response.json(null));
+ mount(fetch, { navigate });
+ await phase("signed_in");
+ fireEvent.click(screen.getByText("Retry"));
+ await phase("signed_out");
+ expect(navigate).not.toHaveBeenCalled();
+ });
+ it("storage events refetch and discover an ended session", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(session()))
+ .mockImplementation(async () => Response.json(null));
+ mount(fetch);
+ await phase("signed_in");
+ act(() => {
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "better-auth.message",
+ newValue: JSON.stringify({ event: "session", data: { trigger: "signout" } }),
+ }),
+ );
+ });
+ await phase("signed_out");
+ expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+ it("401 clears consumer caches before the signed-out commit", async () => {
+ const order: string[] = [];
+ const fetch = vi
+ .fn()
+ .mockImplementation(async (input) =>
+ requestPath(input).endsWith("get-session") ? Response.json(session()) : unauthorized(),
+ );
+ mount(
+ fetch,
+ { onSessionEnded: () => order.push("clear") },
+ order.push(value)} />,
+ );
+ await phase("signed_in");
+ fireEvent.click(screen.getByText("Read"));
+ await phase("signed_out");
+ expect(order.indexOf("clear")).toBeLessThan(order.indexOf("signed_out"));
+ // The refetch returned the stale session object, but cannot revive it.
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
+ expect(screen.getByTestId("phase").textContent).toBe("signed_out");
+ });
+ it.each([false, true])(
+ "explicit/automatic login uses disableRedirect and navigates once (automatic=%s)",
+ async (automatic) => {
+ const navigate = vi.fn<(url: string) => void>();
+ const fetch = vi.fn().mockImplementation(async (input) => {
+ const path = requestPath(input);
+ if (path.endsWith("get-session")) return Response.json(session());
+ if (path.endsWith("sign-in/social"))
+ return Response.json({ url: "https://keycloak.test/login", redirect: true });
+ return unauthorized("refresh_indeterminate");
+ });
+ mount(fetch, { navigate, callbackURL: "https://demo.test/" });
+ await phase("signed_in");
+ fireEvent.click(screen.getByText(automatic ? "Read" : "Sign in"));
+ await waitFor(() =>
+ expect(navigate).toHaveBeenCalledExactlyOnceWith("https://keycloak.test/login"),
+ );
+ const call = fetch.mock.calls.find(([input]) =>
+ requestPath(input).endsWith("sign-in/social"),
+ );
+ expect(JSON.parse(call?.[1]?.body as string)).toMatchObject({
+ provider: "keycloak",
+ disableRedirect: true,
+ callbackURL: "https://demo.test/",
+ });
+ },
+ );
+ it("login returns to the route current at sign-in time, not the one at mount", async () => {
+ const navigate = vi.fn<(url: string) => void>();
+ const fetch = vi.fn().mockImplementation(async (input) => {
+ const path = requestPath(input);
+ if (path.endsWith("get-session")) return Response.json(session());
+ if (path.endsWith("sign-in/social"))
+ return Response.json({ url: "https://keycloak.test/login", redirect: true });
+ return unauthorized("refresh_indeterminate");
+ });
+ mount(fetch, { navigate });
+ await phase("signed_in");
+ window.history.pushState({}, "", "/catalog?tab=mine");
+ fireEvent.click(screen.getByText("Read"));
+ await waitFor(() => expect(navigate).toHaveBeenCalledTimes(1));
+ const call = fetch.mock.calls.find(([input]) => requestPath(input).endsWith("sign-in/social"));
+ expect(JSON.parse(call?.[1]?.body as string)).toMatchObject({
+ callbackURL: `${window.location.origin}/catalog?tab=mine`,
+ });
+ });
+ it.each([200, 503])(
+ "logout %i records outcome and closes the socket immediately",
+ async (status) => {
+ const pending = deferred();
+ const fetch = vi
+ .fn()
+ .mockImplementation(async (input) =>
+ requestPath(input).endsWith("sign-out") ? pending.promise : Response.json(session()),
+ );
+ mount(fetch);
+ await phase("signed_in");
+ const socket = FakeWebSocket.instances[0];
+ act(() => socket?.simulateOpen());
+ fireEvent.click(screen.getByText("Sign out"));
+ await phase("signing_out");
+ expect(socket?.closeCalls).toEqual([1000]);
+ pending.resolve(
+ Response.json(
+ status === 200
+ ? { success: true, keycloakLogout: "ended" }
+ : { code: "sign_out_incomplete", keycloakLogout: "unconfirmed" },
+ { status },
+ ),
+ );
+ await phase("signed_out");
+ expect(screen.getByTestId("outcome").textContent).toBe(
+ status === 200 ? "ended:false" : "unconfirmed:true",
+ );
+ },
+ );
+ it("StrictMode opens one socket and one get-session; socket hook receives events", async () => {
+ const onEvent = vi.fn<(data: unknown) => void>();
+ const fetch = vi
+ .fn()
+ .mockImplementation(async () => Response.json(session()));
+ render(
+
+
+
+
+ ,
+ );
+ await phase("signed_in");
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ act(() => {
+ FakeWebSocket.instances[0]?.simulateOpen();
+ FakeWebSocket.instances[0]?.simulateMessage('{"event":"notifications","data":2}');
+ });
+ expect(screen.getByTestId("socket").textContent).toBe("open");
+ expect(onEvent).toHaveBeenCalledWith(2);
+ });
+ it.each([useInterlockSession, useInterlockFetch, useCatalogClient, useNotificationSocket])(
+ "hooks reject use outside the provider (%s)",
+ (hook) => {
+ function Outside() {
+ hook();
+ return null;
+ }
+ expect(() => render( )).toThrow("InterlockProvider");
+ },
+ );
+});
diff --git a/src/react/provider.tsx b/src/react/provider.tsx
new file mode 100644
index 0000000..3cba2a1
--- /dev/null
+++ b/src/react/provider.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
+
+import type { BackoffOptions } from "../client/backoff.js";
+import { createCatalogClient } from "../client/catalog.js";
+import { dependencyFailure } from "../client/dependency-failure.js";
+import { resolveEndpoints } from "../client/endpoints.js";
+import { UpstreamError } from "../client/errors.js";
+import { createInterlockFetch } from "../client/fetch.js";
+import { createSessionController } from "../client/session-controller.js";
+import { createSocketConnector } from "../client/socket-connector.js";
+import {
+ defaultTimers,
+ type InterlockSession,
+ type SessionEndedInfo,
+ type Timers,
+ type WebSocketConstructor,
+} from "../client/types.js";
+import { createInterlockAuthClient } from "./auth-client.js";
+import { InterlockContext } from "./context.js";
+
+export interface InterlockProviderProps {
+ uri?: string;
+ children: ReactNode;
+ onSessionEnded?: (info: SessionEndedInfo) => void;
+ /** Where login returns; a function is read at each sign-in. Default: the page URL at that moment. */
+ callbackURL?: string | (() => string);
+ refetchIntervalSeconds?: number;
+ unavailableRetryMs?: number;
+ socket?: { enabled?: boolean; backoff?: BackoffOptions };
+ fetch?: typeof fetch;
+ WebSocket?: WebSocketConstructor;
+ navigate?: (url: string) => void;
+ timers?: Timers;
+}
+/** Configuration is fixed for the provider's lifetime; remount to change origins. */
+export function InterlockProvider(props: InterlockProviderProps) {
+ const [runtime] = useState(() => {
+ const timers = props.timers ?? defaultTimers;
+ const endpoints = resolveEndpoints(
+ props.uri,
+ typeof window === "undefined" ? undefined : window.location,
+ );
+ const auth = createInterlockAuthClient(endpoints, props);
+ const controller = createSessionController({
+ ...auth,
+ callbackURL: () => {
+ const { callbackURL } = props;
+ if (typeof callbackURL === "function") return callbackURL();
+ return callbackURL ?? (typeof window === "undefined" ? undefined : window.location.href);
+ },
+ onSessionEnded: props.onSessionEnded,
+ now: timers.now,
+ });
+ const fetch = createInterlockFetch(endpoints, controller, props.fetch);
+ const socket = createSocketConnector({
+ url: endpoints.socketURL,
+ controller,
+ timers,
+ backoff: props.socket?.backoff,
+ WebSocket: props.WebSocket,
+ revalidate: async () => {
+ const response = await fetch("/api/irongate/notifications/count");
+ if (!response.ok) throw new UpstreamError(response.status, null);
+ },
+ });
+ return {
+ auth,
+ controller,
+ fetch,
+ catalog: createCatalogClient(fetch),
+ socket,
+ timers,
+ enabled: props.socket?.enabled !== false,
+ unavailableRetryMs: props.unavailableRetryMs ?? 5000,
+ };
+ });
+ const { data, error, isPending, isRefetching } = runtime.auth.useSession();
+ const state = useSyncExternalStore(
+ runtime.controller.subscribe,
+ runtime.controller.getState,
+ runtime.controller.getState,
+ );
+ useEffect(() => {
+ if (isPending || isRefetching) return;
+ if (error)
+ runtime.controller.dispatch({
+ type: "session_unavailable",
+ failure: dependencyFailure(
+ "session",
+ "get_session_failed",
+ { status: error.status, message: error.message },
+ runtime.timers.now,
+ ),
+ });
+ else
+ runtime.controller.dispatch({
+ type: "session_resolved",
+ session: data as InterlockSession | null,
+ });
+ }, [runtime, data, error, isPending, isRefetching]);
+ useEffect(() => {
+ if (state.phase.kind !== "unavailable" || runtime.unavailableRetryMs <= 0) return undefined;
+ const id = runtime.timers.setTimeout(() => {
+ void runtime.controller.retry();
+ }, runtime.unavailableRetryMs);
+ return () => runtime.timers.clearTimeout(id);
+ }, [runtime, state.phase]);
+ useEffect(() => {
+ if (runtime.enabled) runtime.socket.start();
+ return runtime.socket.stop;
+ }, [runtime]);
+ return {props.children} ;
+}
diff --git a/src/react/use-fetch.ts b/src/react/use-fetch.ts
new file mode 100644
index 0000000..65a15c8
--- /dev/null
+++ b/src/react/use-fetch.ts
@@ -0,0 +1,3 @@
+import { useInterlockContext } from "./context.js";
+export const useInterlockFetch = () => useInterlockContext().fetch;
+export const useCatalogClient = () => useInterlockContext().catalog;
diff --git a/src/react/use-session.ts b/src/react/use-session.ts
new file mode 100644
index 0000000..9cc9f0b
--- /dev/null
+++ b/src/react/use-session.ts
@@ -0,0 +1,24 @@
+import { useSyncExternalStore } from "react";
+
+import { useInterlockContext } from "./context.js";
+
+export function useInterlockSession() {
+ const { controller } = useInterlockContext();
+ const state = useSyncExternalStore(
+ controller.subscribe,
+ controller.getState,
+ controller.getState,
+ );
+ const user =
+ state.phase.kind === "signed_in" || state.phase.kind === "signing_out"
+ ? (state.phase.session?.user ?? null)
+ : null;
+ return {
+ ...state,
+ user,
+ isSignedIn: state.phase.kind === "signed_in",
+ signIn: controller.signIn,
+ signOut: controller.signOut,
+ retry: controller.retry,
+ };
+}
diff --git a/src/react/use-socket.ts b/src/react/use-socket.ts
new file mode 100644
index 0000000..58edebb
--- /dev/null
+++ b/src/react/use-socket.ts
@@ -0,0 +1,30 @@
+import { useEffect, useRef, useSyncExternalStore } from "react";
+
+import { useInterlockContext } from "./context.js";
+
+export function useNotificationSocket() {
+ const { socket } = useInterlockContext();
+ const state = useSyncExternalStore(socket.subscribe, socket.getState, socket.getState);
+ return {
+ ...state,
+ send: socket.send,
+ subscribe: socket.subscribeMessages,
+ reconnectNow: socket.reconnectNow,
+ };
+}
+// The consumer supplies the event payload type; the wire envelope is checked by the connector.
+// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
+export function useSocketEvent(event: string, handler: (data: T) => void) {
+ const { socket } = useInterlockContext();
+ const current = useRef(handler);
+ useEffect(() => {
+ current.current = handler;
+ }, [handler]);
+ useEffect(
+ () =>
+ socket.subscribeMessages((message) => {
+ if (message.event === event) current.current(message.data as T);
+ }),
+ [socket, event],
+ );
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 6dfcb37..89177ef 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
+ { test: { name: "client", environment: "node", include: ["src/client/**/*.test.ts"] } },
{
test: {
// The root entry's core runs on the server: Node's environment, and a timeout that