clone

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

feat/connection-observability
lofi-node / docs / site / tickets-explained.md
tickets-explained.md 5.0K 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 starts as a **bearer credential** with 256-bit entropy. Anyone holding it can sync as58an authorized 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.6263A derived sync ticket can additionally be **bound to the enrolling device**: the app offers a device64public key at the scope-down exchange, and connecting then requires a fresh signature from that key65— which never leaves the device — before the node accepts any traffic. A stolen ticket string alone66no longer connects. What binding does not change: script running on the app's own origin can still67drive the device key while the page is open, and the row-level permission story is still Jazz's.68Binding defeats exfiltration of the credential, not live abuse of a compromised page.6970## What the app does with it7172You paste; the app does the rest. The ticket is validated (against the same rules this node73enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync74location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local75record (encrypted at rest under a device-bound key) and never surfaces it through its session76snapshot — only the host and your label show. A provision ticket gets split first: the app asks the77node for a derived sync ticket to store, and the provision original is either sealed behind your78passkey or kept only in memory with your password manager holding the durable copy. The derived79ticket is linked to its parent — revoking the provision ticket revokes it too. For app developers,80the framework call behind that paste is `enrollSyncTicket`, documented in81[Sync and recovery](/docs/sync-and-recovery).