hive-gateway: route dashboard by path, not Accept header

The dashboard vhost split static-vs-backend on the request Accept
header (map $http_accept $dashboard_spa_target), so the same URL
behaved differently by content-type — e.g. /api/state fetched with
Accept: text/html wrongly returned index.html.

Now that all hive-c0re routes live under /api/ plus the single
/webhook/knowledge endpoint, route by path instead: /api/ and
/webhook/ proxy to c0re (SSE settings on /api/), everything else
serves the dist with try_files $uri /index.html. Drops the
dashboard Accept-header map and the @c0re named location.

Updates docs/gateway.md accordingly.
This commit is contained in:
atlas 2026-06-22 23:08:23 +02:00
commit d4f106d590
2 changed files with 38 additions and 27 deletions

View file

@ -6,7 +6,7 @@ Single nginx in front of every hyperhive web surface. Container `hive-gateway`,
| URL | vhost | upstream | source |
| --- | --- | --- | --- |
| `<hive>/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`) + API/SSE/actions → hive-c0re (`7000`) via `@c0re` | always |
| `<hive>/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`); `/api/` + `/webhook/` → hive-c0re (`7000`) | always |
| `<hive>/agent/<name>/` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) |
| `<hive>/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` |
| `<hive>/matrix/` (deprecated) | `_` | 301 → `matrix.<hive>/` | `matrix.gui.enable` |
@ -31,17 +31,24 @@ Federation peers fetch `.well-known/matrix/server` → `{"m.server":"matrix.<hiv
## SPA fallback (Accept-header pattern)
The `<hive>` catch-all (operator dashboard), the per-agent UIs, and the `matrix.<hive>` vhost all serve a flutter/SPA bundle. Two requirements collide:
The per-agent UIs and the `matrix.<hive>` vhost serve a flutter/SPA bundle via the Accept-header pattern below. (The `<hive>` dashboard catch-all used this too but now routes by **path** — see the dashboard note after.) Two requirements collide:
- hard-refresh on a sub-route must serve `index.html` (SPA's client-side router takes over after JS bootstrap)
- a non-navigation request that isn't an on-disk asset must NOT get HTML with the wrong content-type
Solution: an `nginx http`-context `map $http_accept $<name>_spa_target { ... }` keyed on the request's Accept header. Browser navigations (`Accept: text/html,...`) get `index.html`; everything else (`Accept: image/*`, `*/*`, `application/json`, `text/event-stream`, …) gets a sentinel nonexistent path, so `try_files $uri $<name>_spa_target <final>` falls through to `<final>`. No extension allowlist, no `if` block, no regex heuristics.
The two vhosts differ only in `<final>`:
For matrix / per-agent static assets, `<final>` is `=404` (a missing asset is just missing).
- **matrix / per-agent static assets**`=404` (a missing asset is just missing).
- **dashboard**`@c0re` (a named location that reverse-proxies to hive-c0re `7000`). The dashboard's dynamic surface — every `/api/*`, the two SSE streams, the ~20 bare action/mutation routes (`/approve/{id}`, `/kill/{name}`, `/op-send`, …), and `/webhook/knowledge` — is all `Accept != text/html`, so it lands on `@c0re` automatically, **without enumerating a single backend prefix**. This is what lets the gateway static-serve the dashboard dist (from the `servedFrontend` nix-store path) while hive-c0re stays API-only — so a frontend-only change no longer rebuilds + restarts the core daemon. `@c0re` carries `proxy_buffering off` + a 1d read timeout (for the SSE streams) and a duplicated `auth_basic` block (named locations don't inherit it). Follow-up #1846 will move every backend route under `/api/`, collapsing this to a trivial `/api/* → c0re, else static` split.
### Dashboard: path-based routing (not Accept-header)
Now that every hive-c0re backend route lives under `/api/` plus the single `/webhook/knowledge` endpoint, the dashboard vhost routes by **path**, not Accept header:
- `location /api/` → hive-c0re (`7000`): all dashboard data, actions/mutations, and the two SSE streams (`/api/dashboard/stream`, `/api/build-logs/id/{id}/stream`). Carries `proxy_buffering off` + a 1d read timeout for the streams.
- `location /webhook/` → hive-c0re: the knowledge webhook.
- `location /` → the dashboard dist (from the `servedFrontend` nix-store path) with `try_files $uri /index.html` (SPA fallback).
Each location carries a duplicated `auth_basic` block (separate locations don't inherit it). This keeps the gateway static-serving the dashboard dist while hive-c0re stays API-only — a frontend-only change no longer rebuilds + restarts the core daemon. The earlier `map $http_accept` Accept-header split was replaced because it made the *same* URL behave differently by content-type (e.g. `/api/state` fetched with `Accept: text/html` wrongly returned `index.html`); path routing is deterministic. A new top-level c0re route prefix (beyond `/api` + `/webhook`) needs a matching `location` added to the dashboard vhost.
## Local dev (`localHostsEntry`)

View file

@ -747,8 +747,9 @@ in
};
};
# Shared auth block — named locations don't inherit auth_basic, so
# both `/` and `@c0re` need it or the proxied surface is unauthed.
# Shared auth block — separate locations don't inherit auth_basic, so
# each dashboard location (`/`, `/api/`, `/webhook/`) needs it or that
# surface is unauthed.
dashboardAuth = lib.optionalString cfg.auth.enable ''
auth_basic "${cfg.auth.realm}";
auth_basic_user_file /run/hive-state/gateway.htpasswd;
@ -757,30 +758,39 @@ in
error_page 401 =401 /__hive_auth_unauthorized;
'';
# Dashboard: nginx static-serves the dist, c0re is API-only. The
# Accept-header map splits without enumerating routes — html
# navigations → SPA index.html, everything else (API/SSE/actions/
# webhook) → @c0re. Replaces the old `location / { proxy_pass c0re }`
# that made c0re ServeDir the dist (and restart on every frontend
# change). New c0re routes need no gateway change.
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
# is by PATH, never content-type. c0re serves exactly two prefixes —
# `/api/` (all dashboard data + actions + the SSE streams) and
# `/webhook/` (the knowledge webhook) — so those proxy to c0re and
# everything else serves the dist with an SPA fallback to index.html.
# The earlier `map $http_accept` Accept-header split made the SAME
# url behave differently by content-type (e.g. `/api/state` fetched
# with `Accept: text/html` wrongly got index.html); path routing is
# deterministic. A new top-level c0re route prefix (beyond /api +
# /webhook) would need a matching location added here.
dashboardProxyLocation = {
"/" = {
root = dashboardDist;
extraConfig = ''
try_files $uri $dashboard_spa_target @c0re;
try_files $uri /index.html;
${dashboardAuth}
'';
};
"@c0re" = {
"/api/" = {
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
proxyWebsockets = true;
extraConfig = ''
# off + 1d keep the SSE streams live.
# off + 1d keep the SSE streams (/api/dashboard/stream,
# /api/build-logs/id/{id}/stream) live.
proxy_buffering off;
proxy_read_timeout 1d;
${dashboardAuth}
'';
};
"/webhook/" = {
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
extraConfig = dashboardAuth;
};
};
in
{
@ -862,17 +872,11 @@ in
recommendedTlsSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
# Accept-header SPA maps (see docs/gateway.md "SPA fallback"):
# text/html → index.html, else a sentinel so try_files falls
# through (dashboard → @c0re, matrix → 404). Dashboard map is
# unconditional; matrix map only with the matrix GUI.
appendHttpConfig = ''
map $http_accept $dashboard_spa_target {
default "/__dashboard_no_html_fallback";
"~*text/html" "/index.html";
}
''
+ lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
# "SPA fallback"): text/html → index.html, else a sentinel so
# try_files falls through to 404. The dashboard no longer uses an
# Accept-header map — it routes by path (see dashboardProxyLocation).
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/index.html";