A page inside a proxy

Austin Wagner

A proxy that had only ever spoken to machines now renders a page for a person, on its own hostname, and every safety question it answers has exactly one implementation. The one question that briefly had two answers cost a 403 that named no cause, and the fix is a guard that is itself guarded.

Problem
Every surface was addressed to software: tool calls, an OAuth endpoint, a queue read. Inviting a person in means serving HTML from a process whose job is forwarding requests elsewhere.
Result
A server-rendered, identity-gated page on its own origin. One route serves every view, and one selector decides whether an account may see a workspace.
Scale
Three dynamic sources feed the host allowlist, a hostname must be declared in two files, and the one list that would have needed the canonical registry was dropped rather than paid for with a 29-service rebuild.
Stack
TypeScript, Express, Keycloak, PostgreSQL, Cloudflare Tunnel, Docker.

One question, one answer

The index says what you belong to and how much is in each workspace. The tables live one click away, on the same route, chosen by a query parameter.

I made the detail view a pure selector over the result the index already renders from, rather than a second lookup keyed by slug. A dedicated fetch would have been a second implementation of "may this person see this", and two implementations of one authorization question drift: eventually the index lists a workspace the detail view refuses, or the reverse.

A slug you have no membership for returns 404, and so does a slug that exists nowhere. Same answer to both, so the page cannot be used to enumerate tenants.

Why a separate origin

Web Push subscriptions are scoped to an origin and die if it moves, so the origin is a one-time choice. The existing hostname names a machine protocol endpoint and carries registered clients and OAuth callbacks; a page for people should inherit neither the name nor the migration risk. It cost nothing structurally: several hostnames already resolved to that container, so multiplexing on Host was existing behaviour.

The 403 that named nothing

Routing the hostname at the tunnel is not enough. The proxy builds a DNS-rebinding allowlist from three sources: its Docker service name, the hostname in its OAuth resource URL, and every host it proxies to a backend. A hostname the proxy serves itself is none of those, so the request is refused before any handler runs.

RequestResultReading
Host: mcp.…302redirect to sign-in, correct
Host: corvis.…403refused before the route it had just registered

The boot log said the route was registered, and it was. The allowlist had never been told the hostname existed, and a rebinding guard declines without explaining itself, so the symptom was a bare 403 while every log line looked healthy.

It cannot borrow the mechanism for proxied hosts, which requires a backend to forward to when there is nothing to forward to. I gave it its own config field, declared beside the other sources instead of written as a literal in the function, so nobody has to find it by reading code. The call, copied:

const allowedHosts = buildAllowedHosts(
  config.name,
  publicHostname,
  ...gatedHostnames,
  ...dashboardHostnames,
);

mcp-proxy/src/auth-server.ts, the buildAllowedHosts call, private repository.

The guard is itself guarded

A validator now reads the ingress configuration and refuses any hostname routed at the proxy that none of the declaring fields carries. Deleting the declaration and re-running it reproduces the original incident as a lint failure naming the cause and the places to fix.

That check has its own failure mode: it re-derives what feeds the allowlist from configuration, so the day a fourth source appears it keeps passing while measuring an incomplete set, reporting a safety it stopped checking. So I pinned the argument list. Adding a source fails the build and names the package to update. A behavioural test could not catch that, because a new source contributing no hostnames changes the call without changing the result.

Record

The codebase wiki page corvis-dashboard-human-surface carries every claim here with its source files pinned by git blob hash, including the measured 302 and 403 and the boot line that made them confusing. Every figure above is bound to a named section of it and checked before this page can deploy.

Known limits. The surface has moved since it was measured on 2026-09-11: the wiki list its first version carried was removed the same day (the 29-service trade-off above is the record of that decision, not a live limit), the route grew a third view, and the header changed shape. Every claim on this page was re-verified against the current code on 2026-09-14, and the measured requests are from the first day.

Sources

  • The page itself, on its own origin: corvis.austinwagner.org/dashboard. It answers a redirect to sign-in for anyone without a session.
  • The proxy, the validator and the wiki page are in a private repository; the excerpt above is copied from it with its path and symbol.
  • This article's manifest: provenance.json.