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)) },