diff --git a/internal/api/governance.go b/internal/api/governance.go index 97dfd90..a9f01f6 100644 --- a/internal/api/governance.go +++ b/internal/api/governance.go @@ -64,9 +64,11 @@ type GovernanceServer struct { // Console (Stage-4 step 3) GET-page fields, populated by ConfigureConsole // (governance_console.go). Zero-valued on a POST-only server: the GET routes // then render a 500 "template not found", the same failure mode as the portal - // with a missing template. These stay on the LOCAL-ONLY :8090 mux. + // with a missing template, and the static-asset route 404s. These stay on + // the LOCAL-ONLY :8090 mux. consoleRepo consoleGovRepo templatePaths []string + staticDir string // sounding is the demand-sounding read model behind GET /admin/sounding, // populated by ConfigureSounding (governance_sounding.go). Nil on a server @@ -161,6 +163,14 @@ func (g *GovernanceServer) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /admin/fees", g.handleAdminFeesPage) mux.HandleFunc("GET /admin/messaging", g.handleAdminMessagingPage) + // Console static assets (the portal design-system CSS and the brand mark the + // admin templates reference), served from the web/static directory + // ConfigureConsole derives beside the templates dir — mirroring the portal's + // /static wiring (internal/portal/server.go). Zero-valued on a POST-only + // server: the route then 404s — harmless, like the unwired console pages + // above (which fail 500). LOCAL-ONLY like everything on this mux. + mux.HandleFunc("GET /static/", g.handleStatic) + // Demand-sounding dashboard (governance_sounding.go). Server-rendered SVG // charts over the migration-025 hypertables; LOCAL-ONLY like the rest. mux.HandleFunc("GET /admin/sounding", g.handleAdminSoundingPage) diff --git a/internal/api/governance_console.go b/internal/api/governance_console.go index 0c09199..8c5dfc0 100644 --- a/internal/api/governance_console.go +++ b/internal/api/governance_console.go @@ -6,7 +6,9 @@ import ( "errors" "html/template" "io/fs" + "log/slog" "net/http" + "os" "path/filepath" "time" @@ -81,9 +83,36 @@ func (g *GovernanceServer) ConfigureConsole(readRepo consoleGovRepo, templatesDi } g.consoleRepo = readRepo g.templatePaths = paths + // The admin templates reference /static/css/portal.css and the brand mark; + // serve them from the web/static directory beside the templates dir, the + // same web/ layout the portal serves (internal/portal/server.go). + if templatesDir != "" { + g.staticDir = filepath.Join(templatesDir, "..", "static") + // Surface a missing static dir at configure time. Without this the only + // symptom is per-request 404s and an unstyled console — the silent + // regression this route exists to prevent. + if _, err := os.Stat(g.staticDir); err != nil { + slog.Warn("governance console static dir missing; console pages will render unstyled", + "dir", g.staticDir, "error", err) + } + } return nil } +// handleStatic serves the console pages' static assets (the portal design-system +// CSS and the brand mark) from the directory ConfigureConsole derived beside the +// templates dir. Without ConfigureConsole the dir is unset and the route 404s — +// harmless on a POST-only server, alongside the unwired console pages' own 500 +// "template not found". Reached only through the loopback-bound, +// loopback-source-guarded mux. +func (g *GovernanceServer) handleStatic(w http.ResponseWriter, r *http.Request) { + if g.staticDir == "" { + http.NotFound(w, r) + return + } + http.StripPrefix("/static/", http.FileServer(http.Dir(g.staticDir))).ServeHTTP(w, r) +} + // renderAdmin builds a fresh template set from admin_layout.html and the // requested page file, then executes the named page template. This mirrors the // portal's per-request ParseFiles pattern (every page defines diff --git a/internal/api/governance_console_test.go b/internal/api/governance_console_test.go index ad2cab4..72bb88c 100644 --- a/internal/api/governance_console_test.go +++ b/internal/api/governance_console_test.go @@ -233,3 +233,44 @@ func TestAdminConsole_RejectsNonLoopbackSource(t *testing.T) { } } } + +// TestAdminConsole_StaticAssets_ServedWhenConfigured — the admin templates +// reference /static/css/portal.css and the brand mark; without this route the +// console renders as bare unstyled HTML (found live 2026-07-14: the browser +// showed what looked like "no UI"). ConfigureConsole derives the static dir +// beside the templates dir, so the harness's real web/templates wiring serves +// the real web/static. +func TestAdminConsole_StaticAssets_ServedWhenConfigured(t *testing.T) { + h := newConsoleGovServer(t, sampleGovRead(), nil) + rec := getGov(h, "/static/css/portal.css") + if rec.Code != http.StatusOK { + t.Fatalf("GET /static/css/portal.css: status %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "--amber") { + t.Errorf("portal.css served but missing the design tokens (no --amber)") + } +} + +// TestAdminConsole_Static404WhenUnconfigured — a POST-only server (no +// ConfigureConsole) must 404 the static route — harmless, alongside the +// unwired console pages' own 500 "template not found". +func TestAdminConsole_Static404WhenUnconfigured(t *testing.T) { + g := newTestGovServer(t, &fakeGovRepo{}, notify.NewLogNotifier()) + rec := getGov(govMux(g), "/static/css/portal.css") + if rec.Code != http.StatusNotFound { + t.Fatalf("unconfigured GET /static/css/portal.css: status %d, want 404", rec.Code) + } +} + +// TestAdminConsole_StaticRejectsNonLoopbackSource — the static route sits on +// the same loopback-source-guarded mux as everything else on :8090. +func TestAdminConsole_StaticRejectsNonLoopbackSource(t *testing.T) { + h := newConsoleGovServer(t, sampleGovRead(), nil) + req := httptest.NewRequest(http.MethodGet, "/static/css/portal.css", nil) + req.RemoteAddr = "203.0.113.9:4444" // NOT loopback + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("static from non-loopback source: status %d, want 403", rec.Code) + } +}