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 ; + if (phase.kind !== "signed_in") return ; + return ; +} + +// 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 ( +
+
+

COMPOSE DEVELOPMENT ONLY

+

Interlock client demo

+

+ Session: {session.phase.kind} +

+
+ {session.dependency && ( + + )} + + {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.name} +

+

{application.description}

+

{application.requestable ? "Requestable" : "Not requestable"}

+ {granting.map((group) => ( +

+ {group.path}{" "} + +

+ ))} +
+ ))} +
+
+

My requests

+ {requests.map((request) => ( +

+ {request.group} · {request.clientId}{" "} + +

+ ))} +
+ + )} + {message &&

{message}

} +
+

Notification socket

+

+ {socket.phase} · attempt {socket.attempt} +

+

Last failure: {socket.failure?.reason ?? "None"}

+ +
{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)}` + : ""} + + + + + + + ); +} +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