From b48a5f8fb1b2b5f1bd95c1329e16a725af0acc15 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 21:47:18 +0200 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 3 ++ server/README.md | 21 +++++++- server/src/backends/echo.ts | 43 ++++++++++++++++ server/src/main.ts | 53 +++++++++++++++++++ server/src/tutor.ts | 3 ++ test/server/http.test.ts | 100 ++++++++++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 server/src/backends/echo.ts create mode 100644 test/server/http.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c26a43..8ae3972 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: DATABASE_URL: postgres://postgres:test@localhost:55432/hankan HANKAN_TOKEN: test-token HANKAN_TEST_MODE: "1" + # Keyless, so the tutor endpoint is exercised over real HTTP — + # SSE framing, CORS and auth — without an Anthropic key in CI. + HANKAN_TUTOR_BACKEND: echo PORT: "8788" run: | node --experimental-strip-types server/src/main.ts & diff --git a/server/README.md b/server/README.md index 6e74f2f..09599e4 100644 --- a/server/README.md +++ b/server/README.md @@ -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. diff --git a/server/src/backends/echo.ts b/server/src/backends/echo.ts new file mode 100644 index 0000000..9baa187 --- /dev/null +++ b/server/src/backends/echo.ts @@ -0,0 +1,43 @@ +/* A backend that needs no API key. + + The point is to prove the wiring, not the model. Deploying to the Pi + involves a container, a reverse proxy, a bearer token, CORS and an SSE + stream that must survive compression and buffering — five things that + can each break on their own, none of which have anything to do with + Anthropic. Standing the server up with HANKAN_TUTOR_BACKEND=echo lets + you confirm all five from the phone before an API key is involved and + before a single token is billed. + + It streams in small chunks with a pause between them, because a backend + that answered in one write would not exercise the framing or the + heartbeat at all. */ + +import type { TutorBackend, TutorEvent, TutorRequest } from "./types.ts"; + +const CHUNK = 24; +const GAP_MS = 40; + +export function echoBackend(): TutorBackend { + return { + name: "echo", + async *stream(req: TutorRequest): AsyncIterable { + const reply = [ + "(echo backend — no model was called.)", + "", + `system prompt: ${req.system.length} characters`, + `history: ${req.history.length} turns`, + `you said: ${req.message}`, + ].join("\n"); + + for (let i = 0; i < reply.length; i += CHUNK) { + if (req.signal.aborted) { + yield { type: "done", stopReason: "cancelled" }; + return; + } + yield { type: "delta", text: reply.slice(i, i + CHUNK) }; + await new Promise((r) => setTimeout(r, GAP_MS)); + } + yield { type: "done", stopReason: "end_turn" }; + }, + }; +} diff --git a/server/src/main.ts b/server/src/main.ts index 5117846..3b61657 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -18,6 +18,24 @@ const DATABASE_URL = process.env.DATABASE_URL ?? ""; // migration. const USER_ID = process.env.HANKAN_USER ?? "default"; +/* Which origins may call this from a browser. + + The Android build is the reason this exists at all: a Capacitor webview + runs from its own origin (http://localhost, or capacitor://localhost on + iOS), so every request it makes to the Pi is cross-origin. Without CORS + the phone cannot sync or reach the tutor — it fails at the preflight, + before any of this code runs. + + Default "*" is deliberate and not a hole. The gate here is a bearer + token, not a cookie, so a hostile page gains nothing by being allowed to + *send* a request it cannot authenticate; CORS never protected a + token-authenticated API. Set HANKAN_ALLOWED_ORIGINS to a comma-separated + list to narrow it anyway. */ +const ALLOWED_ORIGINS = (process.env.HANKAN_ALLOWED_ORIGINS ?? "*") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + if (!TOKEN) { console.error("HANKAN_TOKEN is required — refusing to start an unauthenticated sync endpoint."); process.exit(1); @@ -31,6 +49,41 @@ const store = await openStore(DATABASE_URL, pkFor); const app = new Hono(); +/* CORS first, and above all BEFORE the auth middleware. + + A browser sends the preflight OPTIONS with no Authorization header — it + is not allowed to — so putting auth first rejects it 401 and the real + request is never attempted. That is the whole failure, and it looks like + a network error rather than an auth error, which sends you hunting in + the wrong place. */ +app.use("*", async (c, next) => { + const origin = c.req.header("origin"); + const allow = !origin + ? null + : ALLOWED_ORIGINS.includes("*") + ? origin + : ALLOWED_ORIGINS.includes(origin) + ? origin + : null; + + if (c.req.method === "OPTIONS") { + if (!allow) return c.body(null, 403); + return c.body(null, 204, { + "Access-Control-Allow-Origin": allow, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "authorization, content-type", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + }); + } + + await next(); + if (allow) { + c.res.headers.set("Access-Control-Allow-Origin", allow); + c.res.headers.set("Vary", "Origin"); + } +}); + /* Bearer auth on everything except the health check, which has to be reachable by a container healthcheck that holds no secret. */ app.use("/api/*", async (c, next) => { diff --git a/server/src/tutor.ts b/server/src/tutor.ts index f0b7240..083d57c 100644 --- a/server/src/tutor.ts +++ b/server/src/tutor.ts @@ -15,6 +15,7 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { anthropicBackend } from "./backends/anthropic.ts"; +import { echoBackend } from "./backends/echo.ts"; import type { TutorBackend, TutorTurn } from "./backends/types.ts"; const HEARTBEAT_MS = 15_000; @@ -25,6 +26,8 @@ const MAX_HISTORY = 40; function pickBackend(): TutorBackend { const name = process.env.HANKAN_TUTOR_BACKEND ?? "anthropic"; if (name === "anthropic") return anthropicBackend(); + // Keyless, for proving a deployment before any billing happens. + if (name === "echo") return echoBackend(); throw new Error(`unknown HANKAN_TUTOR_BACKEND: ${name}`); } diff --git a/test/server/http.test.ts b/test/server/http.test.ts new file mode 100644 index 0000000..8b64331 --- /dev/null +++ b/test/server/http.test.ts @@ -0,0 +1,100 @@ +/* The server as a browser actually meets it: over HTTP, cross-origin. + + These need a running server (HANKAN_TEST_SERVER) and are skipped + otherwise. The tutor half needs HANKAN_TUTOR_BACKEND=echo, which is + keyless — the point is the transport, not the model. + + The bug this file exists for: CORS was absent and the auth middleware ran + first, so a preflight OPTIONS — which a browser is not allowed to send + credentials on — came back 401 and every cross-origin request failed + before reaching any handler. The Android build always talks to the Pi + cross-origin, so that was launch-blocking for the phone, and it presents + as an opaque "Failed to fetch" rather than as an auth error. */ + +import { describe, it, expect } from "vitest"; + +const BASE = process.env.HANKAN_TEST_SERVER; +const TOKEN = process.env.HANKAN_TEST_TOKEN ?? "test-token"; +const run = BASE ? describe : describe.skip; + +/** What a Capacitor webview sends as its Origin. */ +const PHONE_ORIGIN = "http://localhost"; + +run("CORS", () => { + it("answers the preflight instead of rejecting it as unauthorised", async () => { + const res = await fetch(`${BASE}/api/sync`, { + method: "OPTIONS", + headers: { + origin: PHONE_ORIGIN, + "access-control-request-method": "POST", + "access-control-request-headers": "authorization,content-type", + }, + }); + expect(res.status).not.toBe(401); + expect(res.status).toBeLessThan(300); + expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN); + expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i); + }); + + it("allows the Authorization header, without which the token cannot be sent", async () => { + const res = await fetch(`${BASE}/api/tutor`, { + method: "OPTIONS", + headers: { origin: PHONE_ORIGIN, "access-control-request-method": "POST" }, + }); + expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i); + }); + + it("puts the allow-origin header on the real response too", async () => { + const res = await fetch(`${BASE}/api/sync?cursor=0`, { + headers: { origin: PHONE_ORIGIN, authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN); + // Caches must not serve one origin's response to another. + expect(res.headers.get("vary")).toMatch(/origin/i); + }); + + it("still refuses a bad token", async () => { + const res = await fetch(`${BASE}/api/sync?cursor=0`, { + headers: { origin: PHONE_ORIGIN, authorization: "Bearer wrong" }, + }); + expect(res.status).toBe(401); + }); +}); + +run("tutor over HTTP", () => { + it("streams SSE events that parse, and ends with done", async () => { + const res = await fetch(`${BASE}/api/tutor`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ system: "system prompt here", history: [], message: "hello" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/text\/event-stream/); + // Compression is the usual reason SSE appears to hang behind a proxy. + expect(res.headers.get("cache-control")).toMatch(/no-transform/); + + const body = await res.text(); + const events = body + .split("\n\n") + .map((b) => b.split("\n").find((l) => l.startsWith("data:"))) + .filter((l): l is string => Boolean(l)) + .map((l) => JSON.parse(l.slice(5).trim()) as { type: string; text?: string }); + + expect(events.length).toBeGreaterThan(1); + expect(events.some((e) => e.type === "delta")).toBe(true); + expect(events[events.length - 1]!.type).toBe("done"); + + const text = events.filter((e) => e.type === "delta").map((e) => e.text).join(""); + expect(text).toContain("hello"); // the echo backend reflects the message + }); + + it("rejects a request with no system prompt", async () => { + const res = await fetch(`${BASE}/api/tutor`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ message: "hello" }), + }); + expect(res.status).toBe(400); + }); +});