diff --git a/docs/gateway.md b/docs/gateway.md index db9f4921..d223b110 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -6,7 +6,7 @@ Single nginx in front of every hyperhive web surface. Container `hive-gateway`, | URL | vhost | upstream | source | | --- | --- | --- | --- | -| `/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`) + API/SSE/actions → hive-c0re (`7000`) via `@c0re` | always | +| `/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`); `/api/` + `/webhook/` → hive-c0re (`7000`) | always | | `/agent//` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) | | `/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` | | `/matrix/` (deprecated) | `_` | 301 → `matrix./` | `matrix.gui.enable` | @@ -31,17 +31,24 @@ Federation peers fetch `.well-known/matrix/server` → `{"m.server":"matrix.` catch-all (operator dashboard), the per-agent UIs, and the `matrix.` vhost all serve a flutter/SPA bundle. Two requirements collide: +The per-agent UIs and the `matrix.` vhost serve a flutter/SPA bundle via the Accept-header pattern below. (The `` 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 $_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 $_spa_target ` falls through to ``. No extension allowlist, no `if` block, no regex heuristics. -The two vhosts differ only in ``: +For matrix / per-agent static assets, `` 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`) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 359fc24d..67154a7e 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -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";