# hive-gateway Single nginx in front of every hyperhive web surface. Runs on the **host**, next to hive-c0re, rather than in its own container: it shares the host netns anyway (see [Vhost map](#vhost-map) below), so containerizing it would buy no network isolation while costing a resolv.conf sync, a machine-bus reload, and three bind mounts. System-config (not meta-flake managed). Configured via `services.hyperhive.gateway.*` + per-subsystem opt-in flags in `services.hyperhive.{forge,matrix,...}`. ## Vhost map | URL | vhost | upstream | source | | --- | --- | --- | --- | | `/` | `_` (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 → `chat./` | `matrix.gui.enable` | | `forge./` | `forge.` | forgejo (`3000`) | `forge.behindGateway` | | `chat./_matrix/*` | `chat.` | tuwunel (`8008`) | `matrix.gatewayHost != null` | | `chat./` | `chat.` | fluffychat-web static | `matrix.gui.enable` | | `chat./config.json` | `chat.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | | `auth./` | `auth.` | authelia (`9091`) | `swarm.authelia.enable` | | `/` | `` | swarm-ui dist (static), behind an authelia subrequest | `swarm.ui.enable` | The authelia vhost is declared only by the host that **runs** authelia, not by every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. ⚠️ **A `502` from this vhost usually means authelia has no users yet, not that the proxy is misconfigured.** Authelia treats an empty user store as a fatal startup error, so an enabled-but-unbootstrapped swarm crash-loops the container while the vhost in front of it works perfectly. Check `journalctl -M swarm-authelia -u authelia-swarm` before suspecting anything here; the bootstrap step is in [`swarm/sso.md`](swarm/sso.md). Per-agent UIs stay sub-path, forge and matrix get sub-domains — see [Sub-domain shape (rationale)](#sub-domain-shape-rationale) below for why. ## Discovery flow (matrix) Operator points client at ``. Sequence: 1. Client fetches `https:///.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat."}}` (no port suffix when gateway listens on 443). The gateway always terminates TLS, so the scheme is always `https`; a non-default `httpsPort` is reflected as the port suffix. 2. Client connects to `chat./_matrix/client/...`. 3. Gateway routes `/_matrix/*` → tuwunel at `127.0.0.1:8008`. matrix-dart-sdk (FluffyChat etc.) hardcodes `https` for the well-known fetch regardless of input scheme, so the discovery endpoint MUST be https — see "Self-signed TLS" below for the cert generation that backs the default-on path. Federation peers fetch `.well-known/matrix/server` → `{"m.server":"chat.:"}` (the federation delegation always carries an explicit port, even the HTTPS default 443 — the https-implies-443 elision only applies to the client base_url above). Gateway only listens on configured `port` (+ `httpsPort` when TLS on); cross-hive federation needs either an SRV record (`_matrix._tcp.chat.` → port 80 / 443) OR `matrix.openFirewall = true` so peers reach tuwunel's federation port directly. Hyperhive is mostly closed/internal, so this rarely bites. ## SPA fallback (Accept-header pattern) The per-agent UIs and the `chat.` vhost serve a flutter/SPA bundle via the Accept-header pattern below. The dashboard vhost instead routes by **path** — see [Dashboard: path-based routing](#dashboard-path-based-routing-not-accept-header) below. 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. For matrix / per-agent static assets, `` is `=404` (a missing asset is just missing). ### Dashboard: path-based routing (not Accept-header) Every hive-c0re backend route lives under `/api/` plus the single `/webhook/knowledge` endpoint, so the dashboard vhost routes by **path**, not Accept header — deterministic, unlike a content-type split where the same URL could resolve differently depending on the caller's `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 doesn't rebuild or restart the core daemon. A new top-level c0re route prefix (beyond `/api` + `/webhook`) needs a matching `location` added to the dashboard vhost. ## Local dev (`localHostsEntry`) `services.hyperhive.gateway.localHostsEntry = true` adds entries to the host's `/etc/hosts`: - `` → `127.0.0.1` - `forge.` → `127.0.0.1` (when forge.behindGateway) - `chat.` → `127.0.0.1` (when matrix.gatewayHost set) - `auth.` → `127.0.0.1` (when swarm.authelia.enable) `lib.unique` de-dupes if any sub-domain happens to equal another entry. Operators with real DNS leave it off. ## Sub-domain shape (rationale) Operator decision: sub-domain over sub-path for forge + matrix, sub-path for per-agent UIs. - forgejo's default `ROOT_URL = http:///` works without any `X-Forwarded-Prefix` gymnastics — sub-domain hosting is the canonical Forgejo deploy shape. - matrix-spec deployments universally use `matrix.` for the actual API listener — federation already expects this. - per-agent UIs are hyperhive-internal and base-path-aware specifically for `/agent//`. Sub-domain per agent would multiply DNS + TLS-per-subdomain cost without per-app config wins. - cookie / storage isolation: a future forge XSS can't reach the dashboard session because they're different origins. `services.hyperhive.{forge.domain,matrix.gatewayHost}` take the full hostname (`forge.darkest.space`, `git.example.com`) rather than a label that gets concatenated with hive-domain — operators want control over the full shape, not a forced `