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); + }); +});