clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

docs/adopt-site-pages
lofi-node / docs / app-ticket.md
app-ticket.md 7.5K rendered
1# App-connect tickets — the contract23This is the format and semantics the lofi app side implements against. An app ticket is the4credential a user hands to a lofi app so it syncs against their self-hosted node. It is distinct5from the **node-pairing ticket** (the `endpoint…` string used by `lofi-node pair`).67## Ticket string89```10lofisync1.<base64url(JSON, no padding)>11```1213```jsonc14{15 "v": 1,16 "appId": "cfe52e44-7a59-4232-8dbb-bf53f27aeed6",17 "url": "http://192.168.1.10:4802/t/<secret>", // ← the app's serverUrl, verbatim18 "scope": "provision", // optional: "sync" (default when absent) | "provision"19 "label": "phone", // optional, user-facing20 "node": "endpoint…" // optional: the node's iroh EndpointTicket (forward compat)21}22```2324Parsers must treat unknown OPTIONAL fields as forward-compatible additions and an absent `scope` as25`"sync"` — every pre-scope ticket keeps meaning transport-only. Machine-readable conformance26fixtures: [fixtures/app-ticket-fixtures.json](fixtures/app-ticket-fixtures.json).2728- `<secret>` is 32 random bytes base64url (43 chars, alphabet `[A-Za-z0-9_-]`) — safe as a URL path29 segment with no encoding.30- The node stores only the SHA-256 digest of the secret; the ticket string is displayable **once**,31 at issuance.3233## Why the secret lives in the URL path3435jazz clients preserve a base path in `serverUrl` (`appScopedUrl`) and reject query params. With36`serverUrl = <ticket.url>`, every request the client makes carries the secret with **zero client37changes**:3839- sync WebSocket → `…/t/<secret>/apps/<appId>/ws`40- catalogue reads → `…/t/<secret>/apps/<appId>/schemas`, `schema/<hash>`41- admin (deploys) → `…/t/<secret>/apps/<appId>/admin/…` (Jazz's `X-Jazz-Admin-Secret` is still42 required on top — the ticket gates transport, the admin secret gates administration)4344The node's access gate validates the secret (timing-safe, digest vs digest), strips the45`/t/<secret>` prefix, and proxies to the internal Jazz server (which binds loopback-only).4647## Scopes: sync vs provision4849- **`sync`** (default): transport only. Admin/catalogue-mutating routes (`…/apps/<id>/admin/…`)50 answer the SAME `401 {"error":"invalid_ticket"}` as an unknown secret — nothing to enumerate. This51 is what lofi#109 calls "enrolling a ticket attaches transport only and never mutates the store."52- **`provision`**: a strict superset of sync — everything above PLUS store administration. For53 provision-scoped HTTP requests the gate **injects the node's `X-Jazz-Admin-Secret` itself** (on54 `/admin/*` and on catalogue reads like `/schemas` and `/schema/<hash>`, which the merge-deploy55 flow needs to fetch the stored head schema verbatim). The admin secret therefore never leaves the56 node and never transits the client: a provisioning client passes any placeholder admin secret to57 jazz-tools `deploy` — the gate strips inbound `X-Jazz-Admin-Secret` headers in ticket mode and58 substitutes its own. Possession of a provision ticket IS the store-administration opt-in. Issue59 one per provisioning context (`ticket issue --provision --label60 laptop-admin`), not per device.6162Revocation semantics are identical for both scopes (401 / WS 4001).6364## Scope-down exchange (derive a sync ticket)6566A provision ticket can mint a **derived sync ticket** from itself, so a user who pastes a single67provision ticket ends up with two capability tiers: the app persists the derived sync ticket at rest68and keeps the provision ticket sealed (passkey-PRF) or memory-only.6970```71POST <ticket.url>/derive-sync-ticket72```7374- **Auth**: the provision-scoped secret in the URL path, like every gated request. A sync-scoped or75 unknown secret gets the same `401 {"error":"invalid_ticket"}` as any unauthorized request —76 nothing to enumerate. Non-POST methods on a provision ticket get `405`.77- **Request body** (optional): `{ "label": "laptop" }`. Absent, the label defaults to78 `<parent label> (sync)` (parent id when unlabelled).79- **Response**: `200 {"v": 1, "id": "<ticket id>", "ticket": "lofisync1.…"}` — a complete80 sync-scoped ticket string. Its `url` uses the node's configured public base (`--public-url`),81 exactly like CLI-issued tickets; the secret is embedded once and never stored.8283The derived ticket's record carries the parent's id, and **revocation cascades**: once the parent is84revoked (or its record removed), every ticket derived from it fails verification exactly like a85revoked ticket — 401 on new requests, close code 4001 for live WebSockets. Revoking a derived ticket86alone leaves its parent untouched. Derived tickets are always sync scope, so derivation cannot87escalate and effective chains are one level deep; the parent check still walks the whole lineage.88`lofi-node ticket list` shows derived tickets as `[from <parent id>]` and reports them REVOKED once89their lineage is.9091## Store-status preflight9293Against a store with **no deployed schema, client writes hang indefinitely** (lofi#109's pinned94failure surface) — so a sync-only client needs a preflight it can reach without the admin secret.95Any valid ticket (sync scope included) may call:9697```98GET <ticket.url>/store-status99```100101```jsonc102{103 "v": 1,104 "appId": "…",105 "schema": {106 "deployed": true, // false → { "deployed": false } only107 "headHash": "ff85ac…", // newest stored schema hash108 "permissionsHead": "0195…" // current permissions head object id, or null109 }110}111```112113Metadata only — never schema contents, never policies, never secrets. The node answers it itself (it114holds the admin secret and queries its loopback Jazz). lofi's store classifier maps this to115`no_schema` / hash-comparison states instead of hanging; `502 {"error":"store_unavailable"}` means116the node's Jazz is unreachable. On open-mode (ungated) nodes this endpoint does not exist — dev117setups hold the admin secret and can query Jazz directly.118119## Enrollment flow (app side)1201211. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`122 mirrors the validation: prefix, `v: 1`, http(s) URL with a `/t/<43-char-secret>` path).1232. App encrypts the ticket with the passkey-derived at-rest key (lofi `package/runtime/auth.ts`:124 `derivePrfSecret` → `deriveAtRestKey` → `encryptAtRest`) and stores the blob in localStorage125 (suggested key: `lofi:sync-location:<appId>`).1263. On boot, after the user unlocks with their passkey: decrypt, use `ticket.url` as the runtime127 `serverUrl` override. The decrypted local data therefore reveals _location + access_ for the128 user's lofi data.1294. `ticket.appId` should match the app's own id; refuse enrollment otherwise.130131## Revocation semantics132133- Unknown **and** revoked secrets both get `401 {"error":"invalid_ticket"}` — indistinguishable to134 probers, on HTTP and on the WS upgrade (the 401 _is_ the upgrade response).135- A ticket revoked mid-session closes its live WebSockets with close code **4001**136 (`ticket revoked`) within a couple of seconds.137- On 401/4001 the app should treat the stored ticket as dead and surface re-enrollment (do not138 silently retry forever).139- `GET /health` (no secret) is open — safe for liveness probes.140141## Security notes142143- The ticket is a **bearer credential** (256-bit entropy). Anyone holding it can sync as an144 authorized transport peer; identity/permissions remain Jazz's local-first layer on top.145- Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS146 (the URL scheme in the ticket may be `https`). Installed PWAs generally require a secure origin147 anyway.148- Issue one ticket per device/context (`--label phone`) so revocation is scoped.