Rendered from docs/app-ticket.md in the FelineStateMachine/lofi-node repository at the site's pinned ref — that file is the source of truth, gated by lofi-node's own CI.
App-connect tickets — the contract
This is the format and semantics the lofi app side implements against. An app ticket is the
credential a user hands to a lofi app so it syncs against their self-hosted node. It is distinct
from the node-pairing ticket (the endpoint… string used by lofi-node pair).
Ticket string
lofisync1.<base64url(JSON, no padding)>
{
"v": 1,
"appId": "cfe52e44-7a59-4232-8dbb-bf53f27aeed6",
"url": "http://192.168.1.10:4802/t/<secret>", // ← the app's serverUrl, verbatim
"scope": "provision", // optional: "sync" (default when absent) | "provision"
"label": "phone", // optional, user-facing
"node": "endpoint…" // optional: the node's iroh EndpointTicket (forward compat)
}
Parsers must treat unknown OPTIONAL fields as forward-compatible additions and an absent scope as
"sync" — every pre-scope ticket keeps meaning transport-only. Machine-readable conformance
fixtures: fixtures/app-ticket-fixtures.json.
<secret>is 32 random bytes base64url (43 chars, alphabet[A-Za-z0-9_-]) — safe as a URL path segment with no encoding.- The node stores only the SHA-256 digest of the secret; the ticket string is displayable once, at issuance.
Why the secret lives in the URL path
jazz clients preserve a base path in serverUrl (appScopedUrl) and reject query params. With
serverUrl = <ticket.url>, every request the client makes carries the secret with zero client
changes:
- sync WebSocket →
…/t/<secret>/apps/<appId>/ws - catalogue reads →
…/t/<secret>/apps/<appId>/schemas,schema/<hash> - admin (deploys) →
…/t/<secret>/apps/<appId>/admin/…(Jazz'sX-Jazz-Admin-Secretis still required on top — the ticket gates transport, the admin secret gates administration)
The node's access gate validates the secret (timing-safe, digest vs digest), strips the
/t/<secret> prefix, and proxies to the internal Jazz server (which binds loopback-only).
Scopes: sync vs provision
sync(default): transport only. Admin/catalogue-mutating routes (…/apps/<id>/admin/…) answer the SAME401 {"error":"invalid_ticket"}as an unknown secret — nothing to enumerate. Enrolling a ticket attaches transport only and never mutates the store.provision: a strict superset of sync — everything above PLUS store administration. For provision-scoped HTTP requests the gate injects the node'sX-Jazz-Admin-Secretitself (on/admin/*and on catalogue reads like/schemasand/schema/<hash>, which the merge-deploy flow needs to fetch the stored head schema verbatim). The admin secret therefore never leaves the node and never transits the client: a provisioning client passes any placeholder admin secret to jazz-toolsdeploy— the gate strips inboundX-Jazz-Admin-Secretheaders in ticket mode and substitutes its own. Possession of a provision ticket IS the store-administration opt-in. Issue one per provisioning context (ticket issue --provision --label laptop-admin), not per device.
Revocation semantics are identical for both scopes (401 / WS 4001).
Scope-down exchange (derive a sync ticket)
A provision ticket can mint a derived sync ticket from itself, so a user who pastes a single provision ticket ends up with two capability tiers: the app persists the derived sync ticket at rest and keeps the provision ticket sealed (passkey-PRF) or memory-only.
POST <ticket.url>/derive-sync-ticket
- Auth: the provision-scoped secret in the URL path, like every gated request. A sync-scoped or
unknown secret gets the same
401 {"error":"invalid_ticket"}as any unauthorized request — nothing to enumerate. Non-POST methods on a provision ticket get405. - Request body (optional):
{ "label": "laptop" }, and optionally a device public key to bind the derived ticket to possession (below):{ "label": "laptop", "devicePublicKey": { "alg": "ES256", "spki": "<base64url DER SPKI>" } }. Absent a label, it defaults to<parent label> (sync)(parent id when unlabelled). A malformed or wrong-curvedevicePublicKeyis400 {"error":"invalid_device_key"}— a client bug, not an auth probe. Nodes that predate the field ignore it, and the response then carries nopopmember: the client must fall back to bearer behavior. - Response:
200 {"v": 1, "id": "<ticket id>", "ticket": "lofisync1.…"}— a complete sync-scoped ticket string, plus"pop": truewhen a device key was bound. Itsurluses the node's configured public base (--public-url), exactly like CLI-issued tickets; the secret is embedded once and never stored. The ticket string itself is unchanged by binding — the client knows the ticket is bound because it offered the key and receivedpop: true.
The derived ticket's record carries the parent's id, and revocation cascades: once the parent is
revoked (or its record removed), every ticket derived from it fails verification exactly like a
revoked ticket — 401 on new requests, close code 4001 for live WebSockets. Revoking a derived ticket
alone leaves its parent untouched. Derived tickets are always sync scope, so derivation cannot
escalate and effective chains are one level deep; the parent check still walks the whole lineage.
lofi-node ticket list shows derived tickets as [from <parent id>] and reports them REVOKED once
their lineage is.
Proof-of-possession binding
A derived sync ticket can be bound to a device keypair at derive time (the devicePublicKey field
above). On a bound ticket, the bare secret opens only the possession exchange; every other request
must ride a connect token minted by a fresh signature from the bound key — an exfiltrated ticket
string alone no longer connects.
POST <ticket.url>/pop/challenge
→ 200 { "v": 1, "id": "<challenge id>", "nonce": "<43-char base64url>", "expiresIn": 120 }
POST <ticket.url>/pop/answer { "v": 1, "id": "<challenge id>", "sig": "<base64url raw r||s>" }
→ 200 { "v": 1, "connect": "<43-char base64url>", "expiresIn": 86400 }
→ 401 { "error": "invalid_ticket" }
- Signed message (exact bytes, UTF-8):
"lofisync-pop-v1\n" + appId + "\n" + ticketId + "\n" + nonce— the app id and ticket id bind the signature to this store and ticket, the single-use nonce prevents replay. The signature is WebCrypto ECDSA P-256 over SHA-256 in rawr||sform. - One rejection shape. Bad signatures, expired, unknown, and already-consumed challenges all
answer
401 {"error":"invalid_ticket"}— nothing to enumerate. A challenge dies on its first answer attempt, valid or not. Challenges expire after 120 seconds; at most 32 are outstanding per ticket (the oldest is dropped past that). - The connect token is appended to the enrolled
serverUrlas a path segment —<ticket.url>/c/<connect>— which jazz clients preserve exactly like the ticket secret itself. The node stores only the token's digest. Its lifetime slides: each authenticated use extends it, so a live sync session never expires mid-flight. Tokens are memory-only — a node restart invalidates them and the client re-runs the exchange; revocation kills them with the ticket. - Unbound tickets are unchanged (pure bearer), and a bearer ticket calling
/pop/*gets400 {"error":"pop_not_bound"}. Conformance fixtures for the signature format live indocs/fixtures/pop-fixtures.json(a test-only extractable keypair — never use outside tests). - Binding is not downgrade-proof against node replacement: a node build that predates the
poprecord field serves the ticket as pure bearer. The record field is forward-compatible (tickets.jsonstays v1), so a later node honors bindings made before a rollback.
Store-status preflight
Against a store with no deployed schema, client writes hang indefinitely — so a sync-only client needs a preflight it can reach without the admin secret. Any valid ticket (sync scope included) may call:
GET <ticket.url>/store-status
{
"v": 1,
"appId": "…",
"schema": {
"deployed": true, // false → { "deployed": false } only
"headHash": "ff85ac…", // newest stored schema hash
"permissionsHead": "0195…" // current permissions head object id, or null
}
}
Metadata only — never schema contents, never policies, never secrets. The node answers it itself (it
holds the admin secret and queries its loopback Jazz). lofi's store classifier maps this to
no_schema / hash-comparison states instead of hanging; 502 {"error":"store_unavailable"} means
the node's Jazz is unreachable. On open-mode (ungated) nodes this endpoint does not exist — dev
setups hold the admin secret and can query Jazz directly.
Enrollment flow (app side)
- User pastes/scans the ticket string; app parses it (
decodeAppTicketin@nzip/lofi-nodemirrors the validation: prefix,v: 1, http(s) URL with a/t/<43-char-secret>path). - A
provision-scoped ticket is split before anything persists: the app calls the scope-down exchange (above) and declares the derived sync ticket as its sink; the provision original is held in memory and, on PRF-capable devices, sealed behind the user's passkey — otherwise the user's password manager keeps the durable copy. Against a node without the exchange, the ticket enrolls as pasted. - The declared sink persists only as a sealed envelope under a device-bound key (localStorage key
lofi:data-sink:<appId>; nothing bearer-shaped is stored in cleartext). Boot opens it silently — no ceremony — and usesticket.urlas the runtimeserverUrl. Unlocking sealed provision capability for an admin operation is a user-verifying passkey ceremony. ticket.appIdshould match the app's own id; refuse enrollment otherwise.
Revocation semantics
- Unknown and revoked secrets both get
401 {"error":"invalid_ticket"}— indistinguishable to probers, on HTTP and on the WS upgrade (the 401 is the upgrade response). - A ticket revoked mid-session closes its live WebSockets with close code 4001
(
ticket revoked) within a couple of seconds. - On 401/4001 the app should treat the stored ticket as dead and surface re-enrollment (do not silently retry forever).
GET /health(no secret) is open — safe for liveness probes.
Security notes
- An unbound ticket is a bearer credential (256-bit entropy). Anyone holding it can sync as an authorized transport peer; identity/permissions remain Jazz's local-first layer on top. A possession-bound ticket downgrades theft of the string alone to nothing — connecting requires a signature from the device key, which never leaves the device. Script running on the app's own origin can still drive the signer while the page runs; binding defeats exfiltration, not live same-origin abuse.
- Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS
(the URL scheme in the ticket may be
https). Installed PWAs generally require a secure origin anyway. - Issue one ticket per device/context (
--label phone) so revocation is scoped.