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

@ -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";