Skip to content

feat(web): eliminate browser CORS via header-driven /api proxy#54

Merged
offendingcommit merged 26 commits into
mainfrom
feat/web-api-proxy
Jun 2, 2026
Merged

feat(web): eliminate browser CORS via header-driven /api proxy#54
offendingcommit merged 26 commits into
mainfrom
feat/web-api-proxy

Conversation

@offendingcommit
Copy link
Copy Markdown
Owner

@offendingcommit offendingcommit commented Jun 2, 2026

Summary

Eliminates browser CORS for the web build by routing all Honcho API calls through a same-origin /api reverse proxy. The browser names the real upstream per request via an X-Honcho-Upstream header; 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-Upstream header; Tauri → absolute URL + reqwest. The instance store is unchanged (still absolute URLs).
  • nginx ^~ /api/ block — header-driven proxy_pass, /api strip, proxy_ssl_server_name on + Host $proxy_host for HTTPS upstreams, routing header cleared upstream, 421/403 carry an X-Honcho-Proxy-Reject sentinel so an allowlist 403 is never misread as upstream auth.
  • Resolver derived from /etc/resolv.conf at container start — works on both the default bridge (docker run) and compose networks (the hardcoded 127.0.0.11 only existed on user-defined networks).
  • Vite dev middleware mirrors nginx (header read, /api forward, allowlist, gzip-safe relay) for make dev-web parity.
  • Optional OPENCONCHO_UPSTREAM_ALLOWLIST (comma host globs) SSRF guard; unset = open (fine for the 127.0.0.1:8080 binding). Retires HONCHO_UPSTREAM and the same-origin sentinel.
  • Design spec + plan kept under 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:

Test Network Result
/api/v3/workspaces/list → tailnet Honcho default bridge (docker run) HTTP 200, real workspace JSON
same user-defined network (compose) HTTP 200, real workspace JSON
non-matching allowlist 403 + X-Honcho-Proxy-Reject: allowlist

Plus make ci-web green (lint + typecheck + 93 tests + build).

Test it locally

make smoke-docker

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, 421 on missing header, and 403 + 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:8080

Notes

  • Three defects were caught by end-to-end testing after unit-green: 502 on the default bridge (resolver), the bare image defaulting the removed same-origin sentinel, and an ERR_CONTENT_DECODING_FAILED in the dev middleware. All fixed and re-verified.
  • PR CI is web-only; the container smoke test is a local preflight by design.

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.
@offendingcommit offendingcommit merged commit 5a25435 into main Jun 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant