diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 00b3e62..50a6f2a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,6 +23,20 @@ jobs:
dotnet-version: |
8.0.x
10.0.x
+ # The console dist is BUILT here and embedded by Goldpath.Console: this is the ONLY
+ # place Node is needed, and it is why adopters never run it. The package refuses to
+ # pack without it (see its `RefuseAnEmptyConsole` target).
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ cache-dependency-path: ui/console/pnpm-lock.yaml
+ - name: build the console
+ run: bash scripts/build-console.sh
+
- name: sanity build
run: dotnet build Goldpath.sln --nologo -c Release
- name: pack the train
diff --git a/.gitignore b/.gitignore
index f46f5d0..f0e8ce5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,8 @@ coverage/
# The console's registry is per-DEPLOYMENT config; the smoke writes its own.
ui/console/public/console.config.json
+
+# The console dist is BUILT (scripts/build-console.sh), never committed: it changes with
+# every UI edit and CI lays it before packing.
+packages/Goldpath.Console/wwwroot/*
+!packages/Goldpath.Console/wwwroot/.gitkeep
diff --git a/CLAUDE.md b/CLAUDE.md
index bf91e9a..e62fd2e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,8 +26,9 @@ not propose anything that contradicts them; changes only via a superseding ADR).
Phases 0–1 and the hardening set (H1–H8) are complete; the `0.1.0-preview.3` train is on
nuget.org (plus `specdrift` 0.4.2 as tool/MCP/Docker/Action). Phase D shipped the CorPay
reference app (`samples/corpay`, proven nightly against the published packages). The UI
-phase is active (`docs/rfc/goldpath-console.md`: U1–U3 met — all five module
-panels are proven nightly by the console smoke against one real app; U4 next). Live status ledgers —
+phase is active (`docs/rfc/goldpath-console.md`: U1–U3 met, U4 in flight —
+the registry, the triage home and `MapGoldpathConsole()` ship; the console smoke drives
+three real apps and the app-SERVED console). Live status ledgers —
keep them updated in the same PR that changes reality: `docs/strategy/ai-sdlc-status.md`
(AI-assisted SDLC vs reality), `docs/strategy/coverage-matrix.md` (capability × sample),
and `docs/strategy/open-threads.md` (deferred work with its TRIGGER and the proof that
diff --git a/Goldpath.sln b/Goldpath.sln
index 1951a98..14a4f80 100644
--- a/Goldpath.sln
+++ b/Goldpath.sln
@@ -101,6 +101,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Campaign", "packag
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Campaign.Tests", "tests\Goldpath.Campaign.Tests\Goldpath.Campaign.Tests.csproj", "{FF196F5A-D8A4-45D1-9EC8-A4A1EABEBC52}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Console", "packages\Goldpath.Console\Goldpath.Console.csproj", "{1616453C-824E-42DB-8FB1-63E824736568}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Console.Tests", "tests\Goldpath.Console.Tests\Goldpath.Console.Tests.csproj", "{C44229F0-A168-466E-8E29-EC6321827C59}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -639,6 +643,30 @@ Global
{FF196F5A-D8A4-45D1-9EC8-A4A1EABEBC52}.Release|x64.Build.0 = Release|Any CPU
{FF196F5A-D8A4-45D1-9EC8-A4A1EABEBC52}.Release|x86.ActiveCfg = Release|Any CPU
{FF196F5A-D8A4-45D1-9EC8-A4A1EABEBC52}.Release|x86.Build.0 = Release|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Debug|x64.Build.0 = Debug|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Debug|x86.Build.0 = Debug|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Release|x64.ActiveCfg = Release|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Release|x64.Build.0 = Release|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Release|x86.ActiveCfg = Release|Any CPU
+ {1616453C-824E-42DB-8FB1-63E824736568}.Release|x86.Build.0 = Release|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Debug|x64.Build.0 = Debug|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Debug|x86.Build.0 = Debug|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Release|x64.ActiveCfg = Release|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Release|x64.Build.0 = Release|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Release|x86.ActiveCfg = Release|Any CPU
+ {C44229F0-A168-466E-8E29-EC6321827C59}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -688,5 +716,7 @@ Global
{74BC3CD6-452C-40BB-A225-9D430138979D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{64264F48-A69F-48B3-911F-5C3536ECA88F} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
{FF196F5A-D8A4-45D1-9EC8-A4A1EABEBC52} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {1616453C-824E-42DB-8FB1-63E824736568} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
+ {C44229F0-A168-466E-8E29-EC6321827C59} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md
index 3360945..bea2c64 100644
--- a/docs/rfc/goldpath-console.md
+++ b/docs/rfc/goldpath-console.md
@@ -58,7 +58,14 @@ custom-develop ON, with the same kit, the same way they add features to the back
- **D1 — Stack: React + Tailwind, dist-shipped (supersedes the earlier RCL wording).**
One design system and one stack across the product family (Mockifyr/Praxis lineage);
adopters NEVER run Node — CI builds the dist, `Goldpath.Console` ships it as embedded
- static assets served by `MapGoldpathConsole()` on the management head. The "no Node
+ static assets served by `MapGoldpathConsole()` on the management head. SHIPPED (U4,
+ 2026-07-27): the package refuses to PACK without a built console; the registry comes
+ from the app's own configuration (`AddService`), not a JSON file someone has to copy
+ beside the dist; the console sits behind the same ops floor as the admin surfaces; a
+ missing asset 404s rather than being answered with the page (a blank screen with a green
+ status code is the worst failure a console can have); and an unknown path 404s too,
+ because the console has no client-side routes YET — when it gains them, the server must
+ also inject a `` (open-threads T9). The "no Node
in generated apps" principle holds by construction. Custom pages: the kit npm package
for teams that build UI, and a config-driven link/iframe-free tile row for those that
do not.
diff --git a/docs/strategy/ai-sdlc-status.md b/docs/strategy/ai-sdlc-status.md
index 4713f49..0546cff 100644
--- a/docs/strategy/ai-sdlc-status.md
+++ b/docs/strategy/ai-sdlc-status.md
@@ -30,7 +30,7 @@ document exists to prevent.
| Skill quality (evals) | eval set per skill; a skill that fails evals is not released (§6.2) | **PARTIAL** — as of 2026-07-24 the 4 acceptance runners are portable (published specdrift tool, no local-checkout assumption; failures print their output) and the deterministic half runs nightly (`evals-acceptance`: runner syntax + breaker vs CorPay). The LLM half — running the skills themselves per fixture — stays deferred until an agent-in-CI story exists (P2) | nightly `evals-acceptance` job | agent-in-CI story (P2) unlocks full skill-run evals |
| Model proficiency matrix | skill × model → pass-rate matrix (§6.2) | **NOT BUILT** — Phase 2 (needs the eval lane first) | — | after the eval lane |
| Release / DevOps | release train + delivery telemetry (§6.5) | Train **SHIPPED-PROVEN** (OIDC trusted publishing, license gate, roll script); delivery report **NOT BUILT** — Phase 2 | release workflow + license gate | — |
-| Operations | admin APIs + ops packs + console (§7.1) | Admin APIs + dashboards + runbooks **SHIPPED-PROVEN** per module; console **PARTIAL** (U1–U3 met: the run console plus all four module panels, each driving its module's real verbs in the nightly smoke; U4 — cross-service registry, triage home, auth story — open) | admin contract frozen + integration proofs | console phases U1–U4 |
+| Operations | admin APIs + ops packs + console (§7.1) | Admin APIs + dashboards + runbooks **SHIPPED-PROVEN** per module; console **PARTIAL** (U1–U3 met; U4 in flight — the cross-service registry, the TODAY triage home and `MapGoldpathConsole()` ship, each proven in the nightly smoke against three real apps; the auth story and the CorPay proof remain) | admin contract frozen + integration proofs | console phases U1–U4 |
| Pipeline (chained skills + human gates) | §6.1 level 3 | **NOT BUILT** — Phase 2 by written deferral (§12) | — | — |
| Domain memory | project knowledge that grows with every run (§6.3) | **NOT BUILT** — `domain-memory-v1.md` is a v0.1 draft | — | Phase 2/3 |
diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md
index 248d126..08d5abf 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 |
+| 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 |
## Closed threads
diff --git a/packages/Goldpath.Console/Goldpath.Console.csproj b/packages/Goldpath.Console/Goldpath.Console.csproj
new file mode 100644
index 0000000..544a59b
--- /dev/null
+++ b/packages/Goldpath.Console/Goldpath.Console.csproj
@@ -0,0 +1,46 @@
+
+
+
+ net8.0;net10.0
+ Goldpath.Console
+ The Goldpath operations console, served by YOUR management head: MapGoldpathConsole() maps the built single-page console as embedded static assets, behind the same ops floor as the admin surfaces. Adopters never run Node — the dist is built once by Goldpath's CI and shipped inside this package.
+ Goldpath
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/Goldpath.Console/GoldpathConsoleEndpoints.cs b/packages/Goldpath.Console/GoldpathConsoleEndpoints.cs
new file mode 100644
index 0000000..129ace2
--- /dev/null
+++ b/packages/Goldpath.Console/GoldpathConsoleEndpoints.cs
@@ -0,0 +1,123 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.FileProviders;
+
+namespace Goldpath;
+
+/// One service the console can drive: the operator's word for it, and its head.
+public sealed record GoldpathConsoleService(string Name, string AdminBaseUrl);
+
+///
+/// How the console is served. The registry lives HERE rather than in a file next to the
+/// dist: an adopter who already declares their services in configuration should not also
+/// have to drop JSON into a static folder.
+///
+public sealed class GoldpathConsoleOptions
+{
+ /// The cross-service registry. Empty means "this app only" — the common case.
+ public IList Services { get; } = [];
+
+ /// Adds a service the console should offer (console RFC §3).
+ public GoldpathConsoleOptions AddService(string name, string adminBaseUrl)
+ {
+ Services.Add(new GoldpathConsoleService(name, adminBaseUrl));
+ return this;
+ }
+}
+
+///
+/// Serves the Goldpath console from the app's own management head (console RFC D1).
+///
+/// The console is a single page that talks to the FROZEN admin contract with the
+/// operator's own credentials — it adds no capability the API does not already expose.
+/// Its assets are embedded in this package: adopters never run Node, and a generated app
+/// stays Node-free by construction.
+///
+///
+public static class GoldpathConsoleEndpoints
+{
+ private static readonly FileExtensionContentTypeProvider ContentTypes = new();
+
+ ///
+ /// Maps the console under , behind the SAME ops floor as the
+ /// admin surfaces (H2). `exposeUnsecured: true` is for hosts that have no auth at all
+ /// — the guard logs the choice, exactly as the admin mappers do.
+ ///
+ public static IEndpointRouteBuilder MapGoldpathConsole(
+ this IEndpointRouteBuilder endpoints,
+ string prefix = "/goldpath/console",
+ bool exposeUnsecured = false,
+ Action? configure = null)
+ {
+ var options = new GoldpathConsoleOptions();
+ configure?.Invoke(options);
+
+ var assembly = typeof(GoldpathConsoleEndpoints).Assembly;
+ // The resource prefix follows the ROOT NAMESPACE (Goldpath), not the assembly name —
+ // `Goldpath.Console.wwwroot` would find nothing and every request would 404.
+ var files = new EmbeddedFileProvider(assembly, "Goldpath.wwwroot");
+ var group = endpoints.MapGroup(prefix);
+ AdminSurfaceGuard.Apply(endpoints, group, prefix, exposeUnsecured);
+
+ // The registry, as the console reads it. Served from CONFIG so the same dist works
+ // for every adopter; an empty registry is simply absent, which the console reads
+ // as "this service only".
+ group.MapGet("/console.config.json", () =>
+ {
+ var payload = JsonSerializer.Serialize(new
+ {
+ services = options.Services.Select(service => new { name = service.Name, adminBaseUrl = service.AdminBaseUrl }),
+ });
+ return Results.Content(payload, "application/json", Encoding.UTF8);
+ });
+
+ group.MapGet("/{**path}", (string? path) => Serve(files, path));
+
+ return endpoints;
+ }
+
+ ///
+ /// Resolves one request against the embedded console. Internal rather than private so
+ /// the three outcomes an operator can hit — the page, a missing asset, and a package
+ /// built without a console — are proven deterministically instead of depending on
+ /// whether the machine running the tests happened to build the dist.
+ ///
+ internal static IResult Serve(IFileProvider files, string? path)
+ {
+ var requested = path?.Replace('\\', '/').Trim('/') ?? "";
+
+ // The PAGE is the prefix root, and only the root. The console has no client-side
+ // routes yet (sections are state, not URLs), so there is nothing to fall back TO:
+ // serving the page at an arbitrary path would answer 200 with a document whose
+ // relative asset URLs resolve one directory too deep — a blank screen with a green
+ // status code. When real routes arrive, this is the place that must also inject a
+ // (open-threads T9).
+ if (requested.Length == 0)
+ {
+ var page = files.GetFileInfo("index.html");
+ return page.Exists
+ ? Results.Stream(page.CreateReadStream(), ContentTypeOf("index.html"))
+ : Results.Problem(
+ "This build of Goldpath.Console carries no console. The package is built by Goldpath's CI from ui/console; a locally built one needs scripts/build-console.sh first.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ var asset = files.GetFileInfo(requested);
+ // A missing asset must 404 rather than answer with HTML the browser cannot parse —
+ // that is how a console "loads" into a blank screen with nothing in the log.
+ return asset.Exists ? Results.Stream(asset.CreateReadStream(), ContentTypeOf(requested)) : Results.NotFound();
+ }
+
+ ///
+ /// The framework's own extension→type map (ADR-0003: compose, never rewrite). Anything
+ /// it does not know is served as bytes rather than guessed at — a guessed content type
+ /// is how a console ends up rendered as text.
+ ///
+ private static string ContentTypeOf(string path)
+ => ContentTypes.TryGetContentType(path, out var contentType) ? contentType : "application/octet-stream";
+
+}
diff --git a/packages/Goldpath.Console/PublicAPI.Shipped.txt b/packages/Goldpath.Console/PublicAPI.Shipped.txt
new file mode 100644
index 0000000..7dc5c58
--- /dev/null
+++ b/packages/Goldpath.Console/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/packages/Goldpath.Console/PublicAPI.Unshipped.txt b/packages/Goldpath.Console/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000..77b4762
--- /dev/null
+++ b/packages/Goldpath.Console/PublicAPI.Unshipped.txt
@@ -0,0 +1,13 @@
+#nullable enable
+Goldpath.GoldpathConsoleEndpoints
+Goldpath.GoldpathConsoleOptions
+Goldpath.GoldpathConsoleOptions.AddService(string! name, string! adminBaseUrl) -> Goldpath.GoldpathConsoleOptions!
+Goldpath.GoldpathConsoleOptions.GoldpathConsoleOptions() -> void
+Goldpath.GoldpathConsoleOptions.Services.get -> System.Collections.Generic.IList!
+Goldpath.GoldpathConsoleService
+Goldpath.GoldpathConsoleService.AdminBaseUrl.get -> string!
+Goldpath.GoldpathConsoleService.AdminBaseUrl.init -> void
+Goldpath.GoldpathConsoleService.GoldpathConsoleService(string! Name, string! AdminBaseUrl) -> void
+Goldpath.GoldpathConsoleService.Name.get -> string!
+Goldpath.GoldpathConsoleService.Name.init -> void
+static Goldpath.GoldpathConsoleEndpoints.MapGoldpathConsole(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefix = "/goldpath/console", bool exposeUnsecured = false, System.Action? configure = null) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!
diff --git a/packages/Goldpath.Console/README.md b/packages/Goldpath.Console/README.md
new file mode 100644
index 0000000..3f48152
--- /dev/null
+++ b/packages/Goldpath.Console/README.md
@@ -0,0 +1,21 @@
+# Goldpath.Console
+
+The Goldpath operations console, served by **your** management head.
+
+```csharp
+app.MapGoldpathConsole(); // same-origin, this service only
+app.MapGoldpathConsole(configure: console => console
+ .AddService("payments", "https://payments.internal")
+ .AddService("claims", "https://claims.internal")); // one console, many services
+```
+
+- The console is a **client of the frozen admin contract** — it adds no capability the API
+ does not already expose, and it carries the operator's own credentials.
+- Its assets are **embedded in this package**: adopters never run Node, and a generated app
+ stays Node-free by construction.
+- It sits behind the **same ops floor** as the admin surfaces (`goldpath-ops`). Pass
+ `exposeUnsecured: true` only for a host with no auth at all — the guard logs that choice.
+- The cross-service registry is **configuration**, not a file you drop beside the dist:
+ `AddService(...)` is what the console reads at startup.
+
+Full design: `docs/rfc/goldpath-console.md`.
diff --git a/packages/Goldpath.Console/wwwroot/.gitkeep b/packages/Goldpath.Console/wwwroot/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/build-console.sh b/scripts/build-console.sh
new file mode 100755
index 0000000..546e56e
--- /dev/null
+++ b/scripts/build-console.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Builds the console's dist and lays it inside Goldpath.Console, which embeds it.
+#
+# scripts/build-console.sh
+#
+# This is the ONLY place Node is needed. Adopters never run it: Goldpath's CI runs it
+# before packing, and the package ships the result.
+set -euo pipefail
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+DIST="$ROOT/ui/console/dist"
+TARGET="$ROOT/packages/Goldpath.Console/wwwroot"
+
+echo "── building the console"
+cd "$ROOT/ui/console"
+pnpm install --frozen-lockfile
+pnpm exec vite build
+
+[ -f "$DIST/index.html" ] || { echo "the build produced no index.html — refusing to lay an empty console"; exit 1; }
+
+echo "── laying it into Goldpath.Console"
+find "$TARGET" -mindepth 1 ! -name ".gitkeep" -delete
+cp -R "$DIST"/. "$TARGET"/
+# Vite hashes asset names, so a stale file left behind would be served forever by a route
+# nothing links to — the delete above is what keeps the package honest.
+echo "── console laid: $(find "$TARGET" -type f ! -name '.gitkeep' | wc -l | tr -d ' ') files"
diff --git a/scripts/console-smoke.sh b/scripts/console-smoke.sh
index 6ac76ae..a8304d6 100755
--- a/scripts/console-smoke.sh
+++ b/scripts/console-smoke.sh
@@ -34,6 +34,9 @@ cleanup() {
}
trap cleanup EXIT
+echo "── the console dist (this is the ONLY place Node is needed — adopters never run it)"
+bash "$ROOT/scripts/build-console.sh" >/dev/null
+
echo "── postgres"
docker rm -f "$PG_NAME" >/dev/null 2>&1 || true
docker run -d --name "$PG_NAME" -e POSTGRES_PASSWORD=smoke -e POSTGRES_DB=smoke -p 55432:5432 postgres:17-alpine >/dev/null
@@ -64,6 +67,7 @@ docker exec "$MQ_NAME" rabbitmq-diagnostics -q check_port_connectivity >/dev/nul
echo "── the app (real packages, real Quartz, real broker, the FROZEN admin surface)"
GOLDPATH_CONSOLE_ORIGIN="$CONSOLE_URL" \
+ GOLDPATH_CONSOLE_SERVICES="open=$SERVICE_URL;auth-floored=$SECURED_URL;tenant-scoped=$TENANT_URL" \
dotnet run --project "$ROOT/tests/Goldpath.Jobs.TestHost" -- "$CONNECTION" --console "$SERVICE_URL" --broker "$BROKER" --fleet console-smoke > /tmp/console-smoke-host.log 2>&1 &
HOST_PID=$!
for _ in $(seq 1 60); do
@@ -118,5 +122,6 @@ curl -sf "$CONSOLE_URL" >/dev/null || { echo "the console never came up:"; tail
echo "── playwright"
cd "$ROOT/ui/console"
GOLDPATH_CONSOLE_URL="$CONSOLE_URL" GOLDPATH_SERVICE_URL="$SERVICE_URL" \
+ GOLDPATH_SERVED_CONSOLE_URL="$SERVICE_URL/goldpath/console/" \
GOLDPATH_SECURED_URL="$SECURED_URL" GOLDPATH_TENANT_URL="$TENANT_URL" \
pnpm exec playwright test
diff --git a/tests/Goldpath.Console.Tests/Goldpath.Console.Tests.csproj b/tests/Goldpath.Console.Tests/Goldpath.Console.Tests.csproj
new file mode 100644
index 0000000..c8fd801
--- /dev/null
+++ b/tests/Goldpath.Console.Tests/Goldpath.Console.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Goldpath.Console.Tests/MapGoldpathConsoleTests.cs b/tests/Goldpath.Console.Tests/MapGoldpathConsoleTests.cs
new file mode 100644
index 0000000..02de65f
--- /dev/null
+++ b/tests/Goldpath.Console.Tests/MapGoldpathConsoleTests.cs
@@ -0,0 +1,176 @@
+using System.Net;
+using System.Net.Http.Json;
+using Goldpath;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
+using Xunit;
+
+namespace Goldpath.Console.Tests;
+
+///
+/// What the package OWNS: the routes, the registry it serves from configuration, the ops
+/// floor, and the difference between "this is the single page" and "this asset is missing".
+/// Whether the embedded console itself renders is proven by the console smoke, which
+/// builds the real dist and drives it in a browser — a unit test asserting bytes it also
+/// wrote would prove nothing.
+///
+public class MapGoldpathConsoleTests
+{
+ private static async Task HostAsync(Action? configure = null, bool secured = false)
+ {
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.WebHost.UseTestServer();
+ if (secured)
+ {
+ // The REAL auth floor, composed the way an adopter composes it (ADR-0003):
+ // strategy None wires the deny-only scheme and the ops policies, so a guarded
+ // route answers an honest 401 rather than failing at request time.
+ builder.AddGoldpathAuth(auth => auth.Strategy = GoldpathAuthStrategy.None);
+ }
+
+ var app = builder.Build();
+ if (secured)
+ {
+ app.UseGoldpathAuth();
+ }
+
+ app.MapGoldpathConsole(exposeUnsecured: !secured, configure: configure);
+ await app.StartAsync();
+ return app.GetTestClient();
+ }
+
+ [Fact]
+ public async Task The_registry_is_served_from_configuration_not_from_a_file_beside_the_dist()
+ {
+ var client = await HostAsync(console => console
+ .AddService("payments", "https://payments.internal")
+ .AddService("claims", "https://claims.internal"));
+
+ // `GetFromJsonAsync` reads with JsonSerializerDefaults.Web, which is
+ // case-INSENSITIVE — the camelCase the endpoint emits binds to these PascalCase
+ // records without options (checked after review R5 asked whether it really does).
+ var registry = await client.GetFromJsonAsync("/goldpath/console/console.config.json");
+
+ Assert.NotNull(registry);
+ Assert.Collection(
+ registry!.Services,
+ first => Assert.Equal(("payments", "https://payments.internal"), (first.Name, first.AdminBaseUrl)),
+ second => Assert.Equal(("claims", "https://claims.internal"), (second.Name, second.AdminBaseUrl)));
+ }
+
+ [Fact]
+ public async Task An_app_that_configures_no_service_serves_an_EMPTY_registry_which_the_console_reads_as_this_service_only()
+ {
+ var client = await HostAsync();
+
+ var registry = await client.GetFromJsonAsync("/goldpath/console/console.config.json");
+
+ Assert.NotNull(registry);
+ Assert.Empty(registry!.Services);
+ }
+
+ [Fact]
+ public async Task A_missing_ASSET_is_a_404_never_the_page_itself()
+ {
+ var client = await HostAsync();
+
+ var response = await client.GetAsync("/goldpath/console/assets/never-built.css");
+
+ // Answering a stylesheet request with HTML is how a console "loads" and then shows
+ // an empty screen with no error anywhere.
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public void A_package_built_without_its_console_says_SO_instead_of_serving_nothing()
+ {
+ // Deterministic: an EMPTY provider is a package whose dist was never laid.
+ var result = GoldpathConsoleEndpoints.Serve(new NullFileProvider(), path: null);
+
+ var problem = Assert.IsAssignableFrom(result);
+ Assert.Equal(StatusCodes.Status500InternalServerError, problem.StatusCode);
+ }
+
+ [Fact]
+ public void An_unknown_path_is_404_while_the_console_has_no_client_side_routes()
+ {
+ var files = new FakeConsole(("index.html", ""));
+
+ var result = GoldpathConsoleEndpoints.Serve(files, "runs/it-cluster");
+
+ // Answering 200 with the page would look right and BE wrong: its relative asset
+ // URLs would resolve a directory too deep and the operator would get a blank
+ // screen with a green status code. When the console gains real routes, this test
+ // changes together with the the server must then inject.
+ Assert.Equal(StatusCodes.Status404NotFound, ((IStatusCodeHttpResult)result).StatusCode);
+ }
+
+ [Fact]
+ public void The_prefix_root_is_the_page_with_or_without_a_trailing_slash()
+ {
+ var files = new FakeConsole(("index.html", ""));
+
+ foreach (var path in new string?[] { null, "", "/" })
+ {
+ Assert.IsNotAssignableFrom(GoldpathConsoleEndpoints.Serve(files, path));
+ }
+ }
+
+ [Fact]
+ public void A_missing_ASSET_is_never_answered_with_the_page()
+ {
+ var files = new FakeConsole(("index.html", ""));
+
+ var result = GoldpathConsoleEndpoints.Serve(files, "assets/gone.css");
+
+ Assert.IsAssignableFrom(result);
+ Assert.Equal(StatusCodes.Status404NotFound, ((IStatusCodeHttpResult)result).StatusCode);
+ }
+
+ [Fact]
+ public async Task The_console_sits_behind_the_SAME_ops_floor_as_the_admin_surfaces()
+ {
+ var client = await HostAsync(secured: true);
+
+ var response = await client.GetAsync("/goldpath/console/");
+
+ // No principal, no console: it drives the admin surfaces, so it inherits their floor.
+ Assert.True(
+ response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
+ $"expected the ops floor to refuse, got {(int)response.StatusCode}");
+ }
+
+ /// An embedded console, faked: names and bytes, nothing else.
+ private sealed class FakeConsole(params (string Path, string Content)[] files) : IFileProvider
+ {
+ public IDirectoryContents GetDirectoryContents(string subpath) => NotFoundDirectoryContents.Singleton;
+
+ public IFileInfo GetFileInfo(string subpath)
+ => files.FirstOrDefault(file => file.Path == subpath) is { Path: not null } found
+ ? new FakeFile(found.Path, found.Content)
+ : new NotFoundFileInfo(subpath);
+
+ public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
+ }
+
+ private sealed class FakeFile(string name, string content) : IFileInfo
+ {
+ public bool Exists => true;
+ public long Length => Encoding.UTF8.GetByteCount(content);
+ public string? PhysicalPath => null;
+ public string Name => name;
+ public DateTimeOffset LastModified => DateTimeOffset.UnixEpoch;
+ public bool IsDirectory => false;
+ public Stream CreateReadStream() => new MemoryStream(Encoding.UTF8.GetBytes(content));
+ }
+
+ private sealed record Registry(List Services);
+
+ private sealed record Service(string Name, string AdminBaseUrl);
+}
diff --git a/tests/Goldpath.Jobs.TestHost/Goldpath.Jobs.TestHost.csproj b/tests/Goldpath.Jobs.TestHost/Goldpath.Jobs.TestHost.csproj
index c150365..2df304f 100644
--- a/tests/Goldpath.Jobs.TestHost/Goldpath.Jobs.TestHost.csproj
+++ b/tests/Goldpath.Jobs.TestHost/Goldpath.Jobs.TestHost.csproj
@@ -19,6 +19,7 @@
+
diff --git a/tests/Goldpath.Jobs.TestHost/Program.cs b/tests/Goldpath.Jobs.TestHost/Program.cs
index 5248fea..3b3f99e 100644
--- a/tests/Goldpath.Jobs.TestHost/Program.cs
+++ b/tests/Goldpath.Jobs.TestHost/Program.cs
@@ -273,6 +273,16 @@ static async Task RunConsoleHostAsync(
app.MapGoldpathBulkAdmin(exposeUnsecured: unsecured);
app.MapGoldpathNotificationAdmin(exposeUnsecured: unsecured);
app.MapGoldpathArchivalAdmin(exposeUnsecured: unsecured);
+ // The console SERVED BY THE APP — the shape adopters actually deploy (console RFC D1).
+ // Same origin as the admin surfaces, so no CORS is involved in this path at all.
+ app.MapGoldpathConsole(exposeUnsecured: unsecured, configure: console =>
+ {
+ foreach (var entry in (Environment.GetEnvironmentVariable("GOLDPATH_CONSOLE_SERVICES") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var parts = entry.Split('=', 2);
+ console.AddService(parts[0], parts.Length > 1 ? parts[1] : "");
+ }
+ });
// The webhook channel's destination: a real endpoint that really answers 200.
app.MapPost("/smoke/hook", () => Results.Ok());
if (brokerUri is not null)
diff --git a/ui/console/e2e/served.spec.ts b/ui/console/e2e/served.spec.ts
new file mode 100644
index 0000000..8f51914
--- /dev/null
+++ b/ui/console/e2e/served.spec.ts
@@ -0,0 +1,52 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * The shape adopters actually deploy (console RFC D1): the console served BY the app from
+ * `MapGoldpathConsole()`, out of the package's embedded assets — no Node anywhere near the
+ * running system, no CORS in the path, and the registry coming from the app's own
+ * configuration rather than a file someone remembered to copy.
+ *
+ * The dev server proves the console's behaviour; this proves the console adopters get.
+ */
+const served = process.env.GOLDPATH_SERVED_CONSOLE_URL ?? "http://localhost:5310/goldpath/console/";
+
+test.describe("the console served by the app itself", () => {
+ test("loads from the embedded assets and reads the registry the APP configured", async ({ page }) => {
+ // Only the console's OWN assets are judged here: the capability probes are supposed
+ // to draw 401s and 400s from the auth-floored and tenant-scoped services — that is the
+ // discovery working, not the page failing.
+ const failures: string[] = [];
+ page.on("response", (response) => {
+ if (response.url().startsWith(served) && response.status() >= 400) {
+ failures.push(`${response.status()} ${response.url()}`);
+ }
+ });
+
+ await page.goto(served);
+
+ // The page came from the package, and its assets came with it.
+ await expect(page.getByTestId("triage-home")).toBeVisible();
+ expect(failures, "the served console must not 404 its own assets").toEqual([]);
+
+ // The registry is the app's configuration — three services, named by the host.
+ const picker = page.getByLabel(/service/i);
+ await expect(picker).toBeVisible();
+ await expect(picker.locator("option")).toHaveText(["open", "auth-floored", "tenant-scoped"]);
+ });
+
+ test("an unknown path 404s instead of serving a page that would render blank", async ({ page }) => {
+ // The console has no client-side routes yet: sections are state, not URLs. Answering
+ // this with the page would be 200 + a blank screen, because the page's relative asset
+ // URLs would resolve one directory too deep. Honest 404 until real routes exist
+ // (open-threads T9), and this test changes with them.
+ const response = await page.request.get(`${served}runs/it-cluster`);
+
+ expect(response.status()).toBe(404);
+ });
+
+ test("a missing ASSET is a 404, never the page dressed as a stylesheet", async ({ page }) => {
+ const response = await page.request.get(`${served}assets/never-built.css`);
+
+ expect(response.status()).toBe(404);
+ });
+});
diff --git a/ui/console/package.json b/ui/console/package.json
index 4285d81..ef89253 100644
--- a/ui/console/package.json
+++ b/ui/console/package.json
@@ -9,7 +9,8 @@
"test": "vitest run --coverage --coverage.thresholds.statements=97 --coverage.thresholds.branches=85 --coverage.thresholds.functions=75 --coverage.thresholds.lines=97",
"typecheck": "tsc --noEmit",
"dev": "vite",
- "e2e": "playwright test"
+ "e2e": "playwright test",
+ "build": "vite build"
},
"peerDependencies": {
"react": "^19.0.0",
diff --git a/ui/console/vite.config.ts b/ui/console/vite.config.ts
index b484c36..ad0bf84 100644
--- a/ui/console/vite.config.ts
+++ b/ui/console/vite.config.ts
@@ -4,6 +4,10 @@ import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
+ // RELATIVE asset paths: the console is served under whatever prefix the adopter chose
+ // (`/goldpath/console` by default, but it is theirs to change), so an absolute "/assets"
+ // would 404 everywhere except the root.
+ base: "./",
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@goldpath/kit": fileURLToPath(new URL("../kit/src/index.ts", import.meta.url)) },