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), },