diff --git a/AGENTS.md b/AGENTS.md
index eb70138..3a4d8cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,6 +16,11 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
| `make typecheck` | tsc --noEmit |
| `make test` | Vitest (unit + integration), excludes `e2e/` |
| `make test-e2e` | Playwright e2e (uncached) |
+| `make smoke-docker` | Local: build image + hermetic smoke test of the `/api` proxy (Docker required) |
+| `make up` | Run the web container from source (dev-forward, builds) at :8080 |
+| `make prod` | Run the web container from the published image (pulls `ghcr…:latest`) |
+| `make down` | Stop + remove the web container (dev or prod) |
+| `make clean` | `down` + remove the locally built image |
| `make check` | lint + typecheck + test |
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
@@ -64,6 +69,7 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
## Key Constraints
- **No hardcoded URLs** — connection config lives in `localStorage` under `openconcho:instances` (multi-instance store; legacy `openconcho:config` is auto-migrated)
+- **Web CORS via a same-origin `/api` proxy** — the web build issues all Honcho calls to `/api/*` with an `X-Honcho-Upstream` header (the active instance's URL); nginx (docker) and a Vite middleware (dev) forward server-side. Transport is resolved by `dispatchFor` in `src/lib/dispatch.ts`: web → relative `/api` + header; Tauri → absolute URL + reqwest. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
- **Local git hooks** — `.husky/pre-commit` runs a secret scan + Biome on staged files; `.husky/pre-push` runs `pnpm check`. Your commits and pushes trigger these.
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `` callsites
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
diff --git a/Dockerfile b/Dockerfile
index 8c15d47..6ac2e92 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -37,9 +37,10 @@ COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.con
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh
-# Defaults target the Honcho service in a typical Compose stack; override per deploy.
-ENV HONCHO_UPSTREAM=http://api:8000 \
- OPENCONCHO_DEFAULT_HONCHO_URL=same-origin
+# Empty default → clean first run (configure the instance in Settings). Override per
+# deploy to seed the first instance; the browser routes via /api with an
+# X-Honcho-Upstream header. Optional OPENCONCHO_UPSTREAM_ALLOWLIST guards the proxy.
+ENV OPENCONCHO_DEFAULT_HONCHO_URL=""
EXPOSE 8080
diff --git a/Makefile b/Makefile
index b04432f..1f17e46 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,8 @@
.PHONY: bootstrap dev dev-web dev-desktop \
build test test-e2e lint lint-fix typecheck check \
- ci-web ci-desktop install help
+ ci-web ci-desktop smoke-docker \
+ up prod down clean install help
help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@@ -47,5 +48,20 @@ ci-web: ## CI: lint + typecheck + test + build for @openconcho/web
ci-desktop: ## CI: cargo-check for @openconcho/desktop
pnpm ci:desktop
+smoke-docker: ## Local: build the image + smoke-test the /api proxy (Docker required)
+ bash docker/smoke-test.sh
+
+up: ## Run the web container from source (dev profile, builds) at :8080
+ docker compose --profile dev up -d --build
+
+prod: ## Run the web container from the published image (prod profile, pulls latest)
+ docker compose --profile prod up -d
+
+down: ## Stop + remove the web container (either profile)
+ docker compose --profile dev --profile prod down --remove-orphans
+
+clean: down ## down + remove the locally built image
+ -docker image rm openconcho-web:local
+
install: ## pnpm install (no playwright)
pnpm install
diff --git a/README.md b/README.md
index f88becb..3b14bac 100644
--- a/README.md
+++ b/README.md
@@ -89,20 +89,30 @@ pnpm --filter @openconcho/desktop dev
### Docker (web app)
-Run the web UI in a container — handy for adding it to a self-hosted Honcho
-Compose stack. The image serves the SPA and reverse-proxies the Honcho API under
-its own origin, so the browser makes same-origin requests (no CORS to configure).
+The container serves the SPA and reverse-proxies the Honcho API under its own
+origin: the browser calls `/api` same-origin and names the upstream in an
+`X-Honcho-Upstream` header, so there's no browser CORS to configure.
+
+Two Compose modes (the published image is `ghcr.io/offendingcommit/openconcho-web`):
```bash
-docker run --rm -p 8080:8080 \
- -e HONCHO_UPSTREAM=http://host.docker.internal:8000 \
- ghcr.io/offendingcommit/openconcho-web:latest
+# Dev-forward — build from this repo and run your local changes:
+OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
+
+# Production — pull the latest published image instead of building:
+OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
+
+make down # stop + remove (dev or prod)
+make clean # down + drop the locally built image
# → http://localhost:8080
```
-To drop it into a Honcho Compose stack, use the `openconcho` service in
-[`docker-compose.yml`](docker-compose.yml). Full details, env vars, and the CORS
-options are in [`docs/docker.md`](docs/docker.md).
+Both modes live in one [`docker-compose.yml`](docker-compose.yml) as Compose
+profiles: `make up` runs the `dev` profile (`build: .`), `make prod` runs the
+`prod` profile (pulls `ghcr…:latest`). `OPENCONCHO_DEFAULT_HONCHO_URL` seeds the first instance
+(absolute URL); `OPENCONCHO_UPSTREAM_ALLOWLIST` is an optional SSRF guard
+(comma-separated host globs) for when you expose the proxy. Full details and env
+vars are in [`docs/docker.md`](docs/docker.md).
### Connecting to your instance
diff --git a/docker-compose.yml b/docker-compose.yml
index d1d37c2..34b0e40 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,29 +1,48 @@
-# Run OpenConcho's web UI with `docker compose up`.
+# OpenConcho web UI — one file, two Compose profiles (dev builds, prod pulls).
#
-# Standalone: serves the SPA on http://localhost:8080 and reverse-proxies the
-# Honcho API under the same origin (no browser CORS). By default it points at a
-# Honcho running on the host at :8000 — override HONCHO_UPSTREAM for anything else:
+# make up # profile dev: build from THIS repo + run → http://localhost:8080
+# make prod # profile prod: pull ghcr…:latest instead of building
+# make down # stop + remove (either profile)
+# make clean # down + drop the locally built image
#
-# HONCHO_UPSTREAM=https://honcho.example.net docker compose up
+# The SPA issues all Honcho calls same-origin to /api; nginx forwards each to the
+# URL named in the per-request X-Honcho-Upstream header (no browser CORS). Seed the
+# first instance with OPENCONCHO_DEFAULT_HONCHO_URL:
#
-# To fold this into an existing Honcho Compose stack, drop the `openconcho`
-# service into that project, set HONCHO_UPSTREAM to the api service
-# (e.g. http://api:8000), and add `depends_on: { api: { condition: service_healthy } }`.
+# OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
+#
+# To fold into an existing Honcho Compose stack, point the seed at the api service
+# (e.g. http://api:8000 — nginx resolves it on the compose network).
+
+# Shared config (defined once); both profiles reference it via a YAML merge.
+x-openconcho: &openconcho
+ environment:
+ # Absolute URL seeding the first instance; the browser sends it as the
+ # X-Honcho-Upstream header and nginx forwards there (no browser CORS).
+ OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
+ # Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
+ # binding below). Set comma-separated host globs before exposing the proxy:
+ # OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
+ OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
+ ports:
+ - "127.0.0.1:8080:8080"
+ # Lets the default host.docker.internal upstream resolve on Linux too
+ # (Docker Desktop / Colima provide it automatically).
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ restart: unless-stopped
+
services:
+ # Dev-forward — builds from source so you run your local changes (`make up`).
openconcho:
+ <<: *openconcho
+ profiles: ["dev"]
+ build: .
+ image: openconcho-web:local
+
+ # Production — pulls the published image instead of building (`make prod`).
+ openconcho-prod:
+ <<: *openconcho
+ profiles: ["prod"]
image: ghcr.io/offendingcommit/openconcho-web:latest
- # Or build from this repo instead of pulling the published image:
- # build: .
- environment:
- # nginx reverse-proxies /v3 and /health to this upstream (the Honcho API).
- HONCHO_UPSTREAM: ${HONCHO_UPSTREAM:-http://host.docker.internal:8000}
- # The SPA defaults its Honcho base URL to its own origin, so requests flow
- # through the proxy above — no browser CORS, token never leaves the origin.
- OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
- ports:
- - "127.0.0.1:8080:8080"
- # Lets the default host.docker.internal upstream resolve on Linux too
- # (Docker Desktop / Colima provide it automatically).
- extra_hosts:
- - "host.docker.internal:host-gateway"
- restart: unless-stopped
+ pull_policy: always
diff --git a/docker/40-openconcho-config.sh b/docker/40-openconcho-config.sh
index 864de84..88656ca 100644
--- a/docker/40-openconcho-config.sh
+++ b/docker/40-openconcho-config.sh
@@ -1,7 +1,8 @@
#!/bin/sh
# Regenerate the SPA's runtime config from the environment at container start.
# Lets one prebuilt image target any Honcho backend without a rebuild.
-# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL, "same-origin", or empty.
+# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL seeding the first instance, or empty.
+# OPENCONCHO_UPSTREAM_ALLOWLIST — optional comma-separated host globs (SSRF guard).
# Runs from /docker-entrypoint.d before nginx starts. Requires the html dir to
# be writable (default); skip or bind-mount config.js when running --read-only.
set -eu
@@ -9,3 +10,33 @@ set -eu
cat > /usr/share/nginx/html/config.js < /etc/nginx/conf.d/00-resolver.conf
+
+# Render the SSRF allowlist into an nginx map for $allow_upstream.
+# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
+# localhost-bound default. Set it (comma-separated host globs) before exposing
+# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
+ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
+if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
+ printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
+else
+ {
+ printf 'map $http_x_honcho_upstream $allow_upstream {\n'
+ printf ' default 0;\n'
+ IFS=','
+ for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
+ host=$(printf '%s' "$host" | tr -d ' ')
+ [ -z "$host" ] && continue
+ esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g')
+ printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
+ done
+ printf '}\n'
+ } > "$ALLOWLIST_CONF"
+fi
diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template
index c0dd2f6..04abd34 100644
--- a/docker/nginx.conf.template
+++ b/docker/nginx.conf.template
@@ -1,6 +1,6 @@
-# OpenConcho — nginx site config (envsubst template).
-# The nginx image renders ${HONCHO_UPSTREAM} from the environment at start.
-# Serves the React SPA and reverse-proxies the Honcho API under the same origin.
+# OpenConcho — nginx site config.
+# Serves the React SPA and header-driven same-origin /api proxy to Honcho.
+# The browser sends X-Honcho-Upstream per request; nginx forwards server-side (no browser CORS).
server {
listen 8080;
@@ -12,23 +12,28 @@ server {
# Don't leak the nginx version.
server_tokens off;
- # Same-origin reverse proxy to Honcho so the browser never sees a
- # cross-origin request (no CORS). The variable + Docker resolver let nginx
- # start even when the upstream isn't resolvable yet, re-resolving per request.
- resolver 127.0.0.11 ipv6=off valid=10s;
- set $honcho_upstream "${HONCHO_UPSTREAM}";
+ # The resolver (required for per-request DNS with a runtime-variable proxy_pass)
+ # is rendered by the entrypoint into conf.d from the container's own DNS, so it
+ # works on both user-defined networks (127.0.0.11) and the default bridge.
- # `^~` so these win over the static-asset regex below.
- location ^~ /v3/ {
- proxy_pass $honcho_upstream$request_uri;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
- location = /health {
- proxy_pass $honcho_upstream/health;
- proxy_set_header Host $host;
+ # Header-driven same-origin proxy: the browser names the Honcho upstream per
+ # request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
+ # $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
+ location ^~ /api/ {
+ set $upstream $http_x_honcho_upstream;
+ if ($upstream = "") {
+ add_header X-Honcho-Proxy-Reject "no-upstream" always;
+ return 421;
+ }
+ if ($allow_upstream = 0) {
+ add_header X-Honcho-Proxy-Reject "allowlist" always;
+ return 403;
+ }
+ rewrite ^/api/(.*)$ /$1 break;
+ proxy_pass $upstream;
+ proxy_ssl_server_name on;
+ proxy_set_header Host $proxy_host;
+ proxy_set_header X-Honcho-Upstream "";
}
# Runtime config — regenerated per container start, must never be cached.
diff --git a/docker/smoke-test.sh b/docker/smoke-test.sh
new file mode 100755
index 0000000..f933c63
--- /dev/null
+++ b/docker/smoke-test.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Hermetic container smoke test for the same-origin /api proxy.
+#
+# Builds the image, then stands up a stub upstream + the openconcho container on
+# a shared Docker network and asserts the proxy forwards correctly. Fully
+# self-contained — no external Honcho or tailnet needed. Local-only (requires a
+# Docker daemon); not part of PR CI, like the desktop cargo-check preflight.
+#
+# Idempotent: removes its own containers/network on entry and exit. Exits non-zero
+# on any failed assertion.
+#
+# Usage: make smoke-docker (or: bash docker/smoke-test.sh)
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+IMAGE="openconcho-web:smoke"
+NET="oc-smoke-net"
+UPSTREAM="oc-smoke-upstream"
+APP="oc-smoke-app"
+PORT="${SMOKE_PORT:-18080}"
+# Echo server: returns request method/path/headers as JSON for any verb.
+STUB_IMAGE="mendhak/http-https-echo:31"
+FAIL=0
+
+cleanup() {
+ docker rm -f "$APP" "$UPSTREAM" >/dev/null 2>&1 || true
+ docker network rm "$NET" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+cleanup
+
+wait_ready() { # url
+ for _ in $(seq 1 30); do
+ curl -fsS "$1" >/dev/null 2>&1 && return 0
+ sleep 0.5
+ done
+ echo " FAIL: container did not become ready at $1"
+ FAIL=1
+}
+
+check() { # label expected actual
+ if [ "$2" = "$3" ]; then echo " PASS: $1 ($3)"; else echo " FAIL: $1 — expected $2, got $3"; FAIL=1; fi
+}
+
+echo "==> build image"
+docker build -t "$IMAGE" . >/dev/null
+
+echo "==> create network + stub upstream"
+docker network create "$NET" >/dev/null
+docker run -d --name "$UPSTREAM" --network "$NET" -e HTTP_PORT=8080 "$STUB_IMAGE" >/dev/null
+
+echo "==> start openconcho (default-open allowlist)"
+docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
+ -e "OPENCONCHO_DEFAULT_HONCHO_URL=http://$UPSTREAM:8080" "$IMAGE" >/dev/null
+wait_ready "http://localhost:$PORT/healthz"
+
+echo "==> assertions"
+check "healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")"
+check "SPA served 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")"
+check "config.js injected" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")"
+
+# Proxy forwards POST /api/v3/test -> stub, stripping the /api prefix.
+body=$(curl -s "http://localhost:$PORT/api/v3/test" \
+ -H "X-Honcho-Upstream: http://$UPSTREAM:8080" -H 'content-type: application/json' -X POST -d '{}')
+if echo "$body" | grep -q '/v3/test'; then echo " PASS: /api forwards + strips prefix"; else echo " FAIL: forward/strip — body: $body"; FAIL=1; fi
+# Routing header must NOT leak to the upstream.
+if echo "$body" | grep -qi 'x-honcho-upstream'; then echo " FAIL: X-Honcho-Upstream leaked upstream"; FAIL=1; else echo " PASS: X-Honcho-Upstream cleared upstream"; fi
+# Missing routing header -> 421.
+check "missing header 421" 421 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" -X POST -d '{}')"
+
+echo "==> restart with a non-matching allowlist"
+docker rm -f "$APP" >/dev/null
+docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
+ -e "OPENCONCHO_UPSTREAM_ALLOWLIST=*.honcho.dev" "$IMAGE" >/dev/null
+wait_ready "http://localhost:$PORT/healthz"
+check "allowlist reject 403" 403 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" \
+ -H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}')"
+reject=$(curl -s -D- -o /dev/null "http://localhost:$PORT/api/v3/test" \
+ -H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}' | grep -i 'X-Honcho-Proxy-Reject' | tr -d '\r')
+if echo "$reject" | grep -qi 'allowlist'; then echo " PASS: reject sentinel header present"; else echo " FAIL: missing reject sentinel — got: $reject"; FAIL=1; fi
+
+if [ "$FAIL" = 0 ]; then echo "==> SMOKE TEST PASSED"; else echo "==> SMOKE TEST FAILED"; exit 1; fi
diff --git a/docs/docker.md b/docs/docker.md
index 321ee44..be31dd3 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -5,19 +5,55 @@ builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
a non-root user) that also **reverse-proxies the Honcho API under its own
origin**, so the browser never makes a cross-origin request.
-## Add it to a Honcho Compose stack (recommended)
+## How the proxy works
-Honcho's self-hosting path is Docker Compose. Drop the `openconcho` service from
-[`docker-compose.yml`](../docker-compose.yml) into the project that runs your
-Honcho `api`:
+The browser issues every Honcho call same-origin to `/api/*` and names the real
+upstream per request in an `X-Honcho-Upstream` header (sourced from the active
+instance's base URL). nginx strips `/api`, forwards to that upstream server-side,
+and returns the response. Because the browser→nginx hop is same-origin, **no CORS
+applies**; the nginx→Honcho hop is server-side, where CORS is irrelevant. The
+frontend stays the source of truth for which instance to talk to, so the
+multi-instance switcher and the Fleet view keep working.
+
+## Compose: dev vs prod profiles
+
+One [`docker-compose.yml`](../docker-compose.yml) with two profiles; the shared
+config (env, ports, extra_hosts) is defined once via a YAML anchor:
+
+- **`dev` profile** — `build: .`, runs **your local source** (`make up`).
+- **`prod` profile** — pulls the **published image**
+ (`ghcr.io/offendingcommit/openconcho-web:latest`, `pull_policy: always`) (`make prod`).
+
+```bash
+make up # build from source + run → http://localhost:8080
+make prod # pull ghcr…:latest instead of building
+make down # stop + remove (either profile)
+make clean # down + drop the locally built image
+```
+
+`make up` expands to `docker compose --profile dev up -d --build` and `make prod`
+to `docker compose --profile prod up -d`. A bare `docker compose up` (no profile)
+starts nothing — use the make targets or pass `--profile`. Set env inline or via a
+`.env` file:
+
+```bash
+OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
+```
+
+The published image is multi-arch (amd64 + arm64); the first publish creates a
+private GHCR package — make it public for unauthenticated pulls.
+
+## Add it to an existing Honcho Compose stack
+
+Drop the `openconcho` service into the project that runs your Honcho `api`,
+pointing the seed at the api service (nginx resolves it on the compose network):
```yaml
services:
openconcho:
image: ghcr.io/offendingcommit/openconcho-web:latest
environment:
- HONCHO_UPSTREAM: http://api:8000 # nginx proxies /v3 + /health here
- OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
+ OPENCONCHO_DEFAULT_HONCHO_URL: http://api:8000
ports:
- "127.0.0.1:8080:8080"
depends_on:
@@ -26,17 +62,16 @@ services:
restart: unless-stopped
```
-`OPENCONCHO_DEFAULT_HONCHO_URL: same-origin` makes the UI default its Honcho
-base URL to its own origin, so API calls go through the proxy → **no browser
-CORS, and the API token never leaves the origin.** The published image is
-multi-arch (amd64 + arm64); the first publish creates a private GHCR package —
-make it public if you want unauthenticated pulls.
+`OPENCONCHO_DEFAULT_HONCHO_URL` seeds the UI's first instance with an absolute
+URL. The browser sends that URL in the `X-Honcho-Upstream` header; nginx (on the
+compose network) forwards to it — **no browser CORS, and the API token never
+leaves the origin.**
-## Standalone
+## Standalone (no compose)
```bash
-docker build -t openconcho-web .
-docker run --rm -p 8080:8080 -e HONCHO_UPSTREAM=http://host.docker.internal:8000 openconcho-web
+docker run --rm -p 8080:8080 -e OPENCONCHO_DEFAULT_HONCHO_URL=http://host.docker.internal:8000 \
+ ghcr.io/offendingcommit/openconcho-web:latest
# → http://localhost:8080 · GET /healthz returns "ok"
```
@@ -44,24 +79,26 @@ Runtime knobs (no rebuild needed):
| Env | Default | Meaning |
|-----|---------|---------|
-| `HONCHO_UPSTREAM` | `http://api:8000` | Where nginx proxies `/v3` and `/health` |
-| `OPENCONCHO_DEFAULT_HONCHO_URL` | `same-origin` | SPA's default base URL — `same-origin`, an absolute URL, or empty (configure in Settings) |
+| `OPENCONCHO_DEFAULT_HONCHO_URL` | _(empty)_ | Absolute URL seeding the first instance; empty = configure in Settings |
+| `OPENCONCHO_UPSTREAM_ALLOWLIST` | _(empty)_ | Optional SSRF guard: comma-separated host globs (e.g. `honcho.example.net,*.honcho.dev`). Empty = forward anywhere |
Hardened run adds `--read-only --cap-drop ALL --security-opt no-new-privileges`
-with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the runtime config writes
-`config.js` into the web root at start, which a read-only root blocks — under
-`--read-only` either bind-mount `config.js` or leave
-`OPENCONCHO_DEFAULT_HONCHO_URL` empty and set the URL in Settings.
+with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the entrypoint writes
+`config.js` and the allowlist map at start, which a read-only root blocks — under
+`--read-only` either bind-mount those paths or leave the env empty and configure
+the URL in Settings.
-## CORS, the short version
+## SSRF: when to set the allowlist
-The desktop app routes HTTP through Rust and bypasses browser CORS; the web
-build doesn't. The Compose setup above **solves CORS via the same-origin proxy**
-— nothing to configure on Honcho. If instead you point the UI at a *different*
-origin (absolute `OPENCONCHO_DEFAULT_HONCHO_URL` or a URL typed in Settings),
-allow that origin in Honcho's FastAPI `CORSMiddleware`:
+The header-driven proxy forwards to whatever upstream the client names. With the
+default `127.0.0.1:8080` binding only your own machine can reach nginx, so leaving
+the allowlist open is fine. **Before exposing the proxy** (e.g. behind a tunnel),
+set `OPENCONCHO_UPSTREAM_ALLOWLIST` to the host globs you trust — non-matching
+upstreams are rejected with `403` and an `X-Honcho-Proxy-Reject: allowlist` header.
-```python
-app.add_middleware(CORSMiddleware, allow_origins=["https://your-ui-origin"],
- allow_methods=["*"], allow_headers=["*"])
-```
+## CORS, the short version
+
+The desktop app routes HTTP through Rust (reqwest) and bypasses browser CORS; the
+web build solves it with the same-origin `/api` proxy above — **nothing to
+configure on Honcho.** The proxy makes a Honcho-side `CORSMiddleware` unnecessary
+regardless of which instance you point at.
diff --git a/docs/superpowers/plans/2026-06-02-web-api-proxy.md b/docs/superpowers/plans/2026-06-02-web-api-proxy.md
new file mode 100644
index 0000000..6ff4bbc
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-02-web-api-proxy.md
@@ -0,0 +1,782 @@
+# Header-Driven `/api` Proxy — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Eliminate browser CORS for the web build by routing Honcho API calls through a same-origin, header-driven reverse proxy, while leaving the Tauri desktop path untouched and preserving Fleet aggregation.
+
+**Architecture:** A single `dispatchFor(instance)` helper decides transport at runtime — web mode returns `baseUrl="/api"` plus an `X-Honcho-Upstream` header (the real Honcho URL); Tauri mode returns the absolute URL and reqwest. nginx (docker) and a Vite middleware (dev) read the header and forward server-side, so the browser never makes a cross-origin request. Instances are still stored as absolute URLs — only dispatch changes.
+
+**Tech Stack:** React 19, openapi-fetch, TanStack Query, Vitest, Biome, nginx (envsubst template), Vite dev server, Tauri v2.
+
+**Spec:** `docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md`
+
+**Baseline gate (run before starting AND after every task):**
+`make ci-web` (lint + typecheck + test + build). Targeted test during a task: `pnpm --filter @openconcho/web exec vitest run `.
+
+**Commit discipline:** conventional commits, one logical change per commit, body lines ≤100 chars, no AI attribution. Branch `feat/web-api-proxy` (already created; spec already committed there).
+
+> **Amendment (2026-06-02, post-execution):** Tasks 1–3 are implemented and committed
+> (`d4452ab`, `9945e4c`, `0935099`). During execution a design correction was made:
+> the web-mode base is **absolute same-origin** (`${location.origin}/api`), not the bare
+> relative `"/api"`. Reason: a relative base makes `openapi-fetch` call
+> `new Request("/api/...")`, which throws `ERR_INVALID_URL` under node/undici and is
+> fragile in the browser. `dispatchFor` now returns `${location.origin}${API_PREFIX}`
+> in web mode. Consequently `fleet.test.tsx` **was** updated (the original claim that it
+> "stays green untouched" was wrong — the transport contract genuinely changed) and any
+> test that inspects the dispatched URL asserts the absolute same-origin form. Tests mock
+> `@/lib/http` (not `globalThis.fetch`), because `httpFetch` captures the fetch reference
+> at module load, so `vi.stubGlobal("fetch", …)` would not intercept it.
+
+---
+
+## File Structure
+
+| File | Responsibility | Action |
+|------|----------------|--------|
+| `packages/web/src/lib/platform.ts` | Single `isTauri()` predicate (leaf module, no app imports) | Create |
+| `packages/web/src/lib/dispatch.ts` | `dispatchFor()` + proxy constants — the one transport decision | Create |
+| `packages/web/src/lib/http.ts` | Keep `httpFetch`; consume `isTauri` from platform | Modify |
+| `packages/web/src/lib/discovery.ts` | Re-export `isTauri` from platform; route probe via `dispatchFor` | Modify |
+| `packages/web/src/api/client.ts` | Active-instance client via `dispatchFor` | Modify |
+| `packages/web/src/api/scopedClient.ts` | Scoped client (Fleet/seed-kits) via `dispatchFor` | Modify |
+| `packages/web/src/lib/config.ts` | `checkConnection` via `dispatchFor` + proxy-reject handling | Modify |
+| `packages/web/src/lib/runtimeConfig.ts` | Drop `same-origin` sentinel | Modify |
+| `packages/web/vite.config.ts` | Dev `/api` proxy middleware mirroring nginx | Modify |
+| `docker/nginx.conf.template` | Header-driven `^~ /api/` block (replaces `/v3` + `/health`) | Modify |
+| `docker/40-openconcho-config.sh` | Render allowlist `map` for `$allow_upstream` | Modify |
+| `docker-compose.yml` | Retire `HONCHO_UPSTREAM`; document allowlist env | Modify |
+| `AGENTS.md`, `README.md` | Proxy contract + env vars | Modify |
+| `packages/web/src/test/dispatch.test.ts` | Unit tests for `dispatchFor` | Create |
+| `packages/web/src/test/check-connection.test.ts` | Unit tests for `checkConnection` proxy behavior | Create |
+
+---
+
+## Task 1: Extract `isTauri()` into a leaf module
+
+Prevents an import cycle: `discovery.ts` will later import `dispatchFor`, and `dispatch.ts` needs `isTauri`. A leaf `platform.ts` breaks the cycle and gives one canonical predicate (WIOCHE).
+
+**Files:**
+- Create: `packages/web/src/lib/platform.ts`
+- Test: `packages/web/src/test/platform.test.ts`
+- Modify: `packages/web/src/lib/http.ts`, `packages/web/src/lib/discovery.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/web/src/test/platform.test.ts`:
+```ts
+import { afterEach, describe, expect, it } from "vitest";
+import { isTauri } from "@/lib/platform";
+
+describe("isTauri", () => {
+ afterEach(() => {
+ delete (window as unknown as Record).__TAURI_INTERNALS__;
+ });
+
+ it("returns false in a plain browser/jsdom environment", () => {
+ expect(isTauri()).toBe(false);
+ });
+
+ it("returns true when the Tauri internals global is present", () => {
+ (window as unknown as Record).__TAURI_INTERNALS__ = {};
+ expect(isTauri()).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/platform.test.ts`
+Expected: FAIL — cannot resolve `@/lib/platform`.
+
+- [ ] **Step 3: Create the module**
+
+`packages/web/src/lib/platform.ts`:
+```ts
+/** True when running inside the Tauri desktop shell (WebView with injected internals). */
+export function isTauri(): boolean {
+ return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
+}
+```
+
+- [ ] **Step 4: Point existing consumers at the canonical predicate**
+
+In `packages/web/src/lib/http.ts`, replace the inline const with the shared predicate (call it at module load to preserve current behavior):
+```ts
+import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
+import { isTauri } from "@/lib/platform";
+
+// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
+// Falls back to native browser fetch during plain web dev.
+export const httpFetch: typeof globalThis.fetch = isTauri()
+ ? (tauriFetch as typeof globalThis.fetch)
+ : globalThis.fetch;
+```
+
+In `packages/web/src/lib/discovery.ts`, delete the local `isTauri` function (lines 8-10) and re-export the canonical one so existing importers keep working. Add at the top, after the `httpFetch` import:
+```ts
+export { isTauri } from "@/lib/platform";
+```
+
+- [ ] **Step 5: Run tests + lint + typecheck**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/platform.test.ts && make lint && make typecheck`
+Expected: PASS; no type errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/web/src/lib/platform.ts packages/web/src/lib/http.ts \
+ packages/web/src/lib/discovery.ts packages/web/src/test/platform.test.ts
+git commit -m "refactor(web): extract isTauri into a leaf platform module"
+```
+
+---
+
+## Task 2: `dispatchFor` helper + proxy constants
+
+The heart of the design. Decides transport per instance.
+
+**Files:**
+- Create: `packages/web/src/lib/dispatch.ts`
+- Test: `packages/web/src/test/dispatch.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/web/src/test/dispatch.test.ts`:
+```ts
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const mockIsTauri = vi.fn();
+vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
+
+import {
+ API_PREFIX,
+ dispatchFor,
+ PROXY_REJECT_HEADER,
+ UPSTREAM_HEADER,
+} from "@/lib/dispatch";
+
+afterEach(() => mockIsTauri.mockReset());
+
+describe("dispatchFor — web mode", () => {
+ it("targets the /api prefix and carries the upstream header", () => {
+ mockIsTauri.mockReturnValue(false);
+ const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
+ expect(d.baseUrl).toBe(API_PREFIX);
+ expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
+ expect(d.headers.Authorization).toBeUndefined();
+ });
+
+ it("adds Authorization only when a token is present", () => {
+ mockIsTauri.mockReturnValue(false);
+ const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
+ expect(d.headers.Authorization).toBe("Bearer sk-1");
+ });
+});
+
+describe("dispatchFor — tauri mode", () => {
+ it("targets the absolute URL with no upstream header", () => {
+ mockIsTauri.mockReturnValue(true);
+ const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
+ expect(d.baseUrl).toBe("https://honcho.example.net");
+ expect(d.headers[UPSTREAM_HEADER]).toBeUndefined();
+ expect(d.headers.Authorization).toBe("Bearer sk-1");
+ });
+});
+
+describe("proxy reject header constant", () => {
+ it("is the agreed sentinel name", () => {
+ expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/dispatch.test.ts`
+Expected: FAIL — cannot resolve `@/lib/dispatch`.
+
+- [ ] **Step 3: Create the helper**
+
+`packages/web/src/lib/dispatch.ts`:
+```ts
+import { httpFetch } from "@/lib/http";
+import { isTauri } from "@/lib/platform";
+
+/** Same-origin path prefix the web build issues all Honcho calls through. */
+export const API_PREFIX = "/api";
+/** Request header naming the real Honcho upstream for the proxy to forward to. */
+export const UPSTREAM_HEADER = "X-Honcho-Upstream";
+/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */
+export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject";
+
+export interface Dispatch {
+ baseUrl: string;
+ headers: Record;
+ fetch: typeof globalThis.fetch;
+}
+
+function normalizeUpstream(url: string): string {
+ return url.trim().replace(/\/+$/, "");
+}
+
+/**
+ * Resolve how to issue a request for an instance.
+ * - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
+ * - Tauri: the absolute instance URL via reqwest (no browser same-origin policy).
+ */
+export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
+
+ if (isTauri()) {
+ return { baseUrl: instance.baseUrl, headers, fetch: httpFetch };
+ }
+
+ headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
+ return { baseUrl: API_PREFIX, headers, fetch: httpFetch };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/dispatch.test.ts`
+Expected: PASS (5 assertions across 4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/src/lib/dispatch.ts packages/web/src/test/dispatch.test.ts
+git commit -m "feat(web): add dispatchFor transport helper for same-origin proxy"
+```
+
+---
+
+## Task 3: Route the API clients through `dispatchFor`
+
+**Files:**
+- Modify: `packages/web/src/api/client.ts`, `packages/web/src/api/scopedClient.ts`
+
+- [ ] **Step 1: Rewrite `client.ts`**
+
+`packages/web/src/api/client.ts`:
+```ts
+import createClient from "openapi-fetch";
+import { loadConfig } from "@/lib/config";
+import { dispatchFor } from "@/lib/dispatch";
+import type { paths } from "./schema.d.ts";
+
+export function createHonchoClient() {
+ const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
+ const { baseUrl, headers, fetch } = dispatchFor(config);
+ return createClient({ baseUrl, headers, fetch });
+}
+
+export const client = {
+ get current() {
+ return createHonchoClient();
+ },
+};
+```
+
+- [ ] **Step 2: Rewrite `scopedClient.ts`**
+
+`packages/web/src/api/scopedClient.ts`:
+```ts
+import createClient from "openapi-fetch";
+import type { Instance } from "@/lib/config";
+import { dispatchFor } from "@/lib/dispatch";
+import type { paths } from "./schema.d.ts";
+
+export type ScopedClient = ReturnType>;
+
+/**
+ * Create an openapi-fetch client bound to a specific instance. Use for views that
+ * query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
+ * client self-routes via its own X-Honcho-Upstream header in web mode.
+ */
+export function createScopedClient(instance: Instance): ScopedClient {
+ const { baseUrl, headers, fetch } = dispatchFor(instance);
+ return createClient({ baseUrl, headers, fetch });
+}
+```
+
+- [ ] **Step 3: Verify the full web suite stays green**
+
+Run: `pnpm --filter @openconcho/web exec vitest run && make typecheck && make lint`
+Expected: PASS — existing `fleet.test.tsx`, `seed-kits.test.ts`, `app.test.tsx` still pass (transport swap is invisible to them).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/web/src/api/client.ts packages/web/src/api/scopedClient.ts
+git commit -m "feat(web): route api clients through dispatchFor"
+```
+
+---
+
+## Task 4: `checkConnection` + discovery via the proxy, with reject handling
+
+**Files:**
+- Modify: `packages/web/src/lib/config.ts` (`checkConnection`), `packages/web/src/lib/discovery.ts` (`suggestNameForInstance`)
+- Test: `packages/web/src/test/check-connection.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/web/src/test/check-connection.test.ts`:
+```ts
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+// Mock the platform predicate (web mode) and the fetch boundary. We mock
+// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch
+// reference at module load, so vi.stubGlobal would not be observed by dispatchFor.
+const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({
+ mockIsTauri: vi.fn(() => false),
+ httpFetchMock: vi.fn(),
+}));
+vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
+vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock }));
+
+import { checkConnection } from "@/lib/config";
+
+afterEach(() => {
+ httpFetchMock.mockReset();
+ mockIsTauri.mockReturnValue(false);
+});
+
+describe("checkConnection — web proxy mode", () => {
+ it("calls the absolute same-origin /api path with the upstream header", async () => {
+ httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
+ const res = await checkConnection("https://honcho.example.net", "sk-1");
+ expect(res.status).toBe("ok");
+ const [url, init] = httpFetchMock.mock.calls[0];
+ expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`);
+ expect((init.headers as Record)["X-Honcho-Upstream"]).toBe(
+ "https://honcho.example.net",
+ );
+ expect((init.headers as Record).Authorization).toBe("Bearer sk-1");
+ });
+
+ it("maps an upstream 401 to auth-required", async () => {
+ httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 }));
+ const res = await checkConnection("https://honcho.example.net");
+ expect(res.status).toBe("auth-required");
+ });
+
+ it("treats a proxy reject as unreachable, not auth-required", async () => {
+ httpFetchMock.mockResolvedValue(
+ new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
+ );
+ const res = await checkConnection("https://blocked.example.net");
+ expect(res.status).toBe("unreachable");
+ expect(res.message).toMatch(/allowlist/i);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/check-connection.test.ts`
+Expected: FAIL — current `checkConnection` fetches `${baseUrl}/v3/...` directly (no `/api`, no upstream header, no reject handling).
+
+- [ ] **Step 3: Rewrite `checkConnection`**
+
+In `packages/web/src/lib/config.ts`, replace the `checkConnection` body (keep the signature). Add the import `import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";` at the top (and remove the now-unused `httpFetch` import if nothing else in the file uses it):
+```ts
+export async function checkConnection(
+ baseUrl: string,
+ token?: string,
+): Promise<{ status: HealthStatus; message: string }> {
+ try {
+ const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
+ const res = await fetch(`${base}/v3/workspaces/list`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({}),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ const reject = res.headers.get(PROXY_REJECT_HEADER);
+ if (reject) {
+ return { status: "unreachable", message: `Proxy refused upstream (${reject})` };
+ }
+ if (res.ok) return { status: "ok", message: "Connected successfully" };
+ if (res.status === 401 || res.status === 403) {
+ return { status: "auth-required", message: "Authentication required — provide an API token" };
+ }
+ return { status: "unreachable", message: `Server returned ${res.status}` };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ if (msg.includes("AbortError") || msg.includes("timeout")) {
+ return { status: "unreachable", message: "Connection timed out" };
+ }
+ return { status: "unreachable", message: `Cannot reach server: ${msg}` };
+ }
+}
+```
+
+- [ ] **Step 4: Route the discovery probe through `dispatchFor` too**
+
+In `packages/web/src/lib/discovery.ts`, rewrite `suggestNameForInstance` to dispatch consistently. Add `import { dispatchFor } from "@/lib/dispatch";` and replace the fetch line:
+```ts
+export async function suggestNameForInstance(baseUrl: string): Promise {
+ try {
+ const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl });
+ const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({}),
+ signal: AbortSignal.timeout(2000),
+ });
+ if (!res.ok) return null;
+ const data = (await res.json()) as { items?: Array<{ id?: string }> };
+ const wsId = data.items?.[0]?.id;
+ if (typeof wsId === "string" && wsId.length > 0) {
+ return deriveNameFromWorkspaceId(wsId);
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+```
+(Leave the top-of-file `httpFetch` import only if still referenced; otherwise remove it. `discovery.ts` no longer needs `httpFetch` after this change — remove the import.)
+
+- [ ] **Step 5: Run tests + gate**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/check-connection.test.ts && pnpm --filter @openconcho/web exec vitest run && make typecheck && make lint`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/web/src/lib/config.ts packages/web/src/lib/discovery.ts \
+ packages/web/src/test/check-connection.test.ts
+git commit -m "feat(web): route checkConnection and discovery through the proxy"
+```
+
+---
+
+## Task 5: Drop the `same-origin` sentinel from runtime config
+
+In header mode the default instance needs a real absolute URL (it becomes the header value); `same-origin` was glue for the retired `/v3` proxy.
+
+**Files:**
+- Modify: `packages/web/src/lib/runtimeConfig.ts`
+- Test: `packages/web/src/test/runtime-config.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/web/src/test/runtime-config.test.ts`:
+```ts
+import { afterEach, describe, expect, it } from "vitest";
+import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
+
+const KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
+
+afterEach(() => {
+ delete (globalThis as Record)[KEY];
+});
+
+describe("runtimeDefaultBaseUrl", () => {
+ it("returns an injected absolute URL verbatim", () => {
+ (globalThis as Record)[KEY] = "https://honcho.example.net";
+ expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
+ });
+
+ it("returns null when unset or empty", () => {
+ expect(runtimeDefaultBaseUrl()).toBeNull();
+ (globalThis as Record)[KEY] = " ";
+ expect(runtimeDefaultBaseUrl()).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify current behavior is covered/fails appropriately**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/runtime-config.test.ts`
+Expected: PASS for absolute/empty (existing behavior), but the goal is to simplify; proceed to remove the sentinel branch.
+
+- [ ] **Step 3: Simplify the module**
+
+`packages/web/src/lib/runtimeConfig.ts`:
+```ts
+const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
+
+/**
+ * Runtime-injected default Honcho base URL for container deployments.
+ *
+ * The Docker image writes `/config.js` from `OPENCONCHO_DEFAULT_HONCHO_URL` at
+ * container start, so one prebuilt image can target any backend without a rebuild.
+ * The web build proxies this URL via the same-origin `/api` reverse proxy (no CORS).
+ *
+ * - an absolute URL → that URL (seeds the first instance)
+ * - empty / unset → null (no default; the user configures in Settings)
+ */
+export function runtimeDefaultBaseUrl(): string | null {
+ const raw = (globalThis as Record)[GLOBAL_KEY];
+ if (typeof raw !== "string" || raw.trim() === "") return null;
+ return raw.trim();
+}
+```
+
+- [ ] **Step 4: Run test + gate**
+
+Run: `pnpm --filter @openconcho/web exec vitest run src/test/runtime-config.test.ts && make typecheck && make lint`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/web/src/lib/runtimeConfig.ts packages/web/src/test/runtime-config.test.ts
+git commit -m "refactor(web): drop same-origin sentinel from runtime config"
+```
+
+---
+
+## Task 6: nginx header-driven `/api` proxy
+
+**Files:**
+- Modify: `docker/nginx.conf.template`
+
+- [ ] **Step 1: Replace the `/v3` + `/health` blocks with the `/api` block**
+
+In `docker/nginx.conf.template`, delete the `location ^~ /v3/ { ... }` and `location = /health { ... }` blocks (lines 21-32) and the `set $honcho_upstream ...` line. Keep the `resolver` line. Insert:
+```nginx
+ # Header-driven same-origin proxy: the browser names the Honcho upstream per
+ # request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
+ # $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
+ location ^~ /api/ {
+ set $upstream $http_x_honcho_upstream;
+ if ($upstream = "") {
+ add_header X-Honcho-Proxy-Reject "no-upstream" always;
+ return 421;
+ }
+ if ($allow_upstream = 0) {
+ add_header X-Honcho-Proxy-Reject "allowlist" always;
+ return 403;
+ }
+ rewrite ^/api/(.*)$ /$1 break;
+ proxy_pass $upstream;
+ proxy_ssl_server_name on;
+ proxy_set_header Host $proxy_host;
+ proxy_set_header X-Honcho-Upstream "";
+ }
+```
+
+- [ ] **Step 2: Validate template renders to valid nginx syntax**
+
+Run (renders the template with a dummy upstream and an open allowlist map, then `nginx -t`):
+```bash
+docker run --rm -e HONCHO_UPSTREAM=http://x:8000 -v "$PWD/docker":/d nginxinc/nginx-unprivileged:stable sh -c '
+ mkdir -p /etc/nginx/conf.d
+ echo "map \$http_x_honcho_upstream \$allow_upstream { default 1; }" > /etc/nginx/conf.d/allowlist_map.conf
+ envsubst "\$HONCHO_UPSTREAM" < /d/nginx.conf.template > /etc/nginx/conf.d/default.conf
+ nginx -t'
+```
+Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docker/nginx.conf.template
+git commit -m "feat(docker): header-driven /api reverse proxy in nginx"
+```
+
+---
+
+## Task 7: Entrypoint renders the allowlist map
+
+**Files:**
+- Modify: `docker/40-openconcho-config.sh`
+
+- [ ] **Step 1: Append allowlist-map rendering**
+
+In `docker/40-openconcho-config.sh`, after the existing `config.js` heredoc, append:
+```sh
+# Render the SSRF allowlist into an nginx map for $allow_upstream.
+# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
+# localhost-bound default. Set it (comma-separated host globs) before exposing
+# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
+ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
+if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
+ printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
+else
+ {
+ printf 'map $http_x_honcho_upstream $allow_upstream {\n'
+ printf ' default 0;\n'
+ IFS=','
+ for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
+ host=$(printf '%s' "$host" | tr -d ' ')
+ [ -z "$host" ] && continue
+ esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's/[*]/[^/]*/g')
+ printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
+ done
+ printf '}\n'
+ } > "$ALLOWLIST_CONF"
+fi
+```
+
+- [ ] **Step 2: Validate generated map syntax (allowlist set)**
+
+Run:
+```bash
+docker run --rm -e OPENCONCHO_UPSTREAM_ALLOWLIST="honcho.example.net,*.honcho.dev" \
+ -e HONCHO_UPSTREAM=http://x:8000 -v "$PWD/docker":/d nginxinc/nginx-unprivileged:stable sh -c '
+ mkdir -p /etc/nginx/conf.d
+ export OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net
+ sh /d/40-openconcho-config.sh || true
+ envsubst "\$HONCHO_UPSTREAM" < /d/nginx.conf.template > /etc/nginx/conf.d/default.conf
+ nginx -t && cat /etc/nginx/conf.d/allowlist_map.conf'
+```
+Expected: `nginx -t` success; printed map contains regex lines for both hosts.
+Note: the script writes `config.js` to `/usr/share/nginx/html`; if that dir is absent in this bare check, the heredoc line may error — that is fine for syntax validation (the `|| true` guards it). The allowlist block still runs.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docker/40-openconcho-config.sh
+git commit -m "feat(docker): render SSRF allowlist map from env"
+```
+
+---
+
+## Task 8: Vite dev proxy middleware (dev/CI parity)
+
+**Files:**
+- Modify: `packages/web/vite.config.ts`
+
+- [ ] **Step 1: Add a `configureServer` plugin mirroring nginx**
+
+In `packages/web/vite.config.ts`, add this plugin factory above `defineConfig` and include `honchoApiProxy()` in the `plugins` array (after `react()`):
+```ts
+import type { Plugin } from "vite";
+
+function honchoApiProxy(): Plugin {
+ const HEADER = "x-honcho-upstream";
+ return {
+ name: "honcho-api-proxy",
+ configureServer(server) {
+ server.middlewares.use("/api", async (req, res) => {
+ const upstream = req.headers[HEADER];
+ if (typeof upstream !== "string" || upstream.trim() === "") {
+ res.statusCode = 421;
+ res.setHeader("X-Honcho-Proxy-Reject", "no-upstream");
+ res.end();
+ return;
+ }
+ const target = upstream.replace(/\/+$/, "") + (req.url ?? "");
+ const chunks: Buffer[] = [];
+ for await (const c of req) chunks.push(c as Buffer);
+ try {
+ const upstreamRes = await fetch(target, {
+ method: req.method,
+ headers: {
+ "content-type": req.headers["content-type"] ?? "application/json",
+ ...(req.headers.authorization
+ ? { authorization: req.headers.authorization }
+ : {}),
+ },
+ body: ["GET", "HEAD"].includes(req.method ?? "") ? undefined : Buffer.concat(chunks),
+ });
+ res.statusCode = upstreamRes.status;
+ upstreamRes.headers.forEach((v, k) => res.setHeader(k, v));
+ res.end(Buffer.from(await upstreamRes.arrayBuffer()));
+ } catch (e) {
+ res.statusCode = 502;
+ res.end(`proxy error: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ });
+ },
+ };
+}
+```
+Update the plugins line to:
+```ts
+ plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), honchoApiProxy(), tailwindcss()],
+```
+
+- [ ] **Step 2: Typecheck + lint (the config is type-checked by the build)**
+
+Run: `make typecheck && make lint`
+Expected: PASS (no `any`, `Plugin` typed).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/web/vite.config.ts
+git commit -m "feat(web): dev /api proxy middleware mirroring nginx"
+```
+
+---
+
+## Task 9: Compose env + docs
+
+**Files:**
+- Modify: `docker-compose.yml`, `AGENTS.md`, `README.md`
+
+- [ ] **Step 1: Update `docker-compose.yml`**
+
+Replace the `environment:` block and its comments so the upstream is no longer a single env var. New `environment:` section:
+```yaml
+ environment:
+ # The SPA seeds its first instance from this absolute URL; the browser then
+ # routes all calls same-origin through /api, and nginx forwards them to the
+ # URL named per-request in the X-Honcho-Upstream header (no browser CORS).
+ OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
+ # Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
+ # binding below). Set comma-separated host globs before exposing the proxy:
+ # OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
+ OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
+```
+Update the top-of-file comment block: replace the `HONCHO_UPSTREAM=...` example with `OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net docker compose up` and note the per-request header model.
+
+- [ ] **Step 2: Update `AGENTS.md` Key Constraints**
+
+Replace the CORS-relevant bullet(s) with:
+```markdown
+- **Web CORS is handled by a same-origin `/api` proxy** — the browser issues all
+ Honcho calls to `/api/*` with an `X-Honcho-Upstream` header; nginx (docker) and a
+ Vite middleware (dev) forward server-side. Tauri bypasses CORS via reqwest and uses
+ absolute URLs. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
+```
+
+- [ ] **Step 3: Update `README.md`**
+
+In the Docker/run section, document `OPENCONCHO_DEFAULT_HONCHO_URL` (absolute URL seed) and `OPENCONCHO_UPSTREAM_ALLOWLIST` (optional, comma-separated host globs), and remove any `HONCHO_UPSTREAM` references.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docker-compose.yml AGENTS.md README.md
+git commit -m "docs: document the /api proxy contract and env vars"
+```
+
+---
+
+## Task 10: Final CI-parity gate + branch readiness
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Run the full web gate**
+
+Run: `make ci-web`
+Expected: lint, typecheck, test, and build all PASS.
+
+- [ ] **Step 2: Confirm no `HONCHO_UPSTREAM` or `same-origin` references remain**
+
+Run: `grep -rn "HONCHO_UPSTREAM\|same-origin" docker docker-compose.yml packages/web/src README.md AGENTS.md`
+Expected: no matches in code/config (only the spec/plan under `docs/` may mention them historically).
+
+- [ ] **Step 3: Confirm clean tree**
+
+Run: `git status --short`
+Expected: empty (all work committed).
+
+---
+
+## Self-Review (completed by author)
+
+- **Spec coverage:** dispatch helper (T2/T3), checkConnection+discovery (T4), nginx header proxy + SNI + reject header (T6), allowlist map (T7), vite parity (T8), env migration + docs (T9), Fleet preserved (T3 — scopedClient routed; existing fleet.test.tsx asserted green), runtime sentinel drop (T5). All spec sections mapped.
+- **Placeholder scan:** none — every code step shows full content.
+- **Type consistency:** `dispatchFor`, `Dispatch`, `API_PREFIX`, `UPSTREAM_HEADER`, `PROXY_REJECT_HEADER` are defined in T2 and consumed verbatim in T3/T4. `isTauri()` defined T1, consumed T2.
diff --git a/docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md b/docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md
new file mode 100644
index 0000000..164abdc
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-02-honcho-api-proxy-design.md
@@ -0,0 +1,243 @@
+# Header-Driven `/api` Proxy for Web CORS — Design
+
+- **Date:** 2026-06-02
+- **Status:** Approved (design) — pending spec review
+- **Scope:** One concern — eliminate browser CORS for the web build by routing
+ Honcho API calls through a same-origin, header-driven reverse proxy. Preserve
+ existing Fleet aggregation. No new aggregation features (deferred).
+
+## Problem
+
+The web build (`@openconcho/web`) talks to Honcho directly from the browser. When
+the configured instance URL is a different origin than the page (e.g. a self-hosted
+Honcho at `https://honcho.example.net` while the UI runs on
+`http://localhost:8080`), the browser issues a CORS preflight on the `Authorization`
+header and the request fails — Honcho ships no `CORSMiddleware`.
+
+The desktop (Tauri) build is unaffected: it routes fetch through Rust/`reqwest`
+(`packages/web/src/lib/http.ts`), which has no browser same-origin policy.
+
+The repo already had a partial mitigation (a static `^~ /v3/` nginx proxy keyed to a
+single `HONCHO_UPSTREAM`), but it (a) supported only one backend and (b) was bypassed
+the moment a user typed an absolute URL into Settings — which is the bug that surfaced.
+
+### Evidence gathered
+
+- Browser → `honcho.example.net` is reachable; the CORS error proves the
+ request reached Honcho and only the browser policy check failed.
+- A Docker container under Colima **also** reaches the tailnet: `docker run ...
+ curl https://honcho.example.net/health` returned **HTTP 200**, connected over the
+ tailnet on `:443` with TLS verified. So a container-side proxy is viable on this
+ host (Colima forwards container egress through the host's tailnet routing).
+
+## Decisions
+
+1. **Coexist by runtime mode.** Tauri keeps absolute-URL + `reqwest`. The web build
+ (docker **and** `make dev-web`) routes through a same-origin `/api` proxy. One
+ build; behavior chosen at runtime by `isTauri()`.
+2. **Header-driven routing.** The browser names the target upstream per request via
+ an `X-Honcho-Upstream` header (sourced from the active/scoped instance's
+ `baseUrl`). The proxy is a stateless forwarder; the frontend stays the single
+ source of truth for instances. No server-side slug→upstream map.
+3. **SSRF posture: optional allowlist, open by default.** Unset
+ `OPENCONCHO_UPSTREAM_ALLOWLIST` ⇒ forward anywhere (safe for the default
+ `127.0.0.1:8080` binding). Set it (host globs) before exposing the proxy (e.g.
+ behind `cloudflared`) to reject non-matching upstreams.
+4. **Aggregation: preserve, don't extend.** The existing Fleet dashboard
+ (`compareQueries.ts`, `fleetAggregates.ts`, `FleetDashboard`/`FleetRow`) must keep
+ working identically. New cross-instance merge/dedup/search is explicitly a
+ **non-goal** of this PR.
+
+## Architecture
+
+```
+WEB (docker + dev):
+ browser ──same-origin──▶ /api/v3/... (openapi-fetch base = "/api")
+ X-Honcho-Upstream: https://honcho.example.net (from instance.baseUrl)
+ Authorization: Bearer … (unchanged, when set)
+ │ proxy: validate header, allowlist-check, strip "/api",
+ │ proxy_pass $upstream, set SNI/Host, drop routing header
+ ▼
+ https://honcho.example.net/v3/... (server-side hop — no CORS)
+
+TAURI:
+ webview ──reqwest──▶ https://honcho.example.net/v3/... (unchanged)
+```
+
+**Why a custom header is free here:** `X-Honcho-Upstream` rides a *same-origin*
+request (browser → `/api`), so it triggers no CORS preflight. Preflight only fires
+cross-origin — the exact condition this design removes.
+
+**Why the instance store is unchanged:** instances still persist an absolute
+`baseUrl` (`z.string().url()` stays valid). We change only *how a request is
+dispatched*, not what is stored. In web mode the instance URL stops being the fetch
+target and becomes the header value.
+
+## Components
+
+### A. Centralized dispatch helper (new) — `src/lib/dispatch.ts`
+
+Single source of truth for "how to issue a request for an instance," replacing four
+ad-hoc constructions.
+
+```ts
+export const API_PREFIX = "/api";
+export const UPSTREAM_HEADER = "X-Honcho-Upstream";
+
+export interface Dispatch {
+ baseUrl: string; // "/api" (web) | instance.baseUrl (tauri)
+ headers: Record; // Content-Type, Authorization?, X-Honcho-Upstream?
+ fetch: typeof globalThis.fetch; // globalThis.fetch (web) | tauriFetch (tauri)
+}
+
+export function dispatchFor(
+ instance: Pick,
+): Dispatch;
+```
+
+- **Web:** `baseUrl = API_PREFIX`; headers include `UPSTREAM_HEADER =
+ normalizedUpstream(instance.baseUrl)` (trailing slash stripped) and `Authorization`
+ when `token` is non-empty; `fetch = globalThis.fetch`.
+- **Tauri:** `baseUrl = instance.baseUrl`; no upstream header; `fetch = tauriFetch`.
+
+`API_PREFIX` and `UPSTREAM_HEADER` are named constants (WIOCHE) referenced by both
+the frontend and documented for the proxy.
+
+### B. Consumers of the helper (the four dispatch sites)
+
+| Site | File | Change |
+|------|------|--------|
+| Active-instance client | `src/api/client.ts` | build client from `dispatchFor(loadConfig())` |
+| Scoped client (Fleet/compare, seed-kits) | `src/api/scopedClient.ts` | build client from `dispatchFor(instance)` |
+| Connection health check | `src/lib/config.ts` `checkConnection` | fetch `${baseUrl}/v3/workspaces/list` via `dispatchFor` (hits `/api/...` in web) |
+| Discovery name probe | `src/lib/discovery.ts` `suggestNameForInstance` | same, via `dispatchFor` |
+
+`compareQueries.ts` and `fleetAggregates.ts` need **no changes** — they go through
+`createScopedClient`, so the transport swap is invisible to them. `FleetRow.tsx:114`
+uses `instance.baseUrl` only to render a hostname label (cosmetic) — untouched.
+
+### C. nginx proxy — `docker/nginx.conf.template`
+
+Replace the `^~ /v3/` and `= /health` upstream blocks with one header-driven block:
+
+```nginx
+resolver 127.0.0.11 ipv6=off valid=10s;
+
+# Rendered by the entrypoint from OPENCONCHO_UPSTREAM_ALLOWLIST.
+# Unset → default 1 (open). Set → 1 only for matching hosts, else 0.
+# (map block injected here)
+
+location ^~ /api/ {
+ set $upstream $http_x_honcho_upstream;
+ if ($upstream = "") { return 421; } # misdirected: no target named
+ if ($allow_upstream = 0) { return 403; } # allowlist reject
+ rewrite ^/api/(.*)$ /$1 break; # strip /api, keep /v3/...
+ proxy_pass $upstream;
+ proxy_ssl_server_name on; # SNI for HTTPS upstreams
+ proxy_set_header Host $proxy_host; # upstream host, not localhost:8080
+ proxy_set_header X-Honcho-Upstream ""; # never leak routing header upstream
+}
+```
+
+`Authorization` and other client headers pass through by nginx default. The
+container's own `/healthz` liveness endpoint is unchanged.
+
+### D. Vite dev parity — `vite.config.ts`
+
+A `configureServer` middleware mirrors nginx for `make dev-web`: read
+`X-Honcho-Upstream`, forward `/api/*` (prefix stripped) to it, drop the routing
+header, apply the same allowlist if configured. Result: dev behaves identically to
+the docker image (local/CI parity).
+
+### E. Config / env — `docker/40-openconcho-config.sh`, `docker-compose.yml`
+
+- `OPENCONCHO_DEFAULT_HONCHO_URL` keeps seeding the first instance, now an **absolute
+ URL only**. Drop the `same-origin` sentinel (glue for the retired `/v3` proxy) from
+ `src/lib/runtimeConfig.ts`.
+- **Retire `HONCHO_UPSTREAM`** from compose — the upstream now comes from the header.
+- New optional `OPENCONCHO_UPSTREAM_ALLOWLIST` (comma-separated host globs, e.g.
+ `honcho.example.net,*.honcho.dev`). The entrypoint renders it into the
+ nginx `map` for `$allow_upstream`; unset ⇒ map default 1.
+
+## Data flow (Fleet aggregation, web mode)
+
+```
+FleetDashboard → useScoped*(instanceA) → createScopedClient(A) → dispatchFor(A)
+ POST /api/v3/... X-Honcho-Upstream: A ─┐
+ → useScoped*(instanceB) → createScopedClient(B) → dispatchFor(B) ├▶ nginx → A,B
+ POST /api/v3/... X-Honcho-Upstream: B ─┘ (concurrent)
+fleetAggregates.ts merges results (transport-agnostic). B down ⇒ only B's column errors.
+```
+
+Query keys in `compareQueries.ts` are already scoped by `instance.id`, so caches
+never collide across columns. Stateless per-request routing isolates partial
+failures per instance.
+
+## Error handling
+
+The proxy must not let its own refusals masquerade as upstream responses. Both
+proxy-origin refusals carry a sentinel response header **`X-Honcho-Proxy-Reject`**
+(value: `no-upstream` | `allowlist`). `checkConnection` treats any response bearing
+that header as `unreachable` with the reject reason — **regardless of status code** —
+so an allowlist `403` is never mis-mapped to the upstream's auth `403`.
+
+- **No `X-Honcho-Upstream`** (misconfigured web request) → proxy `421` +
+ `X-Honcho-Proxy-Reject: no-upstream`. Fail loud, not silent.
+- **Allowlist reject** → proxy `403` + `X-Honcho-Proxy-Reject: allowlist`. The
+ sentinel header is what disambiguates it from the upstream's own `401/403` (which
+ arrive without the header and still map to `auth-required`).
+- **Upstream unreachable / TLS failure** → nginx `502`; UI shows "Cannot reach
+ server." This is the symptom if a future host's container is *not* on the tailnet —
+ a network problem, not CORS (documented caveat: proxy portability depends on the
+ container host being able to route to the upstream).
+
+## Testing (TDD)
+
+Unit (mock only `isTauri` + the fetch boundary, per project test rules):
+
+1. `dispatchFor` (web): `baseUrl === "/api"`, sets `X-Honcho-Upstream` to the
+ normalized instance URL, sets `Authorization` only when token non-empty, uses
+ `globalThis.fetch`.
+2. `dispatchFor` (tauri): `baseUrl === instance.baseUrl`, no upstream header, uses
+ `tauriFetch`.
+3. `checkConnection` (web): issues to `/api/v3/workspaces/list` with the upstream
+ header; maps `ok` / `401|403` / other correctly.
+4. **Aggregation regression:** two scoped clients for distinct instances emit two
+ distinct `X-Honcho-Upstream` values; existing `src/test/fleet.test.tsx` stays
+ green.
+
+Integration (nginx): compose up against a stub upstream — assert (a) header →
+forward with `/api` stripped, (b) missing header → 421, (c) allowlist miss → 403,
+(d) `X-Honcho-Upstream` absent on the upstream-side request.
+
+## Non-goals (deferred to separate PRs)
+
+- New cross-instance aggregation intelligence (unified merged lists, dedup, conflict
+ resolution, cross-instance search).
+- Running Tailscale inside the Colima VM / any host-networking change (not needed —
+ reachability confirmed on the target host).
+- Adding `CORSMiddleware` to Honcho (the proxy keeps openconcho self-contained and
+ backend-agnostic; this is the deliberate alternative *not* taken).
+
+## Files touched
+
+- `packages/web/src/lib/dispatch.ts` (new)
+- `packages/web/src/api/client.ts`
+- `packages/web/src/api/scopedClient.ts`
+- `packages/web/src/lib/config.ts`
+- `packages/web/src/lib/discovery.ts`
+- `packages/web/src/lib/runtimeConfig.ts`
+- `packages/web/vite.config.ts`
+- `docker/nginx.conf.template`
+- `docker/40-openconcho-config.sh`
+- `docker-compose.yml`
+- tests: `packages/web/src/test/` (new dispatch + checkConnection cases; keep
+ `fleet.test.tsx`, `settings-form.test.tsx` green)
+- docs: `AGENTS.md`, `README` (env vars + the proxy contract)
+
+## Constraints honored
+
+- Web-only change → PR CI (web checks) covers it; no desktop `cargo-check` needed
+ (Tauri path unchanged).
+- No hardcoded URLs (upstream comes from instance config / runtime env).
+- One concern per PR (proxy only); conventional commits; push under `offendingcommit`.
diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts
index 7652996..514b426 100644
--- a/packages/web/src/api/client.ts
+++ b/packages/web/src/api/client.ts
@@ -1,21 +1,12 @@
import createClient from "openapi-fetch";
import { loadConfig } from "@/lib/config";
-import { httpFetch } from "@/lib/http";
+import { dispatchFor } from "@/lib/dispatch";
import type { paths } from "./schema.d.ts";
export function createHonchoClient() {
- const config = loadConfig();
- const baseUrl = config?.baseUrl ?? "http://localhost:8000";
- const token = config?.token ?? "";
-
- const headers: Record = {
- "Content-Type": "application/json",
- };
- if (token) {
- headers.Authorization = `Bearer ${token}`;
- }
-
- return createClient({ baseUrl, headers, fetch: httpFetch });
+ const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
+ const { baseUrl, headers, fetch } = dispatchFor(config);
+ return createClient({ baseUrl, headers, fetch });
}
export const client = {
diff --git a/packages/web/src/api/scopedClient.ts b/packages/web/src/api/scopedClient.ts
index c37a9af..44c8764 100644
--- a/packages/web/src/api/scopedClient.ts
+++ b/packages/web/src/api/scopedClient.ts
@@ -1,18 +1,16 @@
import createClient from "openapi-fetch";
import type { Instance } from "@/lib/config";
-import { httpFetch } from "@/lib/http";
+import { dispatchFor } from "@/lib/dispatch";
import type { paths } from "./schema.d.ts";
export type ScopedClient = ReturnType>;
/**
- * Create an openapi-fetch client bound to a specific instance. Use for views
- * that need to query non-active instances (e.g. side-by-side comparison).
- * For single-instance access, prefer `client.current` which tracks the active
- * instance via localStorage.
+ * Create an openapi-fetch client bound to a specific instance. Use for views that
+ * query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
+ * client self-routes via its own X-Honcho-Upstream header in web mode.
*/
export function createScopedClient(instance: Instance): ScopedClient {
- const headers: Record = { "Content-Type": "application/json" };
- if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
- return createClient({ baseUrl: instance.baseUrl, headers, fetch: httpFetch });
+ const { baseUrl, headers, fetch } = dispatchFor(instance);
+ return createClient({ baseUrl, headers, fetch });
}
diff --git a/packages/web/src/lib/config.ts b/packages/web/src/lib/config.ts
index 6dda1dd..faa9b10 100644
--- a/packages/web/src/lib/config.ts
+++ b/packages/web/src/lib/config.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { httpFetch } from "@/lib/http";
+import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
const LEGACY_KEY = "openconcho:config";
@@ -7,6 +7,13 @@ const STORE_KEY = "openconcho:instances";
export const HONCHO_CLOUD_URL = "https://api.honcho.dev";
+/**
+ * Connection-test timeout. Generous because a cold/idle self-hosted Honcho (DB
+ * pool spin-up, tunnel wake) can take several seconds on its first request — a
+ * tight 5s budget reported live-and-reachable instances as "Connection timed out".
+ */
+export const CONNECTION_TIMEOUT_MS = 15_000;
+
function normalizeBaseUrl(url: string): string {
return url.trim().replace(/\/+$/, "").toLowerCase();
}
@@ -16,7 +23,7 @@ export function isCloudInstance(instance: Pick): boolean {
}
export const configSchema = z.object({
- baseUrl: z.string().url({ message: "Must be a valid URL" }),
+ baseUrl: z.url({ message: "Must be a valid URL" }),
token: z.string().optional().default(""),
});
@@ -25,7 +32,7 @@ export type Config = z.infer;
export const instanceSchema = z.object({
id: z.string().min(1),
name: z.string().min(1, { message: "Name is required" }),
- baseUrl: z.string().url({ message: "Must be a valid URL" }),
+ baseUrl: z.url({ message: "Must be a valid URL" }),
token: z.string().optional().default(""),
});
@@ -162,21 +169,21 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
export async function checkConnection(
baseUrl: string,
token?: string,
-): Promise<{
- status: HealthStatus;
- message: string;
-}> {
+ timeoutMs: number = CONNECTION_TIMEOUT_MS,
+): Promise<{ status: HealthStatus; message: string }> {
try {
- const headers: Record = { "Content-Type": "application/json" };
- if (token) headers.Authorization = `Bearer ${token}`;
-
- const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
+ const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
+ const res = await fetch(`${base}/v3/workspaces/list`, {
method: "POST",
headers,
body: JSON.stringify({}),
- signal: AbortSignal.timeout(5000),
+ signal: AbortSignal.timeout(timeoutMs),
});
+ const reject = res.headers.get(PROXY_REJECT_HEADER);
+ if (reject) {
+ return { status: "unreachable", message: `Proxy refused upstream (${reject})` };
+ }
if (res.ok) return { status: "ok", message: "Connected successfully" };
if (res.status === 401 || res.status === 403) {
return { status: "auth-required", message: "Authentication required — provide an API token" };
diff --git a/packages/web/src/lib/discovery.ts b/packages/web/src/lib/discovery.ts
index 83bc365..6784f80 100644
--- a/packages/web/src/lib/discovery.ts
+++ b/packages/web/src/lib/discovery.ts
@@ -1,14 +1,13 @@
-import { httpFetch } from "@/lib/http";
+import { dispatchFor } from "@/lib/dispatch";
+import { isTauri } from "@/lib/platform";
+
+export { isTauri } from "@/lib/platform";
export interface DiscoveredInstance {
port: number;
base_url: string;
}
-export function isTauri(): boolean {
- return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
-}
-
/**
* Probe localhost ports for running Honcho instances. Desktop-only — the web
* build can't port-scan due to CORS, so this returns an empty list when not
@@ -38,9 +37,10 @@ export function deriveNameFromWorkspaceId(workspaceId: string): string {
*/
export async function suggestNameForInstance(baseUrl: string): Promise {
try {
- const res = await httpFetch(`${baseUrl}/v3/workspaces/list?page=1&page_size=1`, {
+ const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl });
+ const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers,
body: JSON.stringify({}),
signal: AbortSignal.timeout(2000),
});
diff --git a/packages/web/src/lib/dispatch.ts b/packages/web/src/lib/dispatch.ts
new file mode 100644
index 0000000..3701e18
--- /dev/null
+++ b/packages/web/src/lib/dispatch.ts
@@ -0,0 +1,47 @@
+import { httpFetch } from "@/lib/http";
+import { isTauri } from "@/lib/platform";
+
+/** Same-origin path prefix the web build issues all Honcho calls through. */
+export const API_PREFIX = "/api";
+/** Request header naming the real Honcho upstream for the proxy to forward to. */
+export const UPSTREAM_HEADER = "X-Honcho-Upstream";
+/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */
+export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject";
+
+export interface Dispatch {
+ baseUrl: string;
+ headers: Record;
+ fetch: typeof globalThis.fetch;
+}
+
+function normalizeUpstream(url: string): string {
+ return url.trim().replace(/\/+$/, "");
+}
+
+/**
+ * Absolute same-origin base for the web proxy. Absolute (origin + `/api`), not the
+ * bare relative `/api`, so openapi-fetch and node/undici can construct a Request
+ * without an ambient document base — and so it resolves identically in the browser,
+ * behind a tunnel, and under jsdom.
+ */
+function webApiBase(): string {
+ const origin = typeof location !== "undefined" ? location.origin : "";
+ return `${origin}${API_PREFIX}`;
+}
+
+/**
+ * Resolve how to issue a request for an instance.
+ * - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
+ * - Tauri: the absolute instance URL via reqwest (no browser same-origin policy).
+ */
+export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
+
+ if (isTauri()) {
+ return { baseUrl: instance.baseUrl, headers, fetch: httpFetch };
+ }
+
+ headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
+ return { baseUrl: webApiBase(), headers, fetch: httpFetch };
+}
diff --git a/packages/web/src/lib/http.ts b/packages/web/src/lib/http.ts
index 96c325a..41ba6b8 100644
--- a/packages/web/src/lib/http.ts
+++ b/packages/web/src/lib/http.ts
@@ -1,12 +1,8 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
+import { isTauri } from "@/lib/platform";
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
-// Falls back to native browser fetch during plain web dev (`pnpm dev:web`).
-const isTauri = Boolean(
- typeof window !== "undefined" &&
- (window as unknown as Record).__TAURI_INTERNALS__,
-);
-
-export const httpFetch: typeof globalThis.fetch = isTauri
+// Falls back to native browser fetch during plain web dev.
+export const httpFetch: typeof globalThis.fetch = isTauri()
? (tauriFetch as typeof globalThis.fetch)
: globalThis.fetch;
diff --git a/packages/web/src/lib/platform.ts b/packages/web/src/lib/platform.ts
new file mode 100644
index 0000000..6755142
--- /dev/null
+++ b/packages/web/src/lib/platform.ts
@@ -0,0 +1,4 @@
+/** True when running inside the Tauri desktop shell (WebView with injected internals). */
+export function isTauri(): boolean {
+ return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
+}
diff --git a/packages/web/src/lib/runtimeConfig.ts b/packages/web/src/lib/runtimeConfig.ts
index 235822b..c2990dd 100644
--- a/packages/web/src/lib/runtimeConfig.ts
+++ b/packages/web/src/lib/runtimeConfig.ts
@@ -1,24 +1,17 @@
const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
-const SAME_ORIGIN = "same-origin";
/**
* Runtime-injected default Honcho base URL for container deployments.
*
- * The Docker image writes `/config.js` from the `OPENCONCHO_DEFAULT_HONCHO_URL`
- * env at container start, so one prebuilt image can target any backend without
- * a rebuild (Vite envs are baked at build time and can't do this).
+ * The Docker image writes `/config.js` from `OPENCONCHO_DEFAULT_HONCHO_URL` at
+ * container start, so one prebuilt image can target any backend without a rebuild.
+ * The web build proxies this URL via the same-origin `/api` reverse proxy (no CORS).
*
- * - `"same-origin"` → the page's own origin (pairs with the nginx `/v3` reverse
- * proxy, so the browser makes same-origin requests and CORS never applies)
- * - an absolute URL → that URL
- * - empty / unset → `null` (no default; the user configures in Settings)
+ * - an absolute URL → that URL (seeds the first instance)
+ * - empty / unset → null (no default; the user configures in Settings)
*/
export function runtimeDefaultBaseUrl(): string | null {
const raw = (globalThis as Record)[GLOBAL_KEY];
if (typeof raw !== "string" || raw.trim() === "") return null;
- const value = raw.trim();
- if (value === SAME_ORIGIN) {
- return typeof location !== "undefined" ? location.origin : null;
- }
- return value;
+ return raw.trim();
}
diff --git a/packages/web/src/test/check-connection.test.ts b/packages/web/src/test/check-connection.test.ts
new file mode 100644
index 0000000..f12f688
--- /dev/null
+++ b/packages/web/src/test/check-connection.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+// Mock the platform predicate (web mode) and the fetch boundary. We mock
+// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch
+// reference at module load, so vi.stubGlobal would not be observed by dispatchFor.
+const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({
+ mockIsTauri: vi.fn(() => false),
+ httpFetchMock: vi.fn(),
+}));
+vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
+vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock }));
+
+import { checkConnection } from "@/lib/config";
+
+afterEach(() => {
+ httpFetchMock.mockReset();
+ mockIsTauri.mockReturnValue(false);
+});
+
+describe("checkConnection — web proxy mode", () => {
+ it("calls the absolute same-origin /api path with the upstream header", async () => {
+ httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
+ const res = await checkConnection("https://honcho.example.net", "sk-1");
+ expect(res.status).toBe("ok");
+ const [url, init] = httpFetchMock.mock.calls[0];
+ expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`);
+ expect((init.headers as Record)["X-Honcho-Upstream"]).toBe(
+ "https://honcho.example.net",
+ );
+ expect((init.headers as Record).Authorization).toBe("Bearer sk-1");
+ });
+
+ it("maps an upstream 401 to auth-required", async () => {
+ httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 }));
+ const res = await checkConnection("https://honcho.example.net");
+ expect(res.status).toBe("auth-required");
+ });
+
+ it("treats a proxy reject as unreachable, not auth-required", async () => {
+ httpFetchMock.mockResolvedValue(
+ new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
+ );
+ const res = await checkConnection("https://blocked.example.net");
+ expect(res.status).toBe("unreachable");
+ expect(res.message).toMatch(/allowlist/i);
+ });
+});
+
+describe("checkConnection — timeout budget", () => {
+ // A fetch that resolves after `ms`, but rejects early if the abort signal fires —
+ // mirrors how a real slow upstream interacts with AbortSignal.timeout.
+ function delayedFetch(ms: number) {
+ return (_url: string, init: { signal?: AbortSignal }) =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(() => resolve(new Response("{}", { status: 200 })), ms);
+ init.signal?.addEventListener("abort", () => {
+ clearTimeout(timer);
+ reject(new DOMException("The operation timed out", "TimeoutError"));
+ });
+ });
+ }
+
+ it("reports unreachable when the upstream is slower than the timeout budget", async () => {
+ httpFetchMock.mockImplementation(delayedFetch(80));
+ const res = await checkConnection("https://slow.example.net", undefined, 20);
+ expect(res.status).toBe("unreachable");
+ });
+
+ it("succeeds when a slow upstream responds within the (cold-start) budget", async () => {
+ httpFetchMock.mockImplementation(delayedFetch(20));
+ const res = await checkConnection("https://slow.example.net", undefined, 200);
+ expect(res.status).toBe("ok");
+ });
+});
diff --git a/packages/web/src/test/dispatch.test.ts b/packages/web/src/test/dispatch.test.ts
new file mode 100644
index 0000000..1fbfdc7
--- /dev/null
+++ b/packages/web/src/test/dispatch.test.ts
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const { mockIsTauri } = vi.hoisted(() => ({ mockIsTauri: vi.fn() }));
+vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
+
+import { API_PREFIX, dispatchFor, PROXY_REJECT_HEADER, UPSTREAM_HEADER } from "@/lib/dispatch";
+
+afterEach(() => mockIsTauri.mockReset());
+
+describe("dispatchFor — web mode", () => {
+ it("targets the absolute same-origin /api base and carries the upstream header", () => {
+ mockIsTauri.mockReturnValue(false);
+ const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
+ expect(d.baseUrl).toBe(`${location.origin}${API_PREFIX}`);
+ expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
+ expect(d.headers.Authorization).toBeUndefined();
+ });
+
+ it("adds Authorization only when a token is present", () => {
+ mockIsTauri.mockReturnValue(false);
+ const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
+ expect(d.headers.Authorization).toBe("Bearer sk-1");
+ });
+});
+
+describe("dispatchFor — tauri mode", () => {
+ it("targets the absolute URL with no upstream header", () => {
+ mockIsTauri.mockReturnValue(true);
+ const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
+ expect(d.baseUrl).toBe("https://honcho.example.net");
+ expect(d.headers[UPSTREAM_HEADER]).toBeUndefined();
+ expect(d.headers.Authorization).toBe("Bearer sk-1");
+ });
+});
+
+describe("proxy reject header constant", () => {
+ it("is the agreed sentinel name", () => {
+ expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject");
+ });
+});
diff --git a/packages/web/src/test/fleet.test.tsx b/packages/web/src/test/fleet.test.tsx
index 56c50e9..7ed13ac 100644
--- a/packages/web/src/test/fleet.test.tsx
+++ b/packages/web/src/test/fleet.test.tsx
@@ -103,23 +103,25 @@ describe("scoped option builders", () => {
(httpFetch as ReturnType).mockClear();
});
- it("scopes queue status requests by instance baseUrl and token", async () => {
+ it("scopes queue status requests via the same-origin proxy, upstream header, and token", async () => {
const { httpFetch } = await import("@/lib/http");
const opts = scopedQueueStatusOptions(neo, "ws-1");
await opts.queryFn();
const req = (httpFetch as ReturnType).mock.calls[0][0] as Request;
- expect(req.url).toBe("http://localhost:8001/v3/workspaces/ws-1/queue/status");
+ expect(req.url).toBe(`${location.origin}/api/v3/workspaces/ws-1/queue/status`);
+ expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8001");
expect(req.headers.get("Authorization")).toBe("Bearer neo-token");
});
- it("scopes conclusions-count requests by instance baseUrl and token", async () => {
+ it("scopes conclusions-count requests via the same-origin proxy, upstream header, and token", async () => {
const { httpFetch } = await import("@/lib/http");
const opts = scopedConclusionsCountOptions(iris, "ws-9");
await opts.queryFn();
const req = (httpFetch as ReturnType).mock.calls[0][0] as Request;
- expect(req.url.startsWith("http://localhost:8002/v3/workspaces/ws-9/conclusions/list")).toBe(
+ expect(req.url.startsWith(`${location.origin}/api/v3/workspaces/ws-9/conclusions/list`)).toBe(
true,
);
+ expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8002");
expect(req.headers.get("Authorization")).toBe("Bearer iris-token");
});
diff --git a/packages/web/src/test/instances.test.ts b/packages/web/src/test/instances.test.ts
new file mode 100644
index 0000000..9042e45
--- /dev/null
+++ b/packages/web/src/test/instances.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ addInstance,
+ deleteInstance,
+ getActiveInstance,
+ loadConfig,
+ loadStore,
+ setActiveInstance,
+ updateInstance,
+} from "@/lib/config";
+
+const STORE_KEY = "openconcho:instances";
+const LEGACY_KEY = "openconcho:config";
+
+beforeEach(() => localStorage.clear());
+afterEach(() => localStorage.clear());
+
+describe("instance store — add + active selection", () => {
+ it("makes the first added instance active", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ expect(loadStore().activeId).toBe(a.id);
+ });
+
+ it("does not steal active focus when adding more instances", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
+ expect(loadStore().activeId).toBe(a.id);
+ });
+
+ it("appends instances in insertion order", () => {
+ addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
+ expect(loadStore().instances.map((i) => i.name)).toEqual(["A", "B"]);
+ });
+});
+
+describe("instance store — switching active", () => {
+ it("switches the active instance", () => {
+ addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
+ setActiveInstance(b.id);
+ expect(getActiveInstance()?.id).toBe(b.id);
+ });
+
+ it("ignores an unknown id", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ setActiveInstance("does-not-exist");
+ expect(getActiveInstance()?.id).toBe(a.id);
+ });
+});
+
+describe("instance store — deletion", () => {
+ it("falls back to the first remaining when the active instance is deleted", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
+ setActiveInstance(b.id);
+ deleteInstance(b.id);
+ expect(loadStore().activeId).toBe(a.id);
+ });
+
+ it("leaves the active id unchanged when a non-active instance is deleted", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
+ deleteInstance(b.id);
+ expect(getActiveInstance()?.id).toBe(a.id);
+ });
+
+ it("clears the active id when the last instance is removed", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ deleteInstance(a.id);
+ expect(loadStore().activeId).toBeNull();
+ });
+
+ it("returns null config once every instance is gone", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ deleteInstance(a.id);
+ expect(loadConfig()).toBeNull();
+ });
+});
+
+describe("instance store — update", () => {
+ it("patches the named fields", () => {
+ const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ updateInstance(a.id, { name: "Renamed", token: "sk-1" });
+ expect(loadStore().instances[0]).toMatchObject({ name: "Renamed", token: "sk-1" });
+ });
+
+ it("no-ops on an unknown id", () => {
+ addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
+ updateInstance("nope", { name: "X" });
+ expect(loadStore().instances[0].name).toBe("A");
+ });
+});
+
+describe("instance store — active config", () => {
+ it("reflects the active instance's url and token", () => {
+ addInstance({ name: "A", baseUrl: "https://a.example.net", token: "sk-a" });
+ expect(loadConfig()).toEqual({ baseUrl: "https://a.example.net", token: "sk-a" });
+ });
+});
+
+describe("instance store — legacy migration", () => {
+ it("migrates the legacy single-config key into the instances store", () => {
+ localStorage.setItem(
+ LEGACY_KEY,
+ JSON.stringify({ baseUrl: "https://legacy.example.net", token: "sk-legacy" }),
+ );
+ const store = loadStore();
+ expect(store.instances[0]).toMatchObject({
+ name: "Default",
+ baseUrl: "https://legacy.example.net",
+ token: "sk-legacy",
+ });
+ });
+
+ it("removes the legacy key after migrating", () => {
+ localStorage.setItem(
+ LEGACY_KEY,
+ JSON.stringify({ baseUrl: "https://legacy.example.net", token: "" }),
+ );
+ loadStore();
+ expect(localStorage.getItem(LEGACY_KEY)).toBeNull();
+ expect(localStorage.getItem(STORE_KEY)).toBeTruthy();
+ });
+});
diff --git a/packages/web/src/test/platform.test.ts b/packages/web/src/test/platform.test.ts
new file mode 100644
index 0000000..5be6805
--- /dev/null
+++ b/packages/web/src/test/platform.test.ts
@@ -0,0 +1,17 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { isTauri } from "@/lib/platform";
+
+describe("isTauri", () => {
+ afterEach(() => {
+ delete (window as unknown as Record).__TAURI_INTERNALS__;
+ });
+
+ it("returns false in a plain browser/jsdom environment", () => {
+ expect(isTauri()).toBe(false);
+ });
+
+ it("returns true when the Tauri internals global is present", () => {
+ (window as unknown as Record).__TAURI_INTERNALS__ = {};
+ expect(isTauri()).toBe(true);
+ });
+});
diff --git a/packages/web/src/test/runtime-config.test.ts b/packages/web/src/test/runtime-config.test.ts
index a13e936..695ac8a 100644
--- a/packages/web/src/test/runtime-config.test.ts
+++ b/packages/web/src/test/runtime-config.test.ts
@@ -8,22 +8,14 @@ afterEach(() => {
});
describe("runtimeDefaultBaseUrl", () => {
- it("returns null when the global is unset", () => {
- expect(runtimeDefaultBaseUrl()).toBeNull();
- });
-
- it("returns null when the global is blank", () => {
- (globalThis as Record)[KEY] = " ";
- expect(runtimeDefaultBaseUrl()).toBeNull();
- });
-
- it("returns an absolute URL verbatim", () => {
+ it("returns an injected absolute URL verbatim", () => {
(globalThis as Record)[KEY] = "https://honcho.example.net";
expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
});
- it("resolves 'same-origin' to the page origin", () => {
- (globalThis as Record)[KEY] = "same-origin";
- expect(runtimeDefaultBaseUrl()).toBe(location.origin);
+ it("returns null when unset or empty", () => {
+ expect(runtimeDefaultBaseUrl()).toBeNull();
+ (globalThis as Record)[KEY] = " ";
+ expect(runtimeDefaultBaseUrl()).toBeNull();
});
});
diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts
index 1267586..4622347 100644
--- a/packages/web/vite.config.ts
+++ b/packages/web/vite.config.ts
@@ -1,10 +1,10 @@
import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
-import path from "path";
-import { fileURLToPath } from "url";
-import { defineConfig } from "vite";
+import { defineConfig, type Plugin } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const host = process.env.TAURI_DEV_HOST;
@@ -12,9 +12,80 @@ const { version } = JSON.parse(
readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8"),
) as { version: string };
+// Dev-mode mirror of the nginx /api reverse proxy: read X-Honcho-Upstream and
+// forward /api/* there, so `make dev-web` behaves identically to the docker image
+// (same-origin requests, no browser CORS). Connect strips the /api mount prefix,
+// so req.url is already the upstream path (e.g. /v3/workspaces/list).
+function honchoApiProxy(): Plugin {
+ const HEADER = "x-honcho-upstream";
+ // Mirror nginx's allowlist (spec §D): unset/empty => open; otherwise only
+ // matching upstream hosts forward. Glob `*` -> any non-slash run, like nginx.
+ const raw = process.env.OPENCONCHO_UPSTREAM_ALLOWLIST?.trim();
+ const allowlist: RegExp[] | null = raw
+ ? raw
+ .split(",")
+ .map((h) => h.trim())
+ .filter(Boolean)
+ .map((host) => {
+ const esc = host.replace(/[.]/g, "\\.").replace(/[*]/g, "[^/]*");
+ return new RegExp(`^https?://${esc}(:[0-9]+)?(/.*)?$`);
+ })
+ : null;
+ return {
+ name: "honcho-api-proxy",
+ configureServer(server) {
+ server.middlewares.use("/api", async (req, res) => {
+ const upstream = req.headers[HEADER];
+ if (typeof upstream !== "string" || upstream.trim() === "") {
+ res.statusCode = 421;
+ res.setHeader("X-Honcho-Proxy-Reject", "no-upstream");
+ res.end();
+ return;
+ }
+ if (allowlist && !allowlist.some((re) => re.test(upstream))) {
+ res.statusCode = 403;
+ res.setHeader("X-Honcho-Proxy-Reject", "allowlist");
+ res.end();
+ return;
+ }
+ const target = upstream.replace(/\/+$/, "") + (req.url ?? "");
+ const chunks: Buffer[] = [];
+ for await (const c of req) chunks.push(c as Buffer);
+ try {
+ const upstreamRes = await fetch(target, {
+ method: req.method,
+ headers: {
+ "content-type": req.headers["content-type"] ?? "application/json",
+ ...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
+ },
+ body: ["GET", "HEAD"].includes(req.method ?? "") ? undefined : Buffer.concat(chunks),
+ });
+ res.statusCode = upstreamRes.status;
+ // undici's fetch auto-decompresses the body, so the original
+ // content-encoding/length no longer describe what we re-send —
+ // drop those (and hop-by-hop headers) to avoid ERR_CONTENT_DECODING_FAILED.
+ const SKIP = new Set([
+ "content-encoding",
+ "content-length",
+ "transfer-encoding",
+ "connection",
+ ]);
+ upstreamRes.headers.forEach((v, k) => {
+ if (!SKIP.has(k.toLowerCase())) res.setHeader(k, v);
+ });
+ res.end(Buffer.from(await upstreamRes.arrayBuffer()));
+ } catch (e) {
+ res.statusCode = 502;
+ res.end(`proxy error: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ });
+ },
+ };
+}
+
export default defineConfig({
clearScreen: false,
- plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), tailwindcss()],
+ plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), honchoApiProxy(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(version),
},