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
12 changes: 11 additions & 1 deletion internal/api/governance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions internal/api/governance_console.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"

Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/api/governance_console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}