052 — Operate-mode data sources proxied server-side, not direct-browser
Date: 2026-04-22 Status: Accepted Related: ADR-003 (ai-frontend, three-modes platform UI), ADR-049 (Alertmanager bearer at Caddy), ADR-050 (per-product /metrics bearer), ADR-047 (Grafana folder-per-product)
Context
The Platform UI's Operate mode (ai-frontend §5) renders fleet health and alerts in two regions that need live data:
- Service health grid — one row per product, showing
up/5xx-rate/p95-latencyover the last five minutes. Source: Prometheus athttps://grafana.gyrum.ai/api/datasources/proxy/uid/prometheus(via the Grafana datasource proxy; the Prometheus container on10.0.0.4:9090has no public TLS and no public DNS). - Active + recent alerts — firing now and resolved-in-7d. Source: Alertmanager at
https://alertmanager.gyrum.ai/api/v2/alerts, which is bearer-gated as of ADR-049.
Both sources require secrets. The Grafana datasource-proxy call needs a Grafana service-account token (same one deploy.sh uses to provision dashboards, lives at /deploy/.secrets/grafana.env on gyrum-prod-1). The Alertmanager call needs the bearer from /deploy/.secrets/alertmanager-bearer.token on dark-factory-ops.
A decision is needed because the ai-frontend is a SvelteKit app with a Node adapter — the UI can run queries from either side of the wire. Three broad architectures are plausible:
- A. Direct browser → Prometheus/Alertmanager. Ship the tokens to the browser.
- B. Browser → backend → upstreams. Backend (ai-research
cmd/server, the:9400Go process) holds the tokens and proxies. - C. Browser → Grafana plugin embeds. Iframe Grafana panels, let Grafana handle auth.
The ntfy + Alertmanager incident context (ADR-049) sharpens the threat model: a bearer leak to the browser would let any page with that token silence alerts pre-attack — the exact vector ADR-049 closed. This is stronger than a "tokens should live server-side" generic preference.
Decision
The ai-research Go backend (:9400) proxies both Prometheus and Alertmanager for the Operate mode UI. Two new endpoints, server-rendered with tokens held in backend env vars:
GET /api/v1/operate/service-health— queries Prometheus (via the Grafana datasource proxy) forup, 5xx-rate, and p95 latency. Merges on thejob/servicelabel. Caches the merged payload for 30 seconds.GET /api/v1/operate/alerts— fans out two requests to Alertmanager (active=true&silenced=false+ the 7-day resolved window). Strips Alertmanager internals (fingerprint, receivers, generatorURL, inhibitedBy, silencedBy) from the response and projects onto a UI-shaped payload.
The backend reads four env vars — PROMETHEUS_URL, PROMETHEUS_TOKEN, ALERTMANAGER_URL, ALERTMANAGER_BEARER_TOKEN — and never writes either token to a response body or header. Tests assert the bearer never leaks.
Grafana dashboard links are rendered in the UI (grafanaFolderUrl per row) per ADR-047's folder-per-product convention; we link out, we do not iframe.
Consequences
Good:
- The Alertmanager bearer closed by ADR-049 stays closed. A stolen browser session cannot silence alerts.
- The Grafana service-account token never touches a browser. Its scope (Admin in Grafana) is wide enough that browser exposure would be an org-level incident.
- CORS surface does not grow. The browser only talks to
:9400, which already has a CORS origin policy (cors.go). - Caching happens in one place (backend, 30 s TTL). Hundreds of operators refreshing Operate mode do not fan out to hundreds of Prometheus queries — Prometheus is a single process and was not sized for that.
- Response shape is UI-oriented, not upstream-oriented. When the UI needs a new field (e.g. "traffic sparkline" in §5 Phase 3), the backend aggregates once instead of the browser stitching three endpoints.
Trade-offs:
- The ai-research backend grows a dependency on two upstream services. A Prometheus outage breaks the service-health region (degrades to an empty grid with a banner); an Alertmanager outage hides firing alerts (a real incident hazard — the ops team must monitor Alertmanager health separately, e.g. via an ntfy "Alertmanager not reachable" deadman alert).
- Two more env vars to provision. Documented in
.env.example; deployed via the standard/deploy/.secrets/mechanism. - Query logic lives in Go, not in a Grafana dashboard. Evolving the PromQL means a backend release instead of an ops-side JSON edit. Accepted — the queries are simple and change rarely; a backend release is already the path for any UI-shape change.
What this does NOT solve:
- Per-user auth on
:9400. Today the ai-research backend assumes the operator is already authorised (no login wall). When the platform grows multi-user access, Operate mode is one of the surfaces that needs auth — tracked as a future ADR. - Grafana dashboard embedding. The UI still link-outs to Grafana for deep drill-down. Embedding Grafana panels would require solving Grafana's own auth-from-iframe story; out of scope.
- Write operations on Alertmanager. Silence creation / deletion is not exposed through the backend. That is intentional — writing through a server-side proxy does not make the operation less dangerous; it just removes the attribution (Alertmanager's API does not know who the bearer-holder was). Silences remain a CLI / runbook operation for now (see observability-runbook).
Alternatives considered
Browser → Prometheus/Alertmanager directly, tokens shipped via a public env var. Rejected. ADR-049 explicitly closed the Alertmanager bearer to anonymous; handing it to every browser window undoes that. The Grafana service-account token has Admin scope — browser exposure equals full Grafana compromise. This option is not recoverable by rotation alone: a token leaked to a browser is a token in browser history, extension memory, possibly uploaded in crash dumps.
Browser → Grafana with panel embeds (iframes). Considered. Grafana's own login / allow-list takes care of the Prometheus side. Rejected because (a) Grafana does not expose a low-friction embed-with-auth for our IP-allow-list setup, (b) iframe layouts fight with the UI's density requirements — Operate mode wants a compact table, not a stack of panels, (c) we would still need a separate proxy for Alertmanager (no Grafana datasource for raw AM writes).
A thin separate BFF service (not the ai-research
:9400backend). Rejected. We already have a backend the UI talks to. Adding a sibling service doubles the ops surface (separate container, separate port, separate CORS config, separate deploy). The:9400backend already proxies to other read-only upstreams (/api/v1/infra/*reads hetzner-snapshot JSON); Operate mode follows the same pattern.Server-side Svelte
+page.server.tshandlers instead of Go. Plausible — keeps data-fetching in the same runtime as the UI. Rejected for now because (a) the SSR node runs alongside the UI but is typically sized for render throughput, not cross-service proxying, (b) the Go backend already holds a pattern for secrets-from-env and aggressive caching, (c) splitting Operate-mode data between two runtimes (Prom in Node, something else in Go later) would create a second stitching point. Single language for the proxy tier keeps the blast radius contained.A static dashboard in Grafana that the UI iframes for Operate mode. Rejected. The operator wants one place for "what is broken", not a link-out to a tool that requires a second login context. Grafana remains the place for detailed investigation — linked from every service-health row — but not the Operate-mode landing itself.
Cache in the browser (ServiceWorker / sessionStorage) instead of server-side. Rejected as the cache layer question. Browser cache does not solve the "30 operators hitting Prometheus concurrently" problem — each operator still does the initial fetch. Server-side cache solves both the browser-refresh storm and the across-operator concurrency, for one TTL.
Verification
# With both endpoints deployed:
curl -s http://localhost:9400/api/v1/operate/service-health | jq '.[] | {slug, status, errorRate5m}'
# expect: rows for every product scraped by Prometheus (3+ as of 2026-04-22:
# distill, arbibot, buzzy; 14 more merged, awaiting deploy).
curl -s http://localhost:9400/api/v1/operate/alerts | jq '.active | length, .recent7d | length'
# expect: active=0 (no alerts configured yet), recent7d=0 or small.
# Bearer containment — the token must never appear in responses or headers:
curl -sv http://localhost:9400/api/v1/operate/alerts 2>&1 | grep -i 'authorization\|bearer'
# expect: no output.
# Backend refuses /operate/* without upstream tokens:
# Running the backend with PROMETHEUS_TOKEN unset yields a 502 from
# /service-health with "prometheus HTTP 401" in the error body — deliberate
# fail-closed behaviour.
Rollback
Back out both endpoints and the env-var block from .env.example. The Operate mode UI tolerates 502 responses from both paths (degrades to "Prometheus unreachable" / "Alertmanager unreachable" affordances per the UI Promise.allSettled loader), so rollback does not take the /operate route down — it just shows empty regions until the endpoints return. Mode-switcher and sub-routes (/operate/users etc.) are unaffected.