diff --git a/.gitignore b/.gitignore index 516f1a1..f46f5d0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ ui/**/dist/ # Coverage reports are BUILD OUTPUT: regenerated by `pnpm test`, timestamped, never reviewed. coverage/ + +# The console's registry is per-DEPLOYMENT config; the smoke writes its own. +ui/console/public/console.config.json diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md index 45f7b20..713317b 100644 --- a/docs/rfc/goldpath-console.md +++ b/docs/rfc/goldpath-console.md @@ -40,7 +40,13 @@ custom-develop ON, with the same kit, the same way they add features to the back for every fleet. CorPay proves it: api + payments + eod under one console today. - **Across services**: a registry of entries `{ name, adminBaseUrl }` — config-file or Aspire service discovery. Each service contributes its capability panels under its - name; cross-service home aggregates them. + name; cross-service home aggregates them. SHIPPED (U4, 2026-07-27): the console reads + `console.config.json` from where it is served — adding a service is a config change, + never a rebuild — with `?base=` as the dev override and same-origin as the default. + Switching service re-runs discovery from scratch: one service's panels must never + appear under another's name. A registry that fails to load is ANNOUNCED, because an + operator who configured four services and silently sees one is looking at the wrong + console. - **Auth**: the console signs in ONCE against the adopter's IdP and carries the token to every service — all surfaces demand `goldpath-ops` (H2); the console is just a well-dressed client of that floor. The login-gate primitive exists in the kit. diff --git a/scripts/console-smoke.sh b/scripts/console-smoke.sh index 7c3bdae..6ac76ae 100755 --- a/scripts/console-smoke.sh +++ b/scripts/console-smoke.sh @@ -30,6 +30,7 @@ cleanup() { [ -n "$VITE_PID" ] && kill "$VITE_PID" 2>/dev/null || true docker rm -f "$PG_NAME" >/dev/null 2>&1 || true docker rm -f "$MQ_NAME" >/dev/null 2>&1 || true + rm -f "$ROOT/ui/console/public/console.config.json" 2>/dev/null || true } trap cleanup EXIT @@ -91,6 +92,20 @@ done grep -q "CONSOLEHOST-READY" /tmp/console-smoke-secured.log || { echo "the secured app never came up:"; tail -20 /tmp/console-smoke-secured.log; exit 1; } grep -q "CONSOLEHOST-READY" /tmp/console-smoke-tenanted.log || { echo "the tenant-scoped app never came up:"; tail -20 /tmp/console-smoke-tenanted.log; exit 1; } +# The cross-service registry the console reads at runtime (console RFC §3). Written here +# rather than committed: it names the three apps THIS run started, and an adopter's file +# names theirs. Vite serves public/ at the root, which is where the console looks. +mkdir -p "$ROOT/ui/console/public" +cat > "$ROOT/ui/console/public/console.config.json" < /tmp/console-smoke-vite.log 2>&1) & VITE_PID=$! diff --git a/ui/console/dev/main.tsx b/ui/console/dev/main.tsx index 4f942f9..ad107c9 100644 --- a/ui/console/dev/main.tsx +++ b/ui/console/dev/main.tsx @@ -1,14 +1,13 @@ -// The dev harness: the REAL console against a real service. Point it at a running app -// with `?base=http://localhost:5xxx` (CorPay's api head) or serve it from the app itself. +// The dev harness: the REAL console against real services. Point it at one app with +// `?base=http://localhost:5xxx`, or serve `console.config.json` next to the console to +// drive a whole registry. import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./console.css"; -import { Console } from "../src/Console"; - -const base = new URLSearchParams(window.location.search).get("base") ?? ""; +import { ConsoleApp } from "../src/ConsoleApp"; createRoot(document.getElementById("root")!).render( - + , ); diff --git a/ui/console/e2e/console.spec.ts b/ui/console/e2e/console.spec.ts index 84a0c83..af840e9 100644 --- a/ui/console/e2e/console.spec.ts +++ b/ui/console/e2e/console.spec.ts @@ -359,4 +359,29 @@ test.describe("the run console against a real Goldpath app", () => { // thing it must never do here is imply the trigger landed. await expect(page.getByText(/did not reach the server/)).toBeVisible(); }); + + test("one console, three services: the registry switches between them and re-discovers each", async ({ page }) => { + // No ?base= — the console reads the registry the smoke wrote, exactly as an adopter's + // console reads theirs. + await page.goto("/"); + + const picker = page.getByLabel(/service/i); + await expect(picker).toHaveValue("open"); + await expect(page.getByTestId("run-console")).toBeVisible(); + await expect(page.getByRole("button", { name: "Bulk intake" })).toBeVisible(); + + // The auth-floored app composes the same modules but refuses this operator: the + // sections stay NAMED and the reason is the server's. + await picker.selectOption("auth-floored"); + await expect(page.getByRole("alert")).toContainText("lacks the ops role"); + await expect(page.getByTestId("run-console")).toHaveCount(0); + + // And the tenant-scoped app refuses differently — a call it cannot scope. + await picker.selectOption("tenant-scoped"); + await expect(page.getByRole("alert")).toContainText("composed here but refused this request"); + + // Back to the open app: its panels come back, freshly discovered. + await picker.selectOption("open"); + await expect(page.getByTestId("run-console")).toBeVisible(); + }); }); diff --git a/ui/console/src/Console.tsx b/ui/console/src/Console.tsx index 775a5d7..31e616d 100644 --- a/ui/console/src/Console.tsx +++ b/ui/console/src/Console.tsx @@ -14,6 +14,10 @@ export interface ConsoleProps { title?: string; fetcher?: typeof fetch; now?: Date; + /** The registry's other services — omitted entirely when there is only one. */ + services?: string[]; + activeService?: string; + onSelectService?: (name: string) => void; } type Capabilities = Record; @@ -33,7 +37,15 @@ const SECTION_LABEL: Record = { * role, or no tenant to scope the call to — says exactly that, in the server's words: * "absent" is reserved for a module the app genuinely does not compose. */ -export function Console({ baseUrl, title = "Goldpath console", fetcher, now }: ConsoleProps) { +export function Console({ + baseUrl, + title = "Goldpath console", + fetcher, + now, + services, + activeService, + onSelectService, +}: ConsoleProps) { const client = useMemo(() => new AdminClient({ baseUrl, fetcher }), [baseUrl, fetcher]); const [capabilities, setCapabilities] = useState(null); const [section, setSection] = useState("jobs"); @@ -65,6 +77,8 @@ export function Console({ baseUrl, title = "Goldpath console", fetcher, now }: C title={title} nav={nav} activeId={section} + services={services?.map((name) => ({ name, onSelect: () => onSelectService?.(name) }))} + activeService={activeService} collapsed={collapsed} onToggleCollapsed={() => setCollapsed(!collapsed)} > diff --git a/ui/console/src/ConsoleApp.test.tsx b/ui/console/src/ConsoleApp.test.tsx new file mode 100644 index 0000000..11201c6 --- /dev/null +++ b/ui/console/src/ConsoleApp.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConsoleApp } from "./ConsoleApp"; + +/** + * A fake estate: two services with DIFFERENT compositions, so switching has to re-discover + * rather than reuse what it already drew. + */ +function estate(options: { registry?: unknown; registryStatus?: number } = {}) { + const asked: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + + if (url.includes("console.config.json")) { + if (options.registryStatus && options.registryStatus !== 200) return new Response("", { status: options.registryStatus }); + return json( + options.registry ?? { + services: [ + { name: "payments", adminBaseUrl: "https://payments.internal" }, + { name: "claims", adminBaseUrl: "https://claims.internal" }, + ], + }, + ); + } + + asked.push(url); + // payments composes jobs + bulk; claims composes archival only. + if (url.startsWith("https://payments.internal")) { + if (url.includes("/jobs/fleets")) return json([]); + if (url.includes("/bulk/definitions")) return json([]); + return new Response("", { status: 404 }); + } + + if (url.startsWith("https://claims.internal")) { + if (url.includes("/archival/definitions")) return json([]); + return new Response("", { status: 404 }); + } + + return new Response("", { status: 404 }); + }) as typeof fetch; + + return { fetcher, asked }; +} + +describe("the console across services", () => { + it("lists the registry's services and lands on the first", async () => { + const { fetcher } = estate(); + render(); + + const picker = await screen.findByLabelText(/service/i); + expect(picker).toHaveValue("payments"); + expect(await screen.findByRole("button", { name: "Bulk intake" })).toBeInTheDocument(); + }); + + it("switching service RE-DISCOVERS — one service's panels never appear under another's name", async () => { + const { fetcher, asked } = estate(); + render(); + + await screen.findByRole("button", { name: "Bulk intake" }); + + await userEvent.selectOptions(await screen.findByLabelText(/service/i), "claims"); + + // Claims composes archival only: bulk must be GONE, not carried over. + expect(await screen.findByRole("button", { name: "Archival" })).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole("button", { name: "Bulk intake" })).toBeNull()); + + // And the probes went to the claims service, not to payments again. + expect(asked.some((url) => url.startsWith("https://claims.internal"))).toBe(true); + }); + + it("a single-service console shows no picker at all — nothing to choose between", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("console.config.json")) return new Response("", { status: 404 }); + return new Response(JSON.stringify([]), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + render(); + + await screen.findByRole("button", { name: "Runs" }); + expect(screen.queryByLabelText(/service/i)).toBeNull(); + }); + + it("a registry that failed to load is ANNOUNCED — an operator who configured four services must not silently see one", async () => { + const { fetcher } = estate({ registryStatus: 500 }); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent(/service registry answered 500 — showing this service only/); + }); + + it("a partially broken registry names the loss — the console did NOT fall back, it is incomplete", async () => { + const { fetcher } = estate({ + registry: { services: [{ name: "payments", adminBaseUrl: "https://payments.internal" }, { adminBaseUrl: "https://nameless.internal" }] }, + }); + render(); + + const banner = await screen.findByRole("alert"); + expect(banner).toHaveTextContent("1 registry entry has no name and was skipped"); + expect(banner).toHaveTextContent("missing a service you configured"); + expect(banner).not.toHaveTextContent("showing this service only"); + }); + + it("?base= drives one named service, whatever the registry says", async () => { + const { fetcher } = estate(); + render(); + + await screen.findByRole("button", { name: "Bulk intake" }); + expect(screen.queryByLabelText(/service/i)).toBeNull(); // one service: no picker + }); +}); diff --git a/ui/console/src/ConsoleApp.tsx b/ui/console/src/ConsoleApp.tsx new file mode 100644 index 0000000..454c64b --- /dev/null +++ b/ui/console/src/ConsoleApp.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { Banner } from "@goldpath/kit"; +import { Console } from "./Console"; +import { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry"; + +export interface ConsoleAppProps { + fetcher?: typeof fetch; + /** Injected in tests; defaults to the browser's own query string. */ + search?: string; + now?: Date; +} + +/** + * The console, across services (console RFC §3 + D2). It owns exactly two things the + * per-service screen must not: WHICH services exist (config, not discovery) and which one + * the operator is looking at. + * + * Each service gets its own `Console` instance, keyed by name, so switching re-runs + * capability discovery from scratch — a payments service's panels must never be shown + * under a claims service's name because the shell happened to reuse the component. + */ +export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) { + const [services, setServices] = useState(null); + const [problem, setProblem] = useState<{ text: string; fellBack: boolean } | null>(null); + const [active, setActive] = useState(null); + + useEffect(() => { + let live = true; + void loadRegistry(fetcher, search).then((result) => { + if (!live) return; + setServices(result.services); + setProblem(result.problem ? { text: result.problem, fellBack: result.fellBack === true } : null); + setActive((current) => current ?? result.services[0]?.name ?? null); + }); + return () => { + live = false; + }; + }, [fetcher, search]); + + if (services === null) { + return

Reading the service registry…

; + } + + const service = services.find((entry) => entry.name === active) ?? services[0] ?? SAME_ORIGIN; + + return ( + <> + {problem && ( + // Config that failed to load is NOT a quiet fallback: an operator who configured + // four services and sees one is looking at the wrong console. + + {problem.text} + {problem.fellBack ? " — showing this service only." : " — the console is missing a service you configured."} + + )} + 1 ? services.map((entry) => entry.name) : undefined} + activeService={service.name} + onSelectService={setActive} + /> + + ); +} diff --git a/ui/console/src/index.ts b/ui/console/src/index.ts index 2e51626..70a8aac 100644 --- a/ui/console/src/index.ts +++ b/ui/console/src/index.ts @@ -1,3 +1,5 @@ export { AdminClient, AdminHttpError, MODULES, type AdminClientOptions, type ModuleName, type FleetInfo, type JobInfo, type RunSummary, type RunDetail, type AdminResult } from "./adminClient"; export { RunConsole, type RunConsoleProps } from "./RunConsole"; export { Console, type ConsoleProps } from "./Console"; +export { ConsoleApp, type ConsoleAppProps } from "./ConsoleApp"; +export { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry"; diff --git a/ui/console/src/registry.test.ts b/ui/console/src/registry.test.ts new file mode 100644 index 0000000..fc3c3f3 --- /dev/null +++ b/ui/console/src/registry.test.ts @@ -0,0 +1,115 @@ +import { loadRegistry, SAME_ORIGIN } from "./registry"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +const fetcherFor = (response: Response | (() => never)) => + (async () => (typeof response === "function" ? response() : response)) as typeof fetch; + +describe("the service registry (config across services, discovery within one)", () => { + it("reads the adopter's services, in the order they wrote them", async () => { + const fetcher = fetcherFor( + json({ services: [{ name: "payments", adminBaseUrl: "https://payments.internal" }, { name: "claims", adminBaseUrl: "https://claims.internal" }] }), + ); + + const { services, problem } = await loadRegistry(fetcher, ""); + + expect(services).toEqual([ + { name: "payments", adminBaseUrl: "https://payments.internal" }, + { name: "claims", adminBaseUrl: "https://claims.internal" }, + ]); + expect(problem).toBeUndefined(); + }); + + it("no registry means ONE service — the app that served the console", async () => { + const { services, problem } = await loadRegistry(fetcherFor(new Response("", { status: 404 })), ""); + + expect(services).toEqual([SAME_ORIGIN]); + expect(problem).toBeUndefined(); // a single-service console is normal, not a fault + }); + + it("?base= wins — the dev override and the way to point at a service that did not serve this console", async () => { + const fetcher = fetcherFor(json({ services: [{ name: "payments", adminBaseUrl: "https://payments.internal" }] })); + + const { services } = await loadRegistry(fetcher, "?base=http://localhost:5310"); + + expect(services).toEqual([{ name: "http://localhost:5310", adminBaseUrl: "http://localhost:5310" }]); + }); + + it("an EMPTY ?base= means same-origin, named as such", async () => { + const { services } = await loadRegistry(fetcherFor(new Response("", { status: 404 })), "?base="); + + expect(services).toEqual([{ name: "same-origin", adminBaseUrl: "" }]); + }); + + it("a registry that cannot be READ says so — a quiet fallback would hide the other services", async () => { + const { services, problem } = await loadRegistry(fetcherFor(new Response("", { status: 500 })), ""); + + expect(services).toEqual([SAME_ORIGIN]); + expect(problem).toMatch(/answered 500/); + }); + + it("a registry that is not JSON at all is a problem, not a crash", async () => { + const { services, problem } = await loadRegistry(fetcherFor(new Response("index", { status: 200 })), ""); + + expect(services).toEqual([SAME_ORIGIN]); + expect(problem).toMatch(/could not be read/); + }); + + it("an unreachable registry is a problem, not a crash", async () => { + const { problem } = await loadRegistry( + fetcherFor(() => { + throw new TypeError("network down"); + }), + "", + ); + + expect(problem).toMatch(/could not be read/); + }); + + it("entries without a name are dropped, and a file with nothing usable says so", async () => { + const { services, problem } = await loadRegistry( + fetcherFor(json({ services: [{ adminBaseUrl: "https://nameless.internal" }, { name: " " }] })), + "", + ); + + expect(services).toEqual([SAME_ORIGIN]); + expect(problem).toMatch(/no service with a name/); + }); + + it("a PARTIAL drop is reported — the console still works and is quietly missing a service", async () => { + const { services, problem } = await loadRegistry( + fetcherFor( + json({ + services: [ + { name: "payments", adminBaseUrl: "https://payments.internal" }, + { adminBaseUrl: "https://nameless.internal" }, + { name: " ", adminBaseUrl: "https://blank.internal" }, + ], + }), + ), + "", + ); + + expect(services).toEqual([{ name: "payments", adminBaseUrl: "https://payments.internal" }]); + expect(problem).toBe("2 registry entries have no name and were skipped"); + }); + + it("one dropped entry is reported in the singular — the count is the point", async () => { + const { problem } = await loadRegistry( + fetcherFor(json({ services: [{ name: "payments" }, { adminBaseUrl: "https://nameless.internal" }] })), + "", + ); + + expect(problem).toBe("1 registry entry has no name and was skipped"); + }); + + it("a same-origin entry is legitimate: the console's own app, listed beside the others", async () => { + const { services } = await loadRegistry( + fetcherFor(json({ services: [{ name: "this app", adminBaseUrl: "" }, { name: "claims", adminBaseUrl: "https://claims.internal" }] })), + "", + ); + + expect(services[0]).toEqual({ name: "this app", adminBaseUrl: "" }); + }); +}); diff --git a/ui/console/src/registry.ts b/ui/console/src/registry.ts new file mode 100644 index 0000000..86ef21f --- /dev/null +++ b/ui/console/src/registry.ts @@ -0,0 +1,83 @@ +/** + * The cross-service registry (console RFC §3). Within ONE app there is nothing to + * configure — the fleets are store-discovered and the app's own admin surface speaks for + * all of them. ACROSS services there is nothing to discover: a payments service cannot + * know that a claims service exists, so the registry is CONFIG, and the console reads it + * from where it is served rather than being rebuilt per adopter. + */ +export interface ServiceEntry { + /** The operator's word for the service — what the rail shows. */ + name: string; + /** Its management head; "" means the app serving this console (same-origin). */ + adminBaseUrl: string; +} + +/** The shape of `console.config.json`, as an adopter writes it. */ +interface RegistryFile { + services?: { name?: unknown; adminBaseUrl?: unknown }[]; +} + +/** The single-service console: the app that serves the console is the only service. */ +export const SAME_ORIGIN: ServiceEntry = { name: "this service", adminBaseUrl: "" }; + +/** + * Reads the registry. + * + * Precedence, and why: + * 1. `?base=` — the DEV override, and the escape hatch for pointing the console at a + * service it was not served by. One service, named by its own URL. + * 2. `console.config.json` next to the console — the adopter's registry. Served by the + * app (or by whatever hosts the dist), so adding a service is a config change, never + * a rebuild. + * 3. Nothing — same-origin, single service. + * + * A config that cannot be read or makes no sense yields the same-origin default AND says + * so: a console that silently shows one service when the operator configured four would + * hide exactly the outage they came to find. + */ +export interface Registry { + services: ServiceEntry[]; + /** What went wrong, in words the console shows verbatim. */ + problem?: string; + /** True when the problem cost us the registry entirely and we fell back to same-origin. */ + fellBack?: boolean; +} + +export async function loadRegistry( + fetcher: typeof fetch = (input, init) => globalThis.fetch(input, init), + search: string = globalThis.location?.search ?? "", +): Promise { + const base = new URLSearchParams(search).get("base"); + if (base !== null) { + return { services: [{ name: base || "same-origin", adminBaseUrl: base }] }; + } + + try { + const response = await fetcher("console.config.json", { headers: { accept: "application/json" } }); + if (response.status === 404) return { services: [SAME_ORIGIN] }; // no registry: one service + if (!response.ok) { + return { services: [SAME_ORIGIN], problem: `the service registry answered ${response.status}`, fellBack: true }; + } + + const file = (await response.json()) as RegistryFile; + const services = (file.services ?? []) + .map((entry) => ({ + name: typeof entry.name === "string" ? entry.name.trim() : "", + adminBaseUrl: typeof entry.adminBaseUrl === "string" ? entry.adminBaseUrl.trim() : "", + })) + .filter((entry) => entry.name.length > 0); + + if (services.length === 0) { + return { services: [SAME_ORIGIN], problem: "the service registry lists no service with a name", fellBack: true }; + } + + // A PARTIAL drop is the dangerous one: the console still works, still looks right, and + // is quietly missing a service the operator configured (review R1 on this PR). + const dropped = (file.services ?? []).length - services.length; + return dropped > 0 + ? { services, problem: `${dropped} registry entr${dropped === 1 ? "y has" : "ies have"} no name and ${dropped === 1 ? "was" : "were"} skipped` } + : { services }; + } catch { + return { services: [SAME_ORIGIN], problem: "the service registry could not be read", fellBack: true }; + } +}