clone

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

fix/secret-file-permissions
lofi-node / docs / app-ticket.md
app-ticket.md 5.9K 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## Store-status preflight6566Against a store with **no deployed schema, client writes hang indefinitely** (lofi#109's pinned67failure surface) — so a sync-only client needs a preflight it can reach without the admin secret.68Any valid ticket (sync scope included) may call:6970```71GET <ticket.url>/store-status72```7374```jsonc75{76 "v": 1,77 "appId": "…",78 "schema": {79 "deployed": true, // false → { "deployed": false } only80 "headHash": "ff85ac…", // newest stored schema hash81 "permissionsHead": "0195…" // current permissions head object id, or null82 }83}84```8586Metadata only — never schema contents, never policies, never secrets. The node answers it itself (it87holds the admin secret and queries its loopback Jazz). lofi's store classifier maps this to88`no_schema` / hash-comparison states instead of hanging; `502 {"error":"store_unavailable"}` means89the node's Jazz is unreachable. On open-mode (ungated) nodes this endpoint does not exist — dev90setups hold the admin secret and can query Jazz directly.9192## Enrollment flow (app side)93941. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`95 mirrors the validation: prefix, `v: 1`, http(s) URL with a `/t/<43-char-secret>` path).962. App encrypts the ticket with the passkey-derived at-rest key (lofi `package/runtime/auth.ts`:97 `derivePrfSecret` → `deriveAtRestKey` → `encryptAtRest`) and stores the blob in localStorage98 (suggested key: `lofi:sync-location:<appId>`).993. On boot, after the user unlocks with their passkey: decrypt, use `ticket.url` as the runtime100 `serverUrl` override. The decrypted local data therefore reveals _location + access_ for the101 user's lofi data.1024. `ticket.appId` should match the app's own id; refuse enrollment otherwise.103104## Revocation semantics105106- Unknown **and** revoked secrets both get `401 {"error":"invalid_ticket"}` — indistinguishable to107 probers, on HTTP and on the WS upgrade (the 401 _is_ the upgrade response).108- A ticket revoked mid-session closes its live WebSockets with close code **4001**109 (`ticket revoked`) within a couple of seconds.110- On 401/4001 the app should treat the stored ticket as dead and surface re-enrollment (do not111 silently retry forever).112- `GET /health` (no secret) is open — safe for liveness probes.113114## Security notes115116- The ticket is a **bearer credential** (256-bit entropy). Anyone holding it can sync as an117 authorized transport peer; identity/permissions remain Jazz's local-first layer on top.118- Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS119 (the URL scheme in the ticket may be `https`). Installed PWAs generally require a secure origin120 anyway.121- Issue one ticket per device/context (`--label phone`) so revocation is scoped.