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>
server — sync and the tutor
Two endpoints, both stateless. The client owns its database and its transcript; this process owns a Postgres table and an API key.
The app does not need this to work. With no server configured it runs entirely offline against its own SQLite and a local stand-in tutor. Adding a server turns on two things: the real 선생님, and syncing between devices.
POST /api/tutor {system, history, message} → SSE token stream
GET /api/sync ?cursor=N → rows newer than the cursor
POST /api/sync {rows} → upsert, last-write-wins
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
docker exec -it <your-postgres-container> psql -U postgres <<'SQL'
CREATE ROLE hankan LOGIN PASSWORD 'pick-something-long';
CREATE DATABASE hankan OWNER hankan;
SQL
The schema applies itself on boot — server/sql/001-schema.sql is idempotent,
so there is no migration step to run by hand.
2. Configure
cd server
cp .env.example .env
$EDITOR .env # DATABASE_URL, HANKAN_TOKEN, ANTHROPIC_API_KEY
docker network ls # find the network your Postgres and Caddy share
compose.yaml declares that network external, so it attaches to your
existing stack rather than starting a second Postgres. Nothing is published
to the host: Caddy reaches the container by name on the shared network.
3. Caddy
hankan.example.com {
# Compression must not touch the tutor stream — see below.
encode zstd gzip {
match {
not path /api/tutor*
}
}
handle /api/tutor* {
reverse_proxy hankan:8787 {
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
handle {
reverse_proxy hankan:8787
}
}
Compression is what usually breaks SSE, not buffering. encode delays the
header flush until body bytes arrive and holds already-flushed events inside
an unfinished compression frame, so the stream looks like it hangs. Excluding
the tutor route is the important line; flush_interval -1 is belt-and-braces
(Caddy already auto-flushes text/event-stream) and the zero timeouts stop a
long turn being cut off mid-lesson. Do not set response_buffers on this
route.
The server also sends Cache-Control: no-cache, no-transform and a : ping
heartbeat every 15s, which defeat most intermediary caching and idle timeouts.
4. Connect the app
In the app: 오늘 → 서버 → the URL and the token. It syncs on connect, when the tab regains focus, and every five minutes.
How sync works
Row-level, last-write-wins on updated_at, cursor-based on a server-assigned
change_seq. One user, so the loser of a conflict is at worst one SRS grade.
Rows are stored generically — primary key as text, body as JSONB — because the server never reads inside a row. It stores and orders them; the client interprets them. That keeps the two schemas from having to move in lockstep.
Three things are load-bearing:
change_seqadvances on every update, via a trigger. A row edited after a client last pulled would otherwise sit below that client's cursor and never be delivered. Putting it in a trigger means no write path can forget.- The pull cursor advances only as rows are applied, never from the push
response. The server's newest
change_seqincludes rows this device has not seen; adopting it would skip them permanently, and nothing would ever ask for that range again. - Deletes travel as tombstones. A deleted row leaves nothing to compare timestamps against, so without one the other device pushes its still-live copy back and the row silently returns.
What never syncs
meta holds the learner's preferences and bookkeeping that describes one
install, so an allowlist decides what may leave the device
(shared/sync-protocol.mjs). dict.loadedBands is the dangerous one:
replicating it would tell a phone that had loaded bands 0–2 that it holds
every row the desktop has, and the word rail would then fail to find words it
believes are present. server.token, sync.* and schema_version are
excluded for related reasons.
The bug this schema is shaped around
The original artifact stamped a fresh device's empty defaults as newer than
the server's real history, and clobbered it. Here seeded and defaulted rows
carry updated_at = 0, so they can never be dirty and can never win a
conflict. It is not avoided, it is unrepresentable —
test/sync/roundtrip.test.ts asserts it against a real Postgres.
The tutor
POST /api/tutor takes the assembled system prompt, the transcript the client
owns, and the new message; it holds nothing between requests, so a dropped
connection costs one turn rather than the conversation.
The system prompt is passed as a cached block. It is ~12k characters of gate and is byte-identical for as long as the learner stays in one unit — many 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. 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.
Running it locally
docker run -d --name hankan-pg-test \
-e POSTGRES_PASSWORD=test -e POSTGRES_DB=hankan -p 55432:5432 postgres:16-alpine
DATABASE_URL=postgres://postgres:test@localhost:55432/hankan \
HANKAN_TOKEN=test-token PORT=8788 HANKAN_TEST_MODE=1 \
node --experimental-strip-types server/src/main.ts
# from the repo root, in another shell
HANKAN_TEST_SERVER=http://localhost:8788 npm test
HANKAN_TEST_MODE=1 adds POST /api/test/reset, which wipes the user's rows
so each test starts clean. It exists only when that variable is set, so it
cannot be reached on the Pi even if the token leaks. Never set it in
production.
The tutor's own tests run against a mock backend and need no API key.