Skip to content

host-gateway

One spex dashboard for every project a user serves — instance-validated endpoint records reconciled into a live project list, proxied per project via /p/:projectId/.*

The bar: a user who runs several SpexCode projects on one machine opens ONE dashboard and reaches all of them — no --api-port pairing, no per-project UI process, no "current project" toggle. Each backend stays exactly what it is — one spex serve per repo, loopback-only, auth-unaware, ignorant of every other project; the HOST level is where multiplicity lives.

The seam between the two levels is the endpoint record. After its public bind succeeds, a serve ATOMICALLY publishes {version, url, pid, instanceId, root, identity, startedAt} into the per-user global project store and, on a clean stop, removes only a record that still carries its own instanceId — never a newer serve's, never another project's. The instanceId is minted once per serve lifetime and handed to every child through env, so the identity is stable across zero-downtime reloads; the child answers it (with the root it serves) at GET /api/instance. A record is therefore checkable, not just present: the reader compares instance and actual served root against the live answer, and only a full match counts as online. A linked-worktree serve registers under its own git toplevel and cannot replace the main checkout's slot. A crashed serve, a recycled port now serving something else, or a record copied into the wrong store slot (the slot must equal encodeProject(root)) all fail the match and degrade to "offline project" — never a proxy to the wrong backend.

spex dashboard is gateway-hub plus the host registry — one gateway, one seam, nothing duplicated. The hub owns routing, /p/:projectId/* proxying (HTTP/SSE/WS, prefix-stripped, gateway cookies stripped), and every authorization decision (gateway-auth: admin scope implicit from loopback until an admin password exists; per-project gates as configured). This node mounts the host level onto the hub's extension seam: GET /projects rows become the reconciled list — every cataloged or record-claimed project with its instance-validated online state, not just the live records — each carrying the hub's gating flag and identity config projection; GET /projects/stream is that list as a live SSE; and paths the hub doesn't own serve the built dashboard dist once for the whole host. Per-request routing truth stays the hub's: a record is routable only in the identity shape, only in the store slot its own root encodes to, and only to a loopback url — the shared record-read seam (readEndpointRecord), not a second registry. A project with no record answers 404 before any upstream contact. No --api-port/API_URL pairing survives at this surface; spex serve ui remains the explicit pairing.

The durable known-project catalog remembers what records cannot. Records die with their serve; the catalog (~/.spexcode/projects.json) is the host's memory, populated by explicit registration and by auto-adoption of any validated live record. The reconciled view includes a remembered or record-claimed root only while that root is still an existing directory; a removed checkout/worktree disappears from GET /projects without mutating the catalog file. Explicit registration is one admin-scoped workflow over the real host filesystem: a read-only directory browser selects an existing folder, then POST /projects normalizes it to the repo's main checkout. An existing Git repo can be cataloged directly; a plain folder enters only after the user explicitly chooses the bounded git init side effect. Optional SpexCode setup still runs the real spex init with an explicit harness choice, never a dashboard-owned initializer. A failed init returns its exit code and complete transcript and does not claim catalog success; the catalog write happens only after every requested setup step succeeds. Its project operations ride the same hub admin scope. GET|PUT /projects/:id/config is the narrow source-file seam for the project's raw, committed spexcode.json: it works while the backend is offline, treats an absent file as {}, accepts only a top-level JSON object, writes atomically, and rejects a stale revision rather than overwriting a concurrent edit. It never exposes spexcode.local.json, whose machine-specific layer may carry sensitive paths. Sibling structured icon routes revision-check and update only the canonical project or host field; they never create another setting. Operations remain spawned spex verbs, never forked logic: /projects/:id/init and /doctor run the real spex init / spex doctor with cwd = the project root (same git/harness/additive guarantees, exit code + transcript returned), and /projects/:id/serve starts an offline project's backend as a detached spex serve that publishes its own record and outlives the gateway. A malformed catalog degrades loud-but-alive on read and refuses writes — nothing clobbers the file.

Backends never depend on the gateway. Kill the gateway and every serve keeps serving; direct CLI discovery (remote-client's ladder) reads the same records straight from the store, gateway or no gateway. The gateway obeys the shared port contract (a busy port is a loud non-zero exit via the one bind helper) and carries the standard connection reaping — both via the hub. Authentication is deliberately NOT this node's mechanism: it is gateway-auth's, decided once at the hub — this node adds no second gate, no second cookie, no bypass. Transport is likewise the hub's: startHostDashboard accepts the hub's tls option and hands it through unchanged, so an operator deployment runs the ONE host gateway directly over HTTPS — every surface (admin list, /p proxying, the shell) on that one TLS port, no second proxy in front, and a plaintext client on the TLS port is refused, never silently downgraded. Absent tls, spex dashboard stays plain loopback HTTP; --host widens the bind, behind whatever gates the operator configured.