diff --git a/docs/superpowers/specs/2026-08-24-status-pages-design.md b/docs/superpowers/specs/2026-08-24-status-pages-design.md new file mode 100644 index 0000000..503838b --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-status-pages-design.md @@ -0,0 +1,286 @@ +# Public status pages + +Date: 2026-08-24 + +## Goal + +Let an operator publish one or more public status pages from a Vantage +instance, at `.vantage./status/`, showing the state of any +monitors they choose, plus incidents and maintenance windows they author by +hand. The pages are completely public: no session, no token, no login. + +Out of scope, deliberately: + +- **Custom domains** (`status.customer.com`). Needs certificate provisioning and + a host-to-page lookup that bypasses `hostSlug` entirely. Its own sub-project. +- **Per-page themes.** `web/` is locked dark by design and a public page is not + the place to break that. +- **Subscriber notifications.** Email or webhook on incident updates is a + notification subsystem, and one already exists for monitors; wiring the two + together is a separate decision. +- **SLA reporting.** Uptime percentages are shown; contractual SLA calculation + with credits and exclusions is a different product. + +## Current state + +Everything needed to draw a status page already exists and is already scoped by +instance: + +| Data | Where | +| --- | --- | +| Monitor identity and live state | `models.Monitor`, `Monitor.State` | +| Outage records | `models.Incident`, opened when a monitor flips down | +| Hourly uptime history | `models.Rollup` (`monitor_rollups`) | +| Sub-hour history | `models.MonitorSample`, TTL-expired | +| Instance from hostname | `auth.InstanceFromHost`, 60s cached | + +Three things do not exist: any concept of a page, any operator-authored +incident, and any unauthenticated read path. The third is the constraint that +shapes the rest — every route under `/api` carries `auth.Middleware`, +`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where +it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with +no scope entry. + +## Approach + +Two new collections hold the page and the authored incidents. A single +assembly function reads them alongside the existing monitor data and emits a +purpose-built public struct. The public route is mounted outside `/api`, is +cached in Redis, and is rate limited per client address. + +The redaction boundary is the assembly function, and it is the security +property of this whole feature. + +## Data model + +Both collections carry `instance_id` and both must be added to +`services.ScopedCollections`, or their rows outlive a deleted instance. + +### `status_pages` + +One document per page. It is read whole, always, so its structure is embedded +rather than joined: one page is one Mongo read is one cache fill. + +``` +_id, instance_id +page_id // operator-chosen slug, [a-z0-9-], 3-40 chars +title, description, logo_url +published bool +banner { enabled, level, text } +sections [ { name, entries: [ { monitor_id, display_name } ] } ] +created_at, updated_at +``` + +Unique index on `(instance_id, page_id)`. The slug is operator-chosen rather +than random because it is a URL handed to customers and printed on support +pages; a random identifier would be unguessable and unmemorable in equal +measure. + +`published` exists so a page can be composed before anyone sees it. An +unpublished page answers the same 404 as a page that does not exist — a +distinct 403 would confirm it exists. + +Sections are page-local and unrelated to `Monitor.Group`, which is a display +label on the authenticated monitors list. One monitor may appear under "API" on +the customer page and "Edge" on the partner page, under two different display +names. That is the point of the override: a monitor's internal name is often +not a name you want published. + +The banner is three fields on the page rather than a collection, because it is +one string with no lifecycle. + +### `status_incidents` + +Manual incidents and maintenance windows share one shape, because they share a +timeline, an impact and a set of affected components; splitting them into two +collections would duplicate all three. + +``` +_id, instance_id, incident_id +page_ids []string // which pages show it +kind "incident" | "maintenance" +title +impact // none | minor | major | critical +affected_monitors []string // monitor_ids +status // incident: investigating | identified | monitoring | resolved + // maintenance: scheduled | in_progress | completed +scheduled_start, scheduled_end // maintenance only +updates [ { at, status, body, author } ] +started_at, resolved_at, created_at, updated_at +``` + +Updates are embedded for the same reason sections are: they are few, and they +are never read apart from their incident. + +`page_ids` is explicit rather than derived from `affected_monitors`. Deriving it +would be less to fill in, but adding a monitor to a page later would +retroactively republish old incidents to a new audience. An operator publishing +to customers chooses that audience. + +### Auto-incidents are derived, never copied + +The existing `incidents` collection remains the only writer for +monitor-detected outages. The public snapshot derives them at assembly time: +filter to the monitors on the page, last 90 days, render as display name, start, +end and duration. + +`Incident.Cause` is dropped. It is where `dial tcp 10.0.0.5:5432: connect +refused` lives. + +Copying auto-incidents into `status_incidents` would be a second writer for the +same fact, arriving by a different route with its own opportunity to disagree — +the same argument that keeps `RefreshWorkloadsCmd` from returning workloads +inline. + +### Maintenance does not rewrite uptime + +During a maintenance window, affected components render as "under maintenance" +rather than down. The uptime percentage and the history bar still come from the +rollups, unmodified. + +Rollups are the durable record. Bending them so a page looks better is a lie +pointed the other way, and the operator who later asks "what was our actual +availability" gets an answer that was edited for publication. + +## The redaction boundary + +`services.BuildStatusSnapshot(instanceID, pageID)` is the only function that +reads `monitors`, `incidents`, `monitor_rollups` and `status_incidents` on +behalf of an anonymous caller, and it emits a purpose-built struct. + +**`models.Monitor` is never marshalled to a public caller.** Target URL, host, +port, method, keyword, `state.message`, `state.cert_expiry_at` and +`channel_ids` all stay behind the boundary. A field added to `Monitor` next year +is private by default rather than published by accident. + +What the snapshot contains, per entry: display name, current status, uptime +percentage over the last 90 days, and a 90-day history bar of one cell per day. +A cell is up, down, under maintenance, or no-data — `no-data` for days before +the monitor existed, which is a distinct thing from a day it was down. No +latency, no addresses, no failure text. + +## Public read path + +``` +GET /public/status/:pageId +``` + +Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would +require exempting it from authentication, scope enforcement, token rate +limiting and the licence gate — four holes, each one something a later change +can widen. Outside `/api` it needs none of them. + +The instance is resolved from the request host through `auth.InstanceFromHost`. +A host with no instance label, an unknown slug, an unknown page and an +unpublished page all answer **404**, identically. + +### The feature gate answers 200, not 403 + +Status pages are gated by a new `license.FeatureStatusPages = "status_pages"`, +on both the authoring routes and the public read. + +The public side checks inline rather than through `RequireFeature`, which +aborts with a 403 JSON body. A public page needs to render an explanation: + +```json +{ "available": false, "reason": "feature_unavailable", "title": "Acme Status" } +``` + +`reason` is `feature_unavailable` when the tier does not include the feature and +`licence_inactive` when the licence has lapsed. The title is included so the +page does not look broken; nothing else is. + +**This is not only a server change.** The feature must be added to admin's +`plans` rows per `(deployment, tier)`, or every instance reads it as absent and +the feature ships dark. + +### Cache + +Redis key `vantage:status::` holds the assembled JSON with +a 30-second TTL. N visitors cost one Mongo read regardless of traffic. + +Authoring writes delete the key, so an operator posting an incident update sees +it immediately rather than wondering for half a minute whether it saved. + +Redis rather than Next ISR because with `replicaCount > 1` each `web` pod would +cache separately and two visitors would see different states during an incident. + +### Rate limit + +Per client address, one-minute fixed window, 120 requests, 429 with +`Retry-After` — the same shape as `RateLimitTokens`, including its most +important property: **when Redis is unavailable, allow rather than deny.** A +status page must survive the outage it exists to report. + +### Trusted proxies + +Nothing calls `r.SetTrustedProxies`, so gin trusts every proxy and +`c.ClientIP()` takes `X-Forwarded-For` verbatim. That is spoofable per request, +which makes a per-address limiter decorative. + +This has not mattered so far because `ClientIP()` is only used for audit +strings. It matters now, so this work adds a trusted-proxy configuration and +sets it at boot. Without it the rate limit is theatre. + +## Authoring API + +Under `/api`, owner or admin, behind `RequireFeature("status_pages")`, every +mutation audited: + +``` +GET,POST /status-pages +GET,PUT,DELETE /status-pages/:pageId +GET,POST /status-pages/:pageId/incidents +PUT,DELETE /status-pages/:pageId/incidents/:incidentId +POST /status-pages/:pageId/incidents/:incidentId/updates +``` + +This adds a ninth scope resource, `status:read` and `status:write`. The entries +are required, not optional: `AssertScopeMapComplete` fails boot on an `/api` +route with no scope entry, which is exactly the safeguard working. + +Handlers need `@…` annotations and `openapi.json` must be regenerated and +committed — `server-deploy.yml` runs `git diff --exit-code` against the +committed copy, so a handler whose annotation drifted fails CI. + +## Frontend + +`web/app/status/[pageId]/page.tsx`, **outside the `(app)` route group**, so it +inherits no sidebar, no session fetch and no auth redirect. Server-rendered +against the Go endpoint, with a client refresh every 60 seconds. + +`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh +reaches the server. + +The page stays dark, like the rest of `web/`, and carries no hex values — the +existing token palette covers every state it needs. + +Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar +group. It is `adminOnly`, and since the whole group is, a member sees the group +disappear entirely rather than a labelled section with nothing under it. + +## Testing + +The snapshot tests are the ones that matter, because they are the redaction +boundary made executable: + +- `BuildStatusSnapshot` output contains no target URL or host, no + `state.message`, no `incident.cause`, no `channel_ids`, no latency. +- A monitor on no page never appears in any page's snapshot. +- An unpublished page and an unknown page both 404. +- Feature absent and licence inactive both return 200 with `available: false` + and the matching `reason`. +- A cache hit performs no Mongo read; an authoring write invalidates the key. +- Slug validation: character set, length, uniqueness within an instance. +- Maintenance window renders the component as under maintenance while leaving + the uptime percentage untouched. + +## Migration and rollout + +No migration is needed — both collections are new and absent means empty. Index +builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being +fatal: a missing index on a small collection degrades to a scan, which is no +reason to refuse to serve the fleet. + +The feature ships dark until the `status_pages` feature is added to the plan +rows in admin.