fix(server): CORS, without which the phone could never reach the Pi

The Android build always talks to the Pi cross-origin -- a Capacitor
webview serves the app from http://localhost, not from your domain -- so
the browser sends a preflight OPTIONS first. It is not permitted to attach
an Authorization header to that. Auth ran before anything else, so the
preflight came back 401 and the real request was never attempted.

There were no Access-Control-Allow-* headers either, so even a successful
preflight would not have helped. Verified from an actual page before the
fix: GET /api/sync and POST /api/tutor both "Failed to fetch" -- an opaque
network error that points at the network rather than at middleware order.

CORS now runs first and answers OPTIONS itself. Any origin is allowed by
default, which is not a hole: the gate is a bearer token rather than a
cookie, so a hostile page gains nothing from being allowed to send a
request it cannot authenticate. HANKAN_ALLOWED_ORIGINS narrows it.

backends/echo.ts is a keyless backend that reflects the request back in
chunks. Deploying involves a container, a reverse proxy, a token, CORS and
an SSE stream that has to survive compression -- five things that break
independently, none of which involve Anthropic. HANKAN_TUTOR_BACKEND=echo
proves all five from the phone before a key exists and before anything is
billed. CI now runs the server that way, so the tutor endpoint is
exercised over real HTTP rather than only against an injected mock.

test/server/http.test.ts covers the preflight, the allow-origin header on
real responses, Vary: Origin, that a bad token is still refused, and that
the SSE stream parses and terminates with a done event.

Verified end to end in a browser: the Pi configured through the settings
panel, sync pushing 2 rows and a second sync moving 0 (the pushedAt
watermark holding), the header switching from "local stand-in" to
"connected", and a turn streaming back over SSE with the 8,859-character
system prompt intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 21:47:18 +02:00
parent 821622d148
commit b48a5f8fb1
6 changed files with 222 additions and 1 deletions

View File

@@ -16,6 +16,21 @@ GET /health → no auth, for the healthcheck
Everything under `/api` requires `Authorization: Bearer $HANKAN_TOKEN`.
### CORS — required for the phone
The Android build talks to the Pi **cross-origin**: a Capacitor webview
serves the app from its own origin (`http://localhost`), not from your
domain. So the browser sends a preflight `OPTIONS` first, with no
`Authorization` header — it is not permitted to attach one. Auth therefore
has to run *after* CORS, or the preflight is answered 401 and the real
request is never made. It surfaces as an opaque "Failed to fetch", which
sends you looking at the network rather than at the middleware order.
Any origin is allowed by default. That is not a hole: the gate is a bearer
token rather than a cookie, so a hostile page gains nothing from being
allowed to send a request it cannot authenticate. Set
`HANKAN_ALLOWED_ORIGINS` to a comma-separated list to narrow it.
## Setting it up on the Pi
### 1. A database in the Postgres you already run
@@ -138,7 +153,11 @@ turns — so every turn after the first reads the prefix at a fraction of the
input price. This is the single biggest cost lever in the design.
`backends/` holds the seam. `anthropic.ts` is the Claude API and is the
default. `agent-sdk.ts` documents the subscription-billed path PORT.md
default. `echo.ts` needs no API key and reflects the request back, chunk by
chunk — set `HANKAN_TUTOR_BACKEND=echo` to prove a deployment (container,
proxy, token, CORS, SSE through Caddy) from the phone before a key is
involved and before anything is billed. Five things that can each break on
their own, none of which involve Anthropic. `agent-sdk.ts` documents the subscription-billed path PORT.md
originally specified and why it is not implemented — chiefly that its prompt
accepts only user-role messages, so the transcript would have to be flattened
into one turn.