Specification
The remote-storage protocol.
What Sync to your own server talks to, and the whole wire contract. Everything below is the protocol — a server that honours it is a valid server, whoever wrote it.
The gyms are local-first. Progress lives in the browser’s IndexedDB, and with no endpoint configured — the default — nothing leaves it. Sync exists for the one thing local-first cannot do on its own: a laptop and a desktop that are supposed to be the same history. The learner pastes an endpoint and a key into each gym’s settings screen; the same pair works for all of them.
The server’s job is deliberately small. It stores one opaque JSON document per (key, gym) and understands nothing about any of them. Seventeen gyms have seventeen shapes and change independently; a server that read them would need a release every time one did. Merging is the client’s business. The server stores bytes, hands them back, and keeps a revision number beside them.
Hand it to an agent.
One click copies this whole contract as a ready prompt — paste it into any coding agent and it can build you a compatible server.
The document
The stored document is exactly the envelope a gym’s own Export learning data produces — the bytes uploaded are the bytes that would have been downloaded:
{
"format": "liter8-learning-data",
"gym": "vim",
"version": 3,
"coreVersion": "…",
"exportedAt": "2026-01-01T00:00:00.000Z",
"data": { }
}format— alwaysliter8-learning-data.gym— the gym’s slug, the same value as the URL path segment.version— that gym’s own schema version, mirrored from theX-Learn-Schemaheader on the write.coreVersion— the vendored core’s version, for forensics only.exportedAt— ISO-8601.data— the gym’s whole learning state. Its shape is the gym’s own and none of the server’s business.
Store it as bytes. The one acceptable look inside is JSON.parse on write — refusing a body that would make every future read fail is worth catching at the door. Whether it parses, never what it says.
Keeping a handful of previous revisions per document is cheap insurance against a bad merge a client pushed — not required, but the kind of thing you will be glad of exactly once.
Identity
There are no accounts. Authorization: Bearer <key> on every request except GET /v1/health, and the key is the whole identity.
- Key shape:
^l8_[A-Za-z0-9_-]{43}$—l8_plus 256 bits of base64url, no padding. The shape is fixed because a guessable key is a bucket anyone can write into, and there is no second factor behind it. - How keys are issued is the operator’s business; the gyms never see it.
- Do not store the key itself. Deriving the bucket id as
sha256(key)means a leaked database reveals no key. - A malformed key and an unknown key get the same
401. Telling them apart would make the endpoint an oracle for which keys exist. - Losing the key loses the data — there is nobody to ask for a reset. The settings screen says so before connecting.
No cookies are involved and none should be. The client sends credentials: "omit" and refuses to follow redirects, so a redirect cannot forward the bearer token to a host the learner did not choose.
Endpoints
Protocol version 1. Every response carries X-Learn-Protocol: 1; a request that arrives sending a different X-Learn-Protocol is refused with 400.
GET /v1/health → 200 { "ok": true, "protocol": 1 } (no auth)
GET /v1/gyms → 200 { "gyms": [Summary, …] }
GET /v1/gyms/{gym} → 200 { …Summary, "document": {…} } | 404
PUT /v1/gyms/{gym} → 200 Summary | 409 | 412 | 413 | 415 | 428
DELETE /v1/gyms/{gym} → 204 | 404Summary is { "gym", "revision", "updatedAt", "bytes", "schemaVersion" }:
revision— string. Opaque to the client; a per-document incrementing integer is fine.updatedAt— unix milliseconds.bytes— the stored body’s size.schemaVersion— the lastX-Learn-Schemaaccepted.document— on the single-documentGETand on412, the raw stored JSON.
{gym} matches ^[a-z][a-z0-9-]{1,31}$; anything else is 400. There is deliberately no list of valid gyms — a list would be the server knowing what a gym is. Off the map: 404 for an unknown path, 405 for a wrong method on a known one.
Writes carry a precondition, or they are refused
If-None-Match: *— create;412if anything is already there.If-Match: <revision>— replace exactly the revision you merged against;412if it has moved. The bare revision and the quotedETagform both work.- Neither —
428. No blind writes, ever: a client that does not say what it expects has not thought about the other device.
A 412 carries the current document:
{ "error": "Another device wrote first.", "revision": "7", "document": { } }The status line alone would be enough, but a lost race is the normal case when two devices are both awake, and this makes each one a single round-trip instead of two.
The revision travels in response bodies, not only in ETag. A cross-origin response.headers.get("etag") returns null unless the server remembered Access-Control-Expose-Headers, and a client that reads null there degrades silently to blind writes — the whole concurrency scheme gone, with no error anywhere. Send ETag: "<revision>" too, but the body is the channel no proxy or forgotten CORS header can take away.
Schema versions
X-Learn-Schema: <positive integer> rides on every PUT — a header rather than a body field, so the server can act on it without parsing anything. A write whose version is lower than the stored one is refused with 409 and changes nothing: the server-side mirror of the read-only mode each gym enters when it opens data newer than itself. An old tab left open in another window must not be able to flatten a document written by a newer one. Equal or higher is accepted and raises the stored version; absent means 1.
Refusing to read a newer document is the client’s problem, not the server’s — the client rejects an envelope above its own schema version with the same wording it uses for an import file. Comprehension is the client’s business.
Bodies and sizes
PUTrequiresContent-Type: application/json—415otherwise.- The body must parse as JSON —
400otherwise. - The size limit is the operator’s choice; the client refuses its own writes past 8 MiB, so a server limit at 8 MiB is never reached in normal use. Over it:
413. - Do not accept compressed request bodies. The client never sends them, and accepting them means accepting a decompression bomb for no benefit. Response compression belongs to a reverse proxy in front, if you want it.
CORS
The gyms live on their own origins (vim.liter8.sh, sql.liter8.sh, …) and the storage server lives somewhere else — so CORS is not optional, it is how the feature works at all.
Access-Control-Allow-Methods: GET, PUT, DELETE, OPTIONSAccess-Control-Allow-Headers: authorization, content-type, if-match, if-none-match, x-learn-protocol, x-learn-schemaAccess-Control-Expose-Headers: etag— belt and braces; the body already carries the revision, but a client that prefers the header should work.Access-Control-Max-Age: 86400,Vary: Origin, and anOPTIONSanswer for any path.Access-Control-Allow-Origin— an allowlist of the gym origins once things work, or*. A wildcard is safe here in a way it would not be on a cookie-authenticated service: without the bearer token a cross-origin request gets401, and a browser does not hand out a token it was never given.- Never send
Access-Control-Allow-Credentials. The bearer token is the only identity, which makes CSRF structurally impossible.
Errors, in one place
| 400 | bad gym name, unparsable X-Learn-Schema/If-Match, X-Learn-Protocol mismatch, body not JSON |
| 401 | malformed or unknown key — the same answer for both |
| 404 | no such endpoint, or no document stored for that gym |
| 405 | wrong method |
| 409 | X-Learn-Schema below the stored version |
| 412 | precondition failed; body carries revision and usually document |
| 413 | over the size limit |
| 415 | body not application/json |
| 428 | PUT without If-Match/If-None-Match |
| 429 | rate limited — carry Retry-After in seconds |
Error bodies are { "error": "…" }, in prose plain enough to show a learner verbatim. Rate limiting is the server’s own policy — per-bucket token buckets are a sane default. Whatever the policy, 429 with Retry-After is all the client needs to behave.
What the client does with it
Not part of the contract, but useful context for what the traffic looks like:
- Reads and writes go to IndexedDB first. Sync runs beside the store, not in front of it — with no endpoint configured, none of this runs.
- Push happens ~1.5 s after the last local write settles. Pulls happen on connect, when the tab becomes visible (at most every 15 s), when the network returns, and on Sync now. No polling, no sockets.
- A pull merges three ways — local, remote, and a remembered base: the last document the server acknowledged. Without it, “absent here, present there” cannot be told apart from “deleted here, kept there”.
- A
412is normal operation, not an error: the client merges against the document in the response and retries, up to four rounds. - Requests are
redirect: "error",credentials: "omit",cache: "no-store", with a ~15 s timeout. DELETEis only ever the explicit Delete the server copy action. Disconnecting a device sends nothing — another device may still be pointed at the same key. A404onDELETEcounts as success: already gone is the outcome it asked for.
A valid server
The short version, for checking an implementation:
- One opaque document per (key, gym) — never parsed beyond “is it JSON” on write.
- Bearer auth on everything but
/v1/health; malformed and unknown keys indistinguishable. - A precondition on every write;
412returns the current document; the revision in every body that has one, and inETag. X-Learn-Schemaremembered per document; a lower one refused409.X-Learn-Protocol: 1on every response.- CORS as above; credentials never allowed.
429carriesRetry-After.