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 | | 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>/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>/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` |
| `<hive>/matrix/` (deprecated) | `_` | 301 → `matrix.<hive>/` | `matrix.gui.enable` | | `<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) ## 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) - 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 - 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. 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: path-based routing (not Accept-header)
- **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.
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`) ## Local dev (`localHostsEntry`)

View file

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