fix(webui): version display, CSP guards, config traps, bucket-aligned stacking

Review fix round (4 Important + 2 folded):

1. Version display: OverviewView.vue was missing the muted version line
   the old vanilla-JS page rendered (types/api.ts already declared
   overview.version) - added.

2. CSP invariant now guarded twice: webui/generate.mjs's new
   assertNoInlineAssets fails the build if dist/index.html contains an
   inline <script> (no src= attribute) or any <style> tag - the exact
   condition the tightened admin CSP (script-src/style-src 'self', no
   'unsafe-inline') depends on. admin_test.go's new
   TestAdminIndexHTML_NoInlineAssets re-asserts the identical invariant
   server-side, against the decoded adminIndexHTML admin.go actually
   serves, so a regression is caught even if admin_assets_gen.go were
   regenerated with a modified copy of generate.mjs.

3. webui/components.json's iconLibrary:"lucide"/font:"inter" fields
   cannot be fixed in place - checked shadcn-vue's own schema
   (PRESET_ICON_LIBRARIES/PRESET_FONTS in the installed package): no
   "none"/fontawesome/system choice exists for either, and JSON (unlike
   the tsconfig.*.json files touched in the previous commit) has no
   comment syntax to warn inline. Documented instead: webui/README.md
   gained a loud warning section naming the exact regression (already
   hit twice this branch - a re-added Google Fonts import, a re-added
   lucide icon import) and the two-step fix to apply after any future
   shadcn-vue add/init call.

4. Real-CSP verification: the browser pass earlier in this branch ran
   under vite preview, which sends no CSP header at all. New
   integration/integration_test.go: TestAdminAssetCSP fetches GET
   /admin against the real Traefik+Yaegi pipeline, extracts the asset
   URL from its <script src="..."> tag, fetches that asset, and asserts
   both responses carry the exact adminCSP string byte-for-byte.
   admin.go's serveAdminAsset now also sets Content-Security-Policy
   (previously JSON/page routes only) so this holds for a direct
   navigation to an asset URL, not just as a same-origin sub-resource of
   GET /admin; admin_test.go's TestAdminAsset_Serving updated to match.

5. (folded) UsageChart.vue stacked the "tokens" tab's two series
   (tokin/tokout - two separate HTTP requests, not one atomic call) by
   positional index. A window rollover landing between the two requests
   could leave one series one bucket ahead, mis-stacking that refresh.
   Now aligns both series by their bucket key (a sorted union of every
   active series' buckets - the fixed-width, zero-padded bucket strings
   sort chronologically by construction), with a missing bucket in one
   series reading as 0 rather than shifting the whole series.

6. (folded) AuthGate.vue now triggers an immediate dashboard.refresh()
   on successful key submit, instead of waiting up to 5s for the next
   poll tick - the dashboard no longer sits blank right after auth.

Rebuilt via make admin-ui; admin_assets_gen.go regenerated.
This commit is contained in:
2026-08-21 09:43:24 +01:00
parent afdb7415db
commit b98416d1e9
10 changed files with 1047 additions and 823 deletions
+15 -9
View File
@@ -839,15 +839,21 @@ or in CI.
- **Response headers**: all three JSON routes set - **Response headers**: all three JSON routes set
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
hashed asset sets `X-Content-Type-Options: nosniff` and its own hashed asset sets `X-Content-Type-Options: nosniff` and its own
immutable `Cache-Control` (above). `GET /admin` and the three JSON immutable `Cache-Control` (above). Every one of the four routes — `GET
routes share one `Content-Security-Policy` header (`default-src 'none'; /admin`, every hashed asset, and the three JSON routes — shares one
script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' `Content-Security-Policy` header (`default-src 'none'; script-src
data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) — 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;
no `'unsafe-inline'`, since the built app has no inline script or style frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) — no
of any kind, only external `/admin/assets/*` references; `img-src` `'unsafe-inline'`, since the built app has no inline script or style of
additionally allows `data:` for the page's inlined favicon. The any kind, only external `/admin/assets/*` references; `img-src`
dashboard loads no external asset of any kind and works in an additionally allows `data:` for the page's inlined favicon. A browser
air-gapped cluster. only enforces CSP against the top-level document that names it, so an
asset response's own header governs nothing when the browser fetched it
as a `<script src>`/`<link>` sub-resource of `GET /admin` — it matters
only if that asset URL is ever navigated to directly, and every other
admin route already sends the identical header, so there is no reason
for asset responses to be the exception. The dashboard loads no
external asset of any kind and works in an air-gapped cluster.
## MCP and A2A ## MCP and A2A
+9 -1
View File
@@ -160,7 +160,14 @@ func (g *Gateway) serveAdminPage(w http.ResponseWriter) {
// produces new filenames, never mutates one this browser may have cached. // produces new filenames, never mutates one this browser may have cached.
// X-Content-Type-Options: nosniff matches the JSON routes' own header // X-Content-Type-Options: nosniff matches the JSON routes' own header
// (setAdminJSONHeaders) — the declared Content-Type must never be // (setAdminJSONHeaders) — the declared Content-Type must never be
// second-guessed by the browser's MIME sniffer. // second-guessed by the browser's MIME sniffer. Content-Security-Policy
// carries adminCSP too (review sweep): a browser only enforces CSP
// against the top-level document that names it, so this header governs
// nothing for a JS/CSS response fetched as a sub-resource of GET
// /admin — but a browser navigated STRAIGHT to an asset URL (a pasted
// link, a bookmark) treats that response as its own top-level document,
// and every other admin route already sends the identical header, so
// there is no reason for this one route to be the exception.
func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) { func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) {
asset, ok := adminAssets[name] asset, ok := adminAssets[name]
if !ok { if !ok {
@@ -170,6 +177,7 @@ func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) {
w.Header().Set("Content-Type", asset.contentType) w.Header().Set("Content-Type", asset.contentType)
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Security-Policy", adminCSP)
_, _ = w.Write(asset.body) _, _ = w.Write(asset.body)
} }
+809 -805
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -629,6 +629,9 @@ func TestAdminAsset_Serving(t *testing.T) {
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want %q", got, "nosniff") t.Errorf("X-Content-Type-Options = %q, want %q", got, "nosniff")
} }
if got := rec.Header().Get("Content-Security-Policy"); got != adminCSP {
t.Errorf("Content-Security-Policy = %q, want %q", got, adminCSP)
}
cc := rec.Header().Get("Cache-Control") cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=31536000") { if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=31536000") {
t.Errorf("Cache-Control = %q, want a long-lived immutable directive", cc) t.Errorf("Cache-Control = %q, want a long-lived immutable directive", cc)
@@ -692,6 +695,36 @@ func TestAdminAssets_GeneratedFilePresent(t *testing.T) {
} }
} }
// adminInlineScriptRe matches every <script ...> opening tag, capturing its
// attribute text — TestAdminIndexHTML_NoInlineAssets checks each capture for
// a src= attribute.
var adminInlineScriptRe = regexp.MustCompile(`(?i)<script\b([^>]*)>`)
// TestAdminIndexHTML_NoInlineAssets asserts, server-side, the exact CSP
// invariant generate.mjs's own assertNoInlineAssets checks at build time
// (webui/generate.mjs): the decoded adminIndexHTML — what admin.go's
// serveAdminPage actually serves — contains no inline <script> (a
// <script> tag with no src= attribute) and no <style> tag anywhere. The
// admin CSP (adminCSP, above) ships script-src/style-src 'self' with no
// 'unsafe-inline'; either violation would silently break under that
// policy. This test exists as a second, independent guard: it catches a
// regression even if admin_assets_gen.go were regenerated with a
// modified copy of generate.mjs that dropped or weakened its own check.
func TestAdminIndexHTML_NoInlineAssets(t *testing.T) {
t.Parallel()
html := string(adminIndexHTML)
for _, m := range adminInlineScriptRe.FindAllStringSubmatch(html, -1) {
attrs := m[1]
if !strings.Contains(strings.ToLower(attrs), "src=") {
t.Errorf("adminIndexHTML contains an inline <script> (no src= attribute): %q", m[0])
}
}
if strings.Contains(strings.ToLower(html), "<style") {
t.Error("adminIndexHTML contains a <style> tag — every style must be an external <link rel=\"stylesheet\">")
}
}
func truncateForTest(b []byte, n int) string { func truncateForTest(b []byte, n int) string {
if len(b) <= n { if len(b) <= n {
return string(b) return string(b)
+65
View File
@@ -26,6 +26,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -946,6 +947,70 @@ func TestAudioTranscriptions(t *testing.T) {
}) })
} }
// adminCSPHeader mirrors admin.go's adminCSP constant byte-for-byte. This
// package is a separate Go module (integration/go.mod) and cannot import
// the plugin's unexported const, so the value is duplicated by hand;
// TestAdminAssetCSP below compares it against a real header the running
// plugin sent, so a drift between the two shows up as a test failure
// immediately, not as a silent divergence.
const adminCSPHeader = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
// adminAssetSrcRe extracts the `src` attribute of the first `<script>` tag
// in the served /admin shell — the built Vue app's own entry-point asset
// reference.
var adminAssetSrcRe = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
// TestAdminAssetCSP proves the Content-Security-Policy header under the
// REAL, shipped pipeline: a Vite build baked into the plugin binary by
// webui/generate.mjs, served by a real Traefik v3.5 process interpreting
// the plugin with Yaegi — not the in-process httptest handler
// admin_test.go's unit tests use. It fetches GET /admin, extracts the
// asset URL its own <script src="..."> tag names, fetches that asset, and
// asserts both responses carry the exact adminCSP string. Combined with
// generate.mjs's own build-time assertion and admin_test.go's
// TestAdminIndexHTML_NoInlineAssets (both check the CSP invariant holds —
// no inline script/style — this test checks the header itself is what
// actually reaches the wire), the loop from "build" to "real HTTP
// response" is closed.
func TestAdminAssetCSP(t *testing.T) {
pageResp, err := http.Get(traefik1URL + "/admin")
if err != nil {
t.Fatalf("GET /admin: %v", err)
}
defer func() { _ = pageResp.Body.Close() }()
if pageResp.StatusCode != http.StatusOK {
t.Fatalf("GET /admin: status = %d, want 200", pageResp.StatusCode)
}
if got := pageResp.Header.Get("Content-Security-Policy"); got != adminCSPHeader {
t.Errorf("GET /admin: Content-Security-Policy = %q, want %q", got, adminCSPHeader)
}
pageBody, err := io.ReadAll(pageResp.Body)
if err != nil {
t.Fatalf("read /admin body: %v", err)
}
match := adminAssetSrcRe.FindSubmatch(pageBody)
if match == nil {
t.Fatalf("GET /admin: body has no <script src=\"...\"> reference, body=%s", pageBody)
}
assetPath := string(match[1])
if !strings.HasPrefix(assetPath, "/admin/assets/") {
t.Fatalf("extracted asset src = %q, want a path under /admin/assets/", assetPath)
}
assetResp, err := http.Get(traefik1URL + assetPath)
if err != nil {
t.Fatalf("GET %s: %v", assetPath, err)
}
defer func() { _ = assetResp.Body.Close() }()
if assetResp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: status = %d, want 200", assetPath, assetResp.StatusCode)
}
if got := assetResp.Header.Get("Content-Security-Policy"); got != adminCSPHeader {
t.Errorf("GET %s: Content-Security-Policy = %q, want %q", assetPath, got, adminCSPHeader)
}
}
// TestAdminDashboard covers v0.2 integration coverage (spec §4): the // TestAdminDashboard covers v0.2 integration coverage (spec §4): the
// unauthenticated HTML shell carries no data, the two JSON routes gate // unauthenticated HTML shell carries no data, the two JSON routes gate
// on admin vs. non-admin vs. unauthenticated, and GET /admin/api/usage // on admin vs. non-admin vs. unauthenticated, and GET /admin/api/usage
+30
View File
@@ -10,6 +10,36 @@ only way this directory's output (`../admin_assets_gen.go`) is meant to be
regenerated. Nothing here runs at request time: the plugin serves the regenerated. Nothing here runs at request time: the plugin serves the
built, baked-in output, never `webui/` itself. built, baked-in output, never `webui/` itself.
## Warning: `shadcn-vue add`/`init` regressions
`components.json`'s `iconLibrary` and `font` fields are locked to
`"lucide"` and `"inter"` — **not this project's real choices** (this
project uses FontAwesome only, per `vue-web-development` house skill, and
no external font at all). The shadcn-vue CLI's schema does not accept
`"none"`/`"fontawesome"`/`"system"` for either field (checked directly:
`node_modules/shadcn-vue/dist/preset-*.js`'s `PRESET_ICON_LIBRARIES` and
`PRESET_FONTS` arrays — five fixed choices each, no escape hatch), and
JSON supports no comment syntax to warn inline (the repo's own
pre-commit JSON validator rejects JSONC comments — see `tsconfig.*.json`
history). This warning is the only place the trap could be recorded.
Both `npx shadcn-vue init` and `npx shadcn-vue add <component>` read
these fields and **silently rewrite `src/assets/main.css`** — hit twice
in this project's own history: `init` first, then a follow-up `add`
call, both re-added a `@import url('https://fonts.googleapis.com/...')`
line and switched the dark-mode `@custom-variant` back to a `.dark`-class
toggle. If you ever run either command again:
1. **Diff `src/assets/main.css` before committing.** Remove any
re-added `@import url('https://fonts.googleapis...')` line (external
font, blocked by the admin CSP's `style-src 'self'` anyway) and
restore the `@custom-variant dark` removal (see the file's own header
comment for why: `prefers-color-scheme`, not a class toggle).
2. **Check the new component for a `lucide-vue-next`/`@lucide/vue`
import** and swap it for the FontAwesome equivalent (see
`src/components/ui/select/Select*.vue` for the pattern already
applied there).
## Local development ## Local development
```sh ```sh
+41
View File
@@ -61,6 +61,43 @@ function walk(dir, base = dir) {
return out return out
} }
/**
* assertNoInlineAssets fails the build if html contains an inline
* `<script>` (any `<script>` tag with no `src=` attribute) or any
* `<style>` tag. The admin CSP (admin.go's adminCSP) ships `script-src
* 'self'` and `style-src 'self'` with no `'unsafe-inline'` — that is only
* safe because `vite build`'s own output never needs it (an external
* `<script type="module" src="...">` plus an external `<link
* rel="stylesheet">`, nothing inline). This check is generate.mjs's own
* guard against that invariant silently breaking under a future Vite/
* plugin config change; admin_test.go's TestAdminIndexHTML_NoInlineAssets
* re-asserts the identical invariant server-side, against the DECODED
* adminIndexHTML admin.go actually serves — so a corrupted or
* hand-patched admin_assets_gen.go is still caught even if someone
* regenerates with a modified copy of this script.
*/
function assertNoInlineAssets(html, sourceLabel) {
const scriptTagRe = /<script\b([^>]*)>/gi
let match
while ((match = scriptTagRe.exec(html)) !== null) {
if (!/\bsrc\s*=/i.test(match[1])) {
console.error(
`generate.mjs: ${sourceLabel} contains an inline <script> (no src= attribute): ${match[0]}\n` +
`The admin CSP ships script-src 'self' with no 'unsafe-inline' — every script must be external.`,
)
return false
}
}
if (/<style\b/i.test(html)) {
console.error(
`generate.mjs: ${sourceLabel} contains a <style> tag.\n` +
`The admin CSP ships style-src 'self' with no 'unsafe-inline' — every style must be an external <link rel="stylesheet">.`,
)
return false
}
return true
}
/** goStringLiteral renders a Go double-quoted string literal for s, escaping the handful of characters that require it. base64 output never needs this (its alphabet is a strict subset of what a Go string literal permits unescaped), but this stays generic rather than assuming that forever. */ /** goStringLiteral renders a Go double-quoted string literal for s, escaping the handful of characters that require it. base64 output never needs this (its alphabet is a strict subset of what a Go string literal permits unescaped), but this stays generic rather than assuming that forever. */
function goStringLiteral(s) { function goStringLiteral(s) {
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
@@ -123,6 +160,10 @@ function main() {
assetEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) assetEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
const indexBody = readFileSync(join(DIST_DIR, indexRel)) const indexBody = readFileSync(join(DIST_DIR, indexRel))
if (!assertNoInlineAssets(indexBody.toString('utf-8'), `${DIST_DIR}/${indexRel}`)) {
process.exitCode = 1
return
}
const indexBase64 = indexBody.toString('base64') const indexBase64 = indexBody.toString('base64')
const sourceDigest = createHash('sha256') const sourceDigest = createHash('sha256')
+7
View File
@@ -7,14 +7,21 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
const auth = useAuthStore() const auth = useAuthStore()
const dashboard = useDashboardStore()
const keyInput = ref('') const keyInput = ref('')
function submit(): void { function submit(): void {
const value = keyInput.value const value = keyInput.value
keyInput.value = '' keyInput.value = ''
auth.submit(value) auth.submit(value)
// Fetch immediately rather than waiting for the next 5s poll tick
// (dashboard.startPolling's own interval, already running from App.vue's
// onMounted) — otherwise a freshly authenticated dashboard can sit blank
// for up to 5s after key entry.
void dashboard.refresh()
} }
</script> </script>
+2
View File
@@ -144,5 +144,7 @@ const overview = computed(() => dashboard.overview)
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
<p v-if="overview?.version" class="text-xs text-muted-foreground">Version: {{ overview.version }}</p>
</div> </div>
</template> </template>
+36 -8
View File
@@ -32,12 +32,29 @@ function toDisplayValue(metric: HistoryMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw return metric === 'cost' ? raw / 1_000_000 : raw
} }
const labels = computed<string[]>(() => { /**
* canonicalBuckets is the union of every active dataset's bucket keys,
* sorted. The "tokens" tab fetches tokin and tokout as two separate HTTP
* requests (history.ts's metricsForTab) — not one atomic call — so a
* window rollover landing between the two requests can leave one series
* one bucket ahead of the other. Zipping the two arrays positionally
* would silently mis-stack that refresh's chart. Bucket strings are
* fixed-width, zero-padded digit strings (limits.go's windowKey: hour
* "2006010215", day "20060102", month "200601"), so a plain lexicographic
* sort is also a chronological sort — no date parsing needed to align
* them.
*/
const canonicalBuckets = computed<string[]>(() => {
const specs = DATASET_SPECS[props.tab] const specs = DATASET_SPECS[props.tab]
const points = props.seriesByMetric[specs[0]!.metric] ?? [] const bucketSet = new Set<string>()
return points.map((p) => formatBucketLabel(p.bucket, props.window)) for (const spec of specs) {
for (const p of props.seriesByMetric[spec.metric] ?? []) bucketSet.add(p.bucket)
}
return Array.from(bucketSet).sort()
}) })
const labels = computed<string[]>(() => canonicalBuckets.value.map((b) => formatBucketLabel(b, props.window)))
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */ /** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
function resolveToken(cssVar: string): string { function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim() return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
@@ -48,13 +65,24 @@ const chartData = computed<ChartData<'bar'>>(() => {
// custom properties via resolveToken) on a `prefers-color-scheme` flip — // custom properties via resolveToken) on a `prefers-color-scheme` flip —
// resolveToken's own getComputedStyle read is not itself reactive. // resolveToken's own getComputedStyle read is not itself reactive.
void foreground.value void foreground.value
const buckets = canonicalBuckets.value
return { return {
labels: labels.value, labels: labels.value,
datasets: DATASET_SPECS[props.tab].map((spec) => ({ datasets: DATASET_SPECS[props.tab].map((spec) => {
label: spec.label, // Map<bucket, value> per series, so each canonical bucket looks up
backgroundColor: resolveToken(spec.color), // its own value rather than assuming index i means the same bucket
data: (props.seriesByMetric[spec.metric] ?? []).map((p) => toDisplayValue(spec.metric, p.value)), // across every dataset (the mis-stacking canonicalBuckets exists to
})), // avoid — see its own doc comment). A bucket this series has no
// point for reads as 0, matching the zero-based axis: a genuinely
// missing bucket (not yet reported by the store) is indistinguishable
// from zero usage, which is the honest reading for a bar chart.
const byBucket = new Map((props.seriesByMetric[spec.metric] ?? []).map((p) => [p.bucket, p.value]))
return {
label: spec.label,
backgroundColor: resolveToken(spec.color),
data: buckets.map((b) => toDisplayValue(spec.metric, byBucket.get(b) ?? 0)),
}
}),
} }
}) })