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>
109 lines
4.7 KiB
TypeScript
109 lines
4.7 KiB
TypeScript
/* 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,x-hankan-protocol",
|
|
},
|
|
});
|
|
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);
|
|
// Without this the phone's browser refuses to send the protocol header,
|
|
// and every sync from it is answered 426.
|
|
expect(res.headers.get("access-control-allow-headers")).toMatch(/x-hankan-protocol/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}`, "x-hankan-protocol": "2" },
|
|
});
|
|
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");
|
|
|
|
/* Deliberately not asserting the content. This file tests the
|
|
transport, and the server may be configured with any backend —
|
|
anthropic, openai or echo. Asserting the echo backend's wording made
|
|
the test fail the moment the server was pointed at a real model,
|
|
which is exactly the case it should have kept working through. */
|
|
const text = events.filter((e) => e.type === "delta").map((e) => e.text).join("");
|
|
expect(text.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|