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.
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.
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.
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.
| Request | Result | Reading |
|---|---|---|
Host: mcp.… | 302 | redirect to sign-in, correct |
Host: corvis.… | 403 | refused 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.
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.
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.