1# Sync and recovery23Sync is optional infrastructure layered onto an account that already works locally. A sync location4makes sync available; it does not automatically upload every user's local data. Each user elects to5back up and sync from the generated `AccountGate` UI.67There are two ways a device gets a sync location: the deployment can compile one in (a managed Jazz8app — the _default sink_), or the user can declare one at runtime by enrolling a ticket from a node9**they** run. That second path is an end-user capability, not app hosting — your deployment does not10change. What the user runs on their side (one binary, tickets, optional node-to-node replication) is11covered in the [lofi-node section](/node).1213## Provision a managed Jazz app1415From an existing generated project:1617```sh18deno task jazz:provision19```2021Or provision during project creation:2223```sh24deno run -A jsr:@nzip/lofi/create --sync my-app25```2627Provisioning writes four names to the git-ignored `.env`:2829| Name | Exposure | Purpose |30| ------------------- | -------------- | --------------------------------------- |31| `JAZZ_APP_ID` | Client-visible | Identifies the managed Jazz application |32| `JAZZ_SERVER_URL` | Client-visible | Selects the managed sync endpoint |33| `JAZZ_ADMIN_SECRET` | Server-only | Authorizes schema/permission operations |34| `BACKEND_SECRET` | Server-only | Reserved server-side credential |3536Claim the generated Jazz application within the window printed by the command. Keep `.env` private;37the build projects only the complete public pair and scans the client output for secret values.3839Run `deno task doctor` after provisioning. A partial public pair is invalid: set both public names40or remove both to return to local-only mode.4142## Bring your own sync location4344The sync destination can be **user-selected data instead of developer configuration**. A self-hosted45node (such as [lofi-node](https://github.com/FelineStateMachine/lofi-node)) issues an app-connect46ticket — a `lofisync1.…` string carrying the store's app id and a gate URL — and the user pastes it47into the app:4849```ts50import { enrollSyncTicket, isDataSinkError } from "@nzip/lofi";5152try {53 const session = await enrollSyncTicket(pastedTicket);54 // session.sink → { source: "declared", host: "192.168.1.10:4802", label: "phone" }55} catch (error) {56 if (isDataSinkError(error)) showEnrollmentProblem(error.code, error.message);57 else throw error;58}59```6061Enrollment declares the ticket as this device's _data sink_ and elects sync in one step: the local62data pushes up under the same account identity, exactly as electing against a compiled managed app63does. `parseSyncTicket` validates a pasted string without enrolling it (returns `null` on any64malformed input), and `readDeclaredSink` / `clearDeclaredSink` manage the declaration directly. A65non-ticket location can be declared with `await declareDataSink({ appId, serverUrl })` —66declarations are asynchronous because the record is sealed before it is stored.6768Semantics to rely on:6970- **First boot is unchanged.** Without a compiled managed app or a declared sink the device is71 local-only, and nothing leaves it.72- **A declared sink overrides the compiled default** for this device. A deployment pinned to its own73 managed app refuses tickets for a different app id, so a hosted product cannot be silently74 re-pointed.75- **One store, one active sink.** Declaring a different sink over an existing one is refused; clear76 the declaration first. Local data and elections survive clearing.77- **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path78 is what authorizes transport) and never exposed through the session snapshot — `session.sink`79 carries only the source, host, and label. At rest the declaration persists only as a sealed80 envelope under a device-bound key, so nothing bearer-shaped sits in storage in cleartext. That81 protects the record against disk images, backups, and storage exfiltration; it does not protect it82 from script running on the same origin, which can drive the silent open itself.83- **Tickets carry a scope.** A plain (or `scope: "sync"`) ticket is transport only; a84 `scope: "provision"` ticket additionally administers the store through the node's gate — the basis85 for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather86 than silently granted less than it claims.87- **A provision ticket is split before anything persists.** Enrollment asks the node's scope-down88 exchange for a derived sync ticket; that becomes the declared sink, and the provision capability89 is only _held_ in memory. Sealing it at rest is a separate, explicit passkey ceremony90 (`sealProvisionCapability` / `unlockProvisionCapability`), attempted rather than91 capability-detected; on a device whose authenticator cannot evaluate the WebAuthn PRF extension,92 nothing persists and the durable copy is the ticket in the user's password manager. The packaged93 `TicketEnrollForm` (`@nzip/lofi/preact`) is shaped for exactly that custody — the ticket is a94 `current-password` field a manager saves on first paste and autofills later. Against a node95 without the exchange, the ticket enrolls as pasted, exactly as before.96- If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored97 sink as dead and surface re-enrollment rather than retrying silently.9899## The user's account journey100101```mermaid102stateDiagram-v2103 [*] --> LocalOnly: First boot104 state "Local-only<br/>Private on-device account<br/>Durable local data" as LocalOnly105 LocalOnly --> Syncing: User creates recoverable passkey and saves phrase106 state "Syncing<br/>Same account identity<br/>Local and global durability" as Syncing107 Syncing --> LocalOnly: Stop syncing108 Syncing --> Recovered: Use passkey or enter phrase109 state "Recovered<br/>Same identity restored<br/>Synced data downloads" as Recovered110 Recovered --> Syncing111```112113Data created before opt-in carries forward because enabling sync does not replace the account114secret.115116## Recovery guarantees and limits117118- The recoverable passkey stores the same account secret inside a resident, user-verifying119 credential. Restoring it replaces the Jazz client and confirms the same `session.user_id`.120- Passkey availability is provider- and RP-ID-dependent. Pin `passkey.rpId` to the canonical121 production hostname. A passkey created for a preview hostname cannot move to another RP-ID.122- iCloud Keychain, Google Password Manager, and third-party managers have different platform123 boundaries. lofi does not promise universal iOS/Android/browser/provider portability.124- The recovery phrase is the account authority. Anyone with all the words can act as that account.125- lofi does not retain material that can reconstruct the account for the user.126- Losing both the device and every copy of the phrase loses the account.127- Recovering a phrase can retrieve data that was synced. It cannot reconstruct writes that existed128 only in storage on a lost device.129- Restoring replaces the current device account, so the generated UI confirms before abandoning an130 unsynced local identity.131- Older lofi guard-only credentials protect phrase reveal on one device; they do not contain the132 Jazz secret and are not recoverable account backups.133134Read [Identity and recovery model](auth-identity.md) for the detailed state machine and custody135model.136137## Before shipping sync138139- Customize the account copy without weakening the recovery warnings.140- Confirm the production build receives the intended public configuration.141- Run `deno task build` and verify the secret scan passes.142- Test recovery using throwaway data and a second browser profile or device.143- Run the two-client convergence example described in [Testing](testing.md).144- Decide how users will store their phrase safely; there is no server-assisted reset flow.