The reworked bundle's PORT.md makes row-level last-write-wins conditional
on three gates, each learned by losing real data. The port's sync broke
all three, and had four more ways to lose or stall work. Both ends change,
so this is one protocol version, refused by the other side if mismatched.
Gate 1, hydration. A device now pulls every page the server holds before
it may push anything; it used to push first. The 14 Sep laptop — a
week-old copy re-stamped at boot and pushed over a week of phone work —
is now a test, and the phone's week survives it. Boot writes nothing
syncable either: the lesson opens with the app's own words (the artifact's
seeded turn) and waits for Start, instead of stamping a reply and a
progress edit before a server can even be configured.
Gate 2, a counter. Every row remembers the change_seq it last agreed with
(base_seq). The server applies a write only if that still matches —
compare-and-swap under an advisory lock — and otherwise returns its copy
as a conflict. No clock is compared anywhere: a device an hour fast used
to win every conflict for an hour, and a slow one's newer edit was
silently dropped with HTTP 200. dirty and rev replace the timestamp
watermark, which lost edits whenever a clock moved backwards.
Gate 3, no silent shrinking. A conflict is settled by what each copy
holds (sync/resolve.ts): more reviews, more evidence, a finished unit, the
further roadmap position, the union of learned grammar. A deliberate
shrink is explicit: a reset or a cleared lesson raises a marker every
device obeys, including its own unsynced edits, so a reset is not undone
by a device that had not heard of it. Trimming the transcript is local
and tombstones nothing — it used to delete the other device's turns.
Also fixed on the way:
· keys travel as JSON arrays — a space in 몇 명 used to stop every
device's pull at that row, permanently;
· pulls take a shared lock against pushes, so a change_seq committed
out of order can no longer be skipped;
· study_log and peek are per device and summed, so two devices' reviews
of one day both count;
· each user's data has an epoch; a server that lost it is detected,
and the device re-hydrates and offers its data back;
· a protocol-1 client is refused with 426 rather than half-understood.
Migration 9 adds the columns, re-keys the counters and tombstones; the
server drops protocol-1 rows once (none were deployed). Verified: 14
two-device scenarios against a real Postgres, and two browser profiles
syncing a lesson through the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
/* Hankan's server: sync, and the tutor.
|
|
|
|
Both endpoints are stateless. The client owns its database and its
|
|
transcript; this process owns a Postgres table and an API key. Nothing
|
|
here needs to survive a restart. */
|
|
|
|
import { serve } from "@hono/node-server";
|
|
import { Hono } from "hono";
|
|
|
|
import { pkFor, PAGE_SIZE, PROTOCOL, PROTOCOL_HEADER } from "../../shared/sync-protocol.mjs";
|
|
import { openStore, type WireRow } from "./db.ts";
|
|
import { tutorRoute } from "./tutor.ts";
|
|
|
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
const TOKEN = process.env.HANKAN_TOKEN ?? "";
|
|
const DATABASE_URL = process.env.DATABASE_URL ?? "";
|
|
// One learner. The column exists so a second is a config change, not a
|
|
// 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);
|
|
}
|
|
if (!DATABASE_URL) {
|
|
console.error("DATABASE_URL is required.");
|
|
process.exit(1);
|
|
}
|
|
|
|
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, ${PROTOCOL_HEADER}`,
|
|
"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) => {
|
|
const header = c.req.header("authorization") ?? "";
|
|
const presented = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
// Constant-time-ish: compare lengths first, then every byte, so a wrong
|
|
// token cannot be narrowed down by timing the response.
|
|
const ok =
|
|
presented.length === TOKEN.length &&
|
|
presented.split("").reduce((acc, ch, i) => acc & (ch === TOKEN[i] ? 1 : 0), 1) === 1;
|
|
if (!ok) return c.json({ error: "unauthorized" }, 401);
|
|
await next();
|
|
});
|
|
|
|
app.get("/health", (c) => c.json({ ok: true }));
|
|
|
|
/* ── sync ────────────────────────────────────────────────────────────── */
|
|
|
|
/* Refuse any client not speaking this protocol. A client on protocol 1 is
|
|
exactly the device the artifact lost data to — an old build that pushes
|
|
before it pulls and trusts its own clock — so it gets a clear 426 rather
|
|
than a half-understood write. */
|
|
app.use("/api/sync", async (c, next) => {
|
|
if (c.req.header(PROTOCOL_HEADER) !== String(PROTOCOL)) {
|
|
return c.json({ error: "this server speaks sync protocol 2 — update the app", protocol: PROTOCOL }, 426);
|
|
}
|
|
await next();
|
|
});
|
|
|
|
app.get("/api/sync", async (c) => {
|
|
const cursor = Number(c.req.query("cursor") ?? 0);
|
|
if (!Number.isFinite(cursor) || cursor < 0) return c.json({ error: "bad cursor" }, 400);
|
|
return c.json(await store.pull(USER_ID, cursor, PAGE_SIZE));
|
|
});
|
|
|
|
app.post("/api/sync", async (c) => {
|
|
const body = (await c.req.json()) as { rows?: WireRow[] };
|
|
const rows = Array.isArray(body.rows) ? body.rows : [];
|
|
if (rows.length > PAGE_SIZE) return c.json({ error: "too many rows" }, 413);
|
|
return c.json(await store.push(USER_ID, rows));
|
|
});
|
|
|
|
/* Wipes this user's rows. Exists only when HANKAN_TEST_MODE is set, so it
|
|
cannot be reached on the Pi even if the token leaks. */
|
|
if (process.env.HANKAN_TEST_MODE === "1") {
|
|
app.post("/api/test/reset", async (c) => {
|
|
await store.reset(USER_ID);
|
|
return c.json({ ok: true });
|
|
});
|
|
console.warn("HANKAN_TEST_MODE — /api/test/reset is enabled. Never set this in production.");
|
|
}
|
|
|
|
/* ── tutor ───────────────────────────────────────────────────────────── */
|
|
|
|
app.route("/api/tutor", tutorRoute());
|
|
|
|
const server = serve({ fetch: app.fetch, port: PORT }, (info) => {
|
|
console.log(`hankan server on :${info.port}`);
|
|
});
|
|
|
|
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
process.on(sig, () => {
|
|
server.close(() => void store.close().then(() => process.exit(0)));
|
|
});
|
|
}
|