Skip to content
liter8.sh
~/liter8/remote-storage.md

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": { }
}

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.

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 | 404

Summary is { "gym", "revision", "updatedAt", "bytes", "schemaVersion" }:

{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

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

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.

Errors, in one place

400bad gym name, unparsable X-Learn-Schema/If-Match, X-Learn-Protocol mismatch, body not JSON
401malformed or unknown key — the same answer for both
404no such endpoint, or no document stored for that gym
405wrong method
409X-Learn-Schema below the stored version
412precondition failed; body carries revision and usually document
413over the size limit
415body not application/json
428PUT without If-Match/If-None-Match
429rate 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:

A valid server

The short version, for checking an implementation:

  1. One opaque document per (key, gym) — never parsed beyond “is it JSON” on write.
  2. Bearer auth on everything but /v1/health; malformed and unknown keys indistinguishable.
  3. A precondition on every write; 412 returns the current document; the revision in every body that has one, and in ETag.
  4. X-Learn-Schema remembered per document; a lower one refused 409.
  5. X-Learn-Protocol: 1 on every response.
  6. CORS as above; credentials never allowed.
  7. 429 carries Retry-After.