clone

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

release/0.3.0
lofi-node / docs / site / tickets-explained.md
tickets-explained.md 4.5K rendered
1# Tickets explained23<!-- Source: FelineStateMachine/lofi-node docs/app-ticket.md (the normative contract) and4 src/ticket.ts; lofi package/runtime/data-sink.ts for the app side. -->56lofi-node uses two kinds of ticket, and confusing them is the most common early stumble. This guide7is the conceptual tour; the [app-ticket contract](/node/docs/app-ticket) is the normative spec the8lofi side implements against.910## Two ticket kinds1112- A **node-pairing ticket** (`endpoint…`) is an iroh endpoint address. It names a _node_ so another13 node can dial it. It is used by `lofi-node pair` and never by browsers or apps.14- An **app-connect ticket** (`lofisync1.…`) is the credential _you_ hand to a lofi app so it syncs15 against your node. It carries the store's app id and the gate URL with a 256-bit secret embedded16 in its path.1718## Why the secret lives in the URL path1920A header was the obvious alternative and it does not survive contact with the platform: the21browser's WebSocket API cannot set request headers, so a header-borne credential would need a22different authentication path for exactly the connection that matters most. Jazz clients preserve a23base path in their server URL and reject query parameters. By making the ticket's URL24`http(s)://host:port/t/<secret>`, every request the client ever makes — sync WebSocket connects,25catalogue reads, admin calls — carries the secret with **zero client changes**. The cost of the26choice is that the secret rides in a URL, which is why proxy access logs need27[redaction](beyond-the-lan.md) and why the gate compares digests rather than logging paths. The gate28validates the secret (timing-safe, digest against digest), strips the prefix, and proxies to the29loopback-only Jazz server. The lofi app uses the ticket URL verbatim as its sync server; that is the30whole integration.3132## Scopes3334- **`sync`** (the default, and what an absent scope means — every pre-scope ticket keeps meaning35 transport-only): sync, catalogue reads that transport needs, and the metadata-only `store-status`36 preflight. Admin routes answer the same `401 {"error":"invalid_ticket"}` as an unknown secret, so37 a probe learns nothing.38- **`provision`**: a strict superset — everything above plus store administration. The gate injects39 the node's own `X-Jazz-Admin-Secret` for provisioning requests and strips any inbound one, so the40 admin secret never transits the client. Possession of a provision ticket **is** the41 store-administration opt-in; issue one per provisioning context, not per device.4243A ticket with an unrecognized scope is rejected outright by the lofi parser — never silently granted44less than it claims.4546## Revocation4748- The node stores only SHA-256 digests; the ticket string is displayable once, at issuance.49- Unknown and revoked secrets are indistinguishable to probers: both get50 `401 {"error":"invalid_ticket"}`, on HTTP and on the WebSocket upgrade.51- Revoking a ticket mid-session closes its live WebSockets with close code **4001** within a couple52 of seconds. The app treats the stored sink as dead and surfaces re-enrollment — it never silently53 retries forever.5455## The security posture, honestly5657An app ticket is a **bearer credential** with 256-bit entropy. Anyone holding it can sync as an58authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on59top, enforced by the deployed policy, not by the ticket. Plain http is acceptable on a trusted LAN;60beyond one, front the gate with TLS — installed PWAs generally require a secure origin anyway. Issue61tickets per device or context so revocation is scoped.6263## What the app does with it6465You paste; the app does the rest. The ticket is validated (against the same rules this node66enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync67location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local68record (encrypted at rest under a device-bound key) and never surfaces it through its session69snapshot — only the host and your label show. A provision ticket gets split first: the app asks the70node for a derived sync ticket to store, and the provision original is either sealed behind your71passkey or kept only in memory with your password manager holding the durable copy. The derived72ticket is linked to its parent — revoking the provision ticket revokes it too. For app developers,73the framework call behind that paste is `enrollSyncTicket`, documented in74[Sync and recovery](/docs/sync-and-recovery).