feat(web): eliminate browser CORS via header-driven /api proxy#54
Merged
Conversation
Same-origin reverse proxy removes browser CORS for the web build; upstream named per-request via X-Honcho-Upstream header (frontend stays source of truth). Tauri keeps reqwest-absolute. Optional SSRF allowlist, open by default. Preserves existing Fleet aggregation; new aggregation deferred.
Ten TDD tasks: dispatchFor helper, client/checkConnection/discovery routing, runtime-config simplification, nginx header proxy, allowlist map, vite dev parity, env + docs. Gated by make ci-web after each task.
client.current and createScopedClient resolve transport via dispatchFor: web -> absolute origin + /api base with an X-Honcho-Upstream header; Tauri -> absolute instance URL + reqwest. Absolute base (not bare "/api") so openapi-fetch can construct a Request under node/undici and in the browser alike. Fleet fan-out is unchanged; fleet.test.tsx now asserts the proxy contract.
Records the post-execution design correction (absolute same-origin base) and rewrites the checkConnection test to mock @/lib/http rather than globalThis.fetch.
Retire HONCHO_UPSTREAM and the same-origin sentinel; document the per-request X-Honcho-Upstream header model, OPENCONCHO_DEFAULT_HONCHO_URL seeding, and the optional OPENCONCHO_UPSTREAM_ALLOWLIST SSRF guard across compose, README, AGENTS.md, and docs/docker.md.
The published image defaulted OPENCONCHO_DEFAULT_HONCHO_URL=same-origin, but the sentinel was removed — a bare run seeded an invalid "same-origin" base. Default to empty (configure in Settings); HONCHO_UPSTREAM is unused by the new nginx.
Mirrors the nginx allowlist (spec section D) so make dev-web matches prod: when OPENCONCHO_UPSTREAM_ALLOWLIST is set, non-matching upstreams get 403 + X-Honcho-Proxy-Reject; unset stays open.
undici fetch auto-decompresses the body, so re-sending the upstream content-encoding/length would cause ERR_CONTENT_DECODING_FAILED if Honcho gzips. Drop those and hop-by-hop headers when relaying. nginx is unaffected.
Hardcoded resolver 127.0.0.11 only exists on user-defined networks, so a plain docker run on the default bridge 502'd (DNS connection refused). Render the resolver from /etc/resolv.conf at start so per-request proxy_pass resolves on both the default bridge (host DNS) and compose networks (embedded DNS).
make smoke-docker builds the image, stands up a stub upstream + the container on a shared network, and asserts forward+prefix-strip, upstream-header cleared, 421 on missing header, and 403 + reject sentinel on allowlist miss. Self- contained (no tailnet), idempotent, local-only (Docker) like cargo-check.
docker-compose.yml now builds from source (dev-forward: run your local changes); docker-compose.prod.yml overrides it to pull ghcr latest (build reset via !reset). Adds make compose-up / compose-up-prod / compose-down. Env, ports, and extra_hosts stay defined once in the base file; the prod override only swaps build -> image.
README, docs/docker.md, and AGENTS.md now cover make compose-up (dev-forward, builds from source) vs make compose-up-prod (pulls ghcr latest) and compose-down.
Concise verbs: make up (dev build+run), make prod (pull published image), make down (stop regardless of mode), make clean (down + drop local image). Updates all docs and compose-file comment headers to match.
Collapse docker-compose.yml + docker-compose.prod.yml into one file with dev/prod profiles sharing a YAML anchor: dev builds from source, prod pulls ghcr latest. make up/prod select the profile; down passes both so it stops either. Drops the separate prod file and the !reset hack.
A cold/idle self-hosted Honcho can take ~5s on its first request (DB pool, tunnel wake); the hardcoded 5s budget aborted just before the response and reported a live instance as 'Connection timed out'. Extract CONNECTION_TIMEOUT_MS (15s), make checkConnection's timeout injectable, and cover the budget behavior.
Zod v4 deprecates the chained .url() in favor of the top-level format validator.
Closes the multi-instance coverage gap: add (first becomes active, later adds don't steal focus, insertion order), switch (incl. unknown-id no-op), delete (active->first-remaining fallback, non-active unchanged, last clears active), update (patch + unknown-id no-op), active config, and legacy-key migration.
Replace the real tailnet hostname and IP with honcho.example.net / a generic tailnet reference — specs and PRs should carry examples, not live endpoints.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Eliminates browser CORS for the web build by routing all Honcho API calls through a same-origin
/apireverse proxy. The browser names the real upstream per request via anX-Honcho-Upstreamheader; nginx (docker) and a Vite middleware (dev) forward it server-side. The frontend stays the source of truth for instances, so multi-instance and the Fleet view are preserved. Tauri is unchanged (reqwest bypasses CORS).Closes the gap where pointing the web UI at a cross-origin Honcho (e.g. a Tailscale endpoint) failed the
Authorization-header preflight.What changed
dispatchFor(instance)(src/lib/dispatch.ts) — one transport decision: web → absolute same-origin${location.origin}/api+X-Honcho-Upstreamheader; Tauri → absolute URL + reqwest. The instance store is unchanged (still absolute URLs).^~ /api/block — header-drivenproxy_pass,/apistrip,proxy_ssl_server_name on+Host $proxy_hostfor HTTPS upstreams, routing header cleared upstream,421/403carry anX-Honcho-Proxy-Rejectsentinel so an allowlist403is never misread as upstream auth./etc/resolv.confat container start — works on both the default bridge (docker run) and compose networks (the hardcoded127.0.0.11only existed on user-defined networks)./apiforward, allowlist, gzip-safe relay) formake dev-webparity.OPENCONCHO_UPSTREAM_ALLOWLIST(comma host globs) SSRF guard; unset = open (fine for the127.0.0.1:8080binding). RetiresHONCHO_UPSTREAMand thesame-originsentinel.docs/superpowers/as the design record.Validation
Verified end-to-end against a real Honcho (not just unit-mocked) — built the image from this branch and exercised the live proxy path:
/api/v3/workspaces/list→ tailnet Honchodocker run)X-Honcho-Proxy-Reject: allowlistPlus
make ci-webgreen (lint + typecheck + 93 tests + build).Test it locally
A hermetic smoke test (added in this PR): builds the image, stands up a stub upstream + the container on a shared network, and asserts forward+prefix-strip, upstream-header cleared,
421on missing header, and403+ reject sentinel on allowlist miss. Self-contained (no tailnet), idempotent, local-only (Docker required) like the desktop cargo-check.To point the real app at your Honcho:
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up # → http://localhost:8080Notes
502on the default bridge (resolver), the bare image defaulting the removedsame-originsentinel, and anERR_CONTENT_DECODING_FAILEDin the dev middleware. All fixed and re-verified.