Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions docs/rfc/goldpath-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/strategy/open-threads.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base href>` 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 |

Expand Down
10 changes: 10 additions & 0 deletions ui/console/e2e/served.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
15 changes: 15 additions & 0 deletions ui/console/src/ConsoleApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ConsoleApp fetcher={fetcher} search="" />);

// 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);
Expand Down
6 changes: 4 additions & 2 deletions ui/console/src/ConsoleApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
Expand Down
8 changes: 6 additions & 2 deletions ui/console/src/adminClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
13 changes: 11 additions & 2 deletions ui/console/src/adminClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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<Record<ModuleName, Capability>> {
const entries = await Promise.all(
Expand 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
}
}),
);
Expand Down
15 changes: 13 additions & 2 deletions ui/console/src/sections.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ModuleName, Capability>> = {}): Capabilities =>
Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: "absent" }])) as Capabilities;
const capabilities = (
over: Partial<Record<ModuleName, Capability>> = {},
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 });

Expand Down Expand Up @@ -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(<ServicePanels client={client} capabilities={capabilities({}, "unreachable")} section="jobs" />);

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",
Expand Down
23 changes: 22 additions & 1 deletion ui/console/src/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,10 +47,20 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan
return <p className="text-sm text-muted-foreground">Discovering capabilities…</p>;
}

if (isUnreachable(capabilities)) {
return (
<Banner tone="danger" live="alert">
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.
</Banner>
);
}

if (composedSections(capabilities).length === 0) {
return (
<p className="text-sm text-muted-foreground">
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.
</p>
);
}
Expand All @@ -61,6 +76,12 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan
</Banner>
)}

{capability.kind === "unreachable" && (
<Banner tone="danger" live="alert">
{SECTION_LABEL[section]} did not answer — the service may be down, or blocking this console's origin.
</Banner>
)}

{capability.kind === "refused" && (
<Banner tone="warning">
{SECTION_LABEL[section]} is composed here but refused this request.
Expand Down
26 changes: 26 additions & 0 deletions ui/console/src/triage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 14 additions & 4 deletions ui/console/src/triage.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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",
});
}

Expand Down
Loading