diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md index bea2c64..9f0f804 100644 --- a/docs/rfc/goldpath-console.md +++ b/docs/rfc/goldpath-console.md @@ -47,9 +47,26 @@ custom-develop ON, with the same kit, the same way they add features to the back 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. +- **Auth** (settled in U4, 2026-07-27 — and deliberately NOT an IdP client of our own): + + 1. **Same origin — the shape we ship.** The console is served by the app it drives + (`MapGoldpathConsole`), behind the SAME ops floor as the admin surfaces. The browser + is challenged for the console's own document, so an unauthenticated operator never + reaches a screen at all; every subsequent call rides whatever the app's floor already + established (cookie, or a gateway's header). Nothing to configure, and no way to ship + an unauthenticated console by accident — proven in the smoke: the console mapped on + the auth-floored host answers 401 for its own page. + 2. **Cross-service.** The console sends its calls with credentials, so each additional + service must (a) sit under the same auth as the console's origin, or (b) allow that + origin explicitly in CORS *with* credentials. There is no third option that does not + involve a token the console would have to hold. + 3. **A token the console holds is NOT built.** A browser-side OIDC client would put an + identity library in the dist and a per-adopter authority/clientId in config, which is + a different product decision from "the console is a client of the app's floor". Until + an adopter needs it, the honest answer is (1) and (2) — and the console says so when + a service refuses: a call blocked by CORS is reported as UNREACHABLE, never as an + absent module, because "we could not ask" and "they do not have it" are different + sentences. - **External products**: Mockifyr (mocks) and other products appear as configured LINK tiles — their UI is theirs. diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md index 08d5abf..2adac7b 100644 --- a/docs/strategy/open-threads.md +++ b/docs/strategy/open-threads.md @@ -18,6 +18,7 @@ pending forever. | T5 | **LLM-half skill evals** — running the skills themselves per fixture | Deterministic acceptance runs nightly; running the skills needs agent-in-CI | agent-in-CI exists | a skill that fails its evals cannot be released ([ai-sdlc-status.md](ai-sdlc-status.md) §2) | | T6 | **CLI-as-MCP** and **plugin packaging** | Both are distribution shapes with no adopter asking yet | CLI-as-MCP: the Insurance sample's first run · packaging: the first brownfield adopter | the shape is exercised by that adopter's own flow, not by a demo we write for it | | T7 | **`Goldpath.Ai`** (runtime AI as an opt-in module, ADR-0011) | The console phase owns the calendar; the RFC is written and the ADR is accepted | after the UI phase | the module composes in and out cleanly (a manifest without it has NO AI code), and its evals run in the same lane as the rest | +| T10 | A **token the console holds** (browser-side OIDC) | The console is a client of the app's own floor: served behind it, it needs no identity library and no per-adopter authority/clientId. Building one is a different product decision | an adopter whose services cannot share an auth boundary or allow the console's origin in CORS | the console signs in once and carries the token to every service, with the same honesty about refusals it has now (console RFC §3 auth, case 3) | | T9 | Client-side ROUTES for the console (a section is state today, not a URL) | Nobody has asked to bookmark a panel yet, and a fake fallback is worse than none: serving the page at an arbitrary path answers 200 with a document whose relative assets resolve a directory too deep — a blank screen with a green status code | the first operator who wants to share a link to a panel | `MapGoldpathConsole` serves the page for real routes AND injects a `` so assets resolve; the served-console e2e stops asserting 404 and asserts the panel | | T8 | Bulk's **`?definition=` filter** on `/batches` ([#72](https://github.com/omercelikdev/goldpath/issues/72)) | The console refuses to fake it client-side: narrowing one take-bounded page would read as "no batches" while more exist | the next bulk surface change | the panel filters by definition through the SERVER, and the smoke walks a definition with more batches than one page | diff --git a/ui/console/e2e/served.spec.ts b/ui/console/e2e/served.spec.ts index 8f51914..f6ebd45 100644 --- a/ui/console/e2e/served.spec.ts +++ b/ui/console/e2e/served.spec.ts @@ -49,4 +49,14 @@ test.describe("the console served by the app itself", () => { expect(response.status()).toBe(404); }); + + test("the console served by an AUTH-FLOORED app refuses its own page", async ({ page }) => { + // The most important property of the auth story: an adopter cannot ship an + // unauthenticated console by accident. The page itself is behind the ops floor, so an + // operator without a principal never reaches a screen to be confused by. + const secured = process.env.GOLDPATH_SECURED_URL ?? "http://localhost:5312"; + const response = await page.request.get(`${secured}/goldpath/console/`); + + expect(response.status()).toBe(401); + }); }); diff --git a/ui/console/src/ConsoleApp.test.tsx b/ui/console/src/ConsoleApp.test.tsx index 7263cca..7992820 100644 --- a/ui/console/src/ConsoleApp.test.tsx +++ b/ui/console/src/ConsoleApp.test.tsx @@ -138,6 +138,21 @@ describe("the console across services", () => { expect(screen.queryByLabelText(/service/i)).toBeNull(); }); + it("a service that never answers is reported as unreachable, on Today and in its own screen", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("console.config.json")) return new Response("", { status: 404 }); + throw new TypeError("network down"); // what a blocked or dead service looks like + }) as typeof fetch; + + render(); + + // Today says it first — a blind spot outranks everything, including nothing. + expect(await screen.findByText(/did not answer at all/)).toBeInTheDocument(); + // And there is no module nav to click into: every link would say the same thing. + expect(screen.queryByRole("button", { name: "Runs" })).toBeNull(); + }); + it("a triage row opens the panel it belongs to, on the SERVICE it belongs to", async () => { const fetcher = (async (input: RequestInfo | URL) => { const url = String(input); diff --git a/ui/console/src/ConsoleApp.tsx b/ui/console/src/ConsoleApp.tsx index ed3ef4e..f07a9e9 100644 --- a/ui/console/src/ConsoleApp.tsx +++ b/ui/console/src/ConsoleApp.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { AppShell, Banner } from "@goldpath/kit"; import type { ShellNavItem } from "@goldpath/kit"; import { AdminClient, type ModuleName } from "./adminClient"; -import { composedSections, SECTION_LABEL, ServicePanels, type Capabilities } from "./sections"; +import { composedSections, isUnreachable, SECTION_LABEL, ServicePanels, type Capabilities } from "./sections"; import { TriageHome } from "./TriageHome"; import { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry"; @@ -79,7 +79,9 @@ export function ConsoleApp({ title = "Goldpath console", fetcher, search, now }: const nav: ShellNavItem[] = [ { id: TODAY, label: "Today", onSelect: () => setSection(TODAY) }, - ...composedSections(capabilities).map((module) => ({ + // A service that never answered has no sections to offer: the nav would be a list of + // links that each say the same thing. + ...(isUnreachable(capabilities) ? [] : composedSections(capabilities)).map((module) => ({ id: module, label: SECTION_LABEL[module], onSelect: () => setSection(module), diff --git a/ui/console/src/adminClient.test.ts b/ui/console/src/adminClient.test.ts index 9d5bb02..bba3ec7 100644 --- a/ui/console/src/adminClient.test.ts +++ b/ui/console/src/adminClient.test.ts @@ -78,14 +78,18 @@ describe("the admin client (the console's only door — the FROZEN contract)", ( expect(capabilities.jobs).toEqual({ kind: "refused", message: undefined }); }); - it("an unreachable service yields no panels instead of crashing the console", async () => { + it("a service that never ANSWERS is unreachable, not module-less", async () => { + // A dead service and a cross-origin one whose CORS refuses this console look identical + // from here — fetch reports nothing more. Both are "we could not ask", which is a + // different sentence from "the app does not have this module", and the console must + // not collapse the two: only one of them means stop looking. const fetcher = (async () => { throw new TypeError("network down"); }) as typeof fetch; const capabilities = await new AdminClient({ fetcher }).discoverCapabilities(); - expect(MODULES.every((module) => capabilities[module].kind === "absent")).toBe(true); + expect(MODULES.every((module) => capabilities[module].kind === "unreachable")).toBe(true); }); it("clamps take to the contract's [1,500] before the request leaves the browser", async () => { diff --git a/ui/console/src/adminClient.ts b/ui/console/src/adminClient.ts index c3371ad..69b0f19 100644 --- a/ui/console/src/adminClient.ts +++ b/ui/console/src/adminClient.ts @@ -34,7 +34,15 @@ export type Capability = | { kind: "present" } | { kind: "absent" } | { kind: "forbidden"; message?: string } - | { kind: "refused"; message?: string }; + | { kind: "refused"; message?: string } + /** + * The probe never got an answer: the service is down, the network is in the way, or the + * browser blocked the call (a cross-origin service whose CORS does not allow this + * console's origin looks EXACTLY like this from here — fetch reports nothing more). + * Distinct from `absent` on purpose: "the app does not have this module" and "we could + * not ask" are different sentences, and only one of them is a reason to stop looking. + */ + | { kind: "unreachable" }; /** * Pulls the human sentence out of a refusal. Goldpath's own envelope says `message`; @@ -336,6 +344,7 @@ export class AdminClient { * revision R1). Reading that as "absent" would tell the operator the module is not * composed, which is a lie the console must never tell. * - anything else non-ok → `absent`, honestly: nothing usable answered. + * - a call that THREW → `unreachable`: down, blocked, or cross-origin without CORS. */ async discoverCapabilities(): Promise> { const entries = await Promise.all( @@ -356,7 +365,7 @@ export class AdminClient { return [module, { kind: response.ok ? "present" : "absent" } as Capability] as const; } catch { - return [module, { kind: "absent" } as Capability] as const; // unreachable: no panel, no crash + return [module, { kind: "unreachable" } as Capability] as const; // asked, never answered } }), ); diff --git a/ui/console/src/sections.test.tsx b/ui/console/src/sections.test.tsx index 2ae1c62..3e9c571 100644 --- a/ui/console/src/sections.test.tsx +++ b/ui/console/src/sections.test.tsx @@ -2,8 +2,11 @@ import { render, screen } from "@testing-library/react"; import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient"; import { composedSections, ServicePanels, type Capabilities } from "./sections"; -const capabilities = (over: Partial> = {}): Capabilities => - Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: "absent" }])) as Capabilities; +const capabilities = ( + over: Partial> = {}, + fallback: Capability["kind"] = "absent", +): Capabilities => + Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: fallback }])) as Capabilities; const client = new AdminClient({ fetcher: (async () => new Response("[]", { status: 200 })) as typeof fetch }); @@ -47,6 +50,14 @@ describe("one service's panels", () => { } }); + it("a service that did not answer says SO — it is not an app without modules", () => { + render(); + + expect(screen.getByRole("alert")).toHaveTextContent(/did not answer at all/); + expect(screen.getByRole("alert")).toHaveTextContent(/CORS policy/); + expect(screen.queryByText(/composes none of them/)).toBeNull(); + }); + it("composedSections keeps the standard order, whatever order the probes answered in", () => { expect(composedSections(capabilities({ campaign: { kind: "present" }, jobs: { kind: "forbidden" } }))).toEqual([ "jobs", diff --git a/ui/console/src/sections.tsx b/ui/console/src/sections.tsx index 3eab1a1..be83302 100644 --- a/ui/console/src/sections.tsx +++ b/ui/console/src/sections.tsx @@ -22,6 +22,11 @@ export function composedSections(capabilities: Capabilities | null): ModuleName[ return capabilities ? MODULES.filter((module) => capabilities[module].kind !== "absent") : []; } +/** True when NOTHING answered — the service is down or blocking us, not module-less. */ +export function isUnreachable(capabilities: Capabilities | null): boolean { + return capabilities !== null && MODULES.every((module) => capabilities[module].kind === "unreachable"); +} + export interface ServicePanelsProps { client: AdminClient; capabilities: Capabilities | null; @@ -42,10 +47,20 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan return

Discovering capabilities…

; } + if (isUnreachable(capabilities)) { + return ( + + This service did not answer at all — it may be down, or its CORS policy may not allow this console's + origin. That is different from an app that composes no Goldpath module, and the console will not + pretend otherwise. + + ); + } + if (composedSections(capabilities).length === 0) { return (

- No Goldpath admin surface answered here — this app composes none, or the service is unreachable. + No Goldpath admin surface answered here — this app composes none of them.

); } @@ -61,6 +76,12 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan )} + {capability.kind === "unreachable" && ( + + {SECTION_LABEL[section]} did not answer — the service may be down, or blocking this console's origin. + + )} + {capability.kind === "refused" && ( {SECTION_LABEL[section]} is composed here but refused this request. diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts index 0dcf327..a73d222 100644 --- a/ui/console/src/triage.test.ts +++ b/ui/console/src/triage.test.ts @@ -304,6 +304,32 @@ describe("triage — what is wrong, read from the contract's own lists", () => { expect(rows[0].detail).toContain("the 'goldpath-ops' role is required"); }); + it("a service that never answered is named as such, not as five unreadable surfaces", async () => { + const dead = Object.fromEntries(MODULES.map((module) => [module, { kind: "unreachable" }])) as Capabilities; + + const rows = await collectServiceTriage("claims", client({}), dead, NOW); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ blind: true, tone: "danger" }); + expect(rows[0].headline).toBe("claims did not answer at all"); + expect(rows[0].detail).toContain("blocking this console's origin"); + }); + + it("ONE dead probe among honest refusals is not an outage — the count is told, not dramatised", async () => { + const mixed = Object.fromEntries( + MODULES.map((module, index) => [ + module, + index === 0 ? { kind: "unreachable" } : { kind: "forbidden", message: "the 'goldpath-ops' role is required" }, + ]), + ) as Capabilities; + + const rows = await collectServiceTriage("payments", client({}), mixed, NOW); + + expect(rows).toHaveLength(1); + expect(rows[0].headline).toBe("5 surfaces on payments cannot be read"); + expect(rows[0].headline).not.toContain("did not answer at all"); + }); + it("one unreadable surface reads in the singular, and without a message says what it can", async () => { const rows = await collectServiceTriage( "payments", diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts index 0837c26..b68c0e4 100644 --- a/ui/console/src/triage.ts +++ b/ui/console/src/triage.ts @@ -1,6 +1,6 @@ import { deadlineVerdict, humanizeSeconds } from "@goldpath/kit"; import { MODULES, type AdminClient, type ModuleName } from "./adminClient"; -import type { Capabilities } from "./sections"; +import { isUnreachable, type Capabilities } from "./sections"; /** * One thing worth an operator's attention. `tone` is the semantic ramp, not a priority @@ -54,7 +54,7 @@ export async function collectServiceTriage( // identical "forbidden" rows would bury the estate's real problems under our own. const unreadable = MODULES.filter((module) => { const kind = capabilities[module].kind; - return kind === "forbidden" || kind === "refused"; + return kind === "forbidden" || kind === "refused" || kind === "unreachable"; }); if (unreadable.length > 0) { @@ -65,8 +65,18 @@ export async function collectServiceTriage( section: unreadable[0], tone: "danger", blind: true, - headline: `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`, - detail: said ? `the service said: “${said}”` : "this operator may not see them, or the request cannot be scoped", + // "Did not answer AT ALL" must mean exactly that — EVERY probe threw. One dead + // probe among four honest refusals is not an outage, and reporting it as one is the + // same false confidence this row exists to prevent (review R1 on this PR). The + // shell's own predicate is reused rather than re-derived. + headline: isUnreachable(capabilities) + ? `${service} did not answer at all` + : `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`, + detail: said + ? `the service said: “${said}”` + : isUnreachable(capabilities) + ? "it may be down, or blocking this console's origin — triage cannot speak for it" + : "this operator may not see them, or the request cannot be scoped", }); }