Sync and recovery
Sync is optional infrastructure layered onto an account that already works locally. A sync location
makes sync available; it does not automatically upload every user's local data. Each user elects to
back up and sync from the generated AccountGate UI.
There are two ways a device gets a sync location: the deployment can compile one in (a managed Jazz app — the default sink), or the user can declare one at runtime by enrolling a ticket from a node they run. That second path is an end-user capability, not app hosting — your deployment does not change. What the user runs on their side (one binary, tickets, optional node-to-node replication) is covered in the lofi-node section.
Provision a managed Jazz app
From an existing generated project:
deno task jazz:provision
Or provision during project creation:
deno run -A jsr:@nzip/lofi/create --sync my-app
Provisioning writes four names to the git-ignored .env:
| Name | Exposure | Purpose |
|---|---|---|
JAZZ_APP_ID | Client-visible | Identifies the managed Jazz application |
JAZZ_SERVER_URL | Client-visible | Selects the managed sync endpoint |
JAZZ_ADMIN_SECRET | Server-only | Authorizes schema/permission operations |
BACKEND_SECRET | Server-only | Reserved server-side credential |
Claim the generated Jazz application within the window printed by the command. Keep .env private;
the build projects only the complete public pair and scans the client output for secret values.
Run deno task doctor after provisioning. A partial public pair is invalid: set both public names
or remove both to return to local-only mode.
Bring your own sync location
The sync destination can be user-selected data instead of developer configuration. A self-hosted
node (such as lofi-node) issues an app-connect
ticket — a lofisync1.… string carrying the store's app id and a gate URL — and the user pastes it
into the app:
import { enrollSyncTicket, isDataSinkError, isSyncEnrollmentError } from "@nzip/lofi";
try {
const session = await enrollSyncTicket(pastedTicket);
// session.sink → { source: "declared", host: "192.168.1.10:4802", label: "phone" }
} catch (error) {
if (isDataSinkError(error)) showEnrollmentProblem(error.code, error.message);
else if (isSyncEnrollmentError(error)) showEnrollmentProblem(error.code, error.message);
else throw error;
}
Enrollment declares the ticket as this device's data sink and elects sync in one step: the local
data pushes up under the same account identity, exactly as electing against a compiled managed app
does. parseSyncTicket validates a pasted string without enrolling it (returns null on any
malformed input), and readDeclaredSink / clearDeclaredSink manage the declaration directly. A
non-ticket location can be declared with await declareDataSink({ appId, serverUrl }) —
declarations are asynchronous because the record is sealed before it is stored.
Semantics to rely on:
- First boot is unchanged. Without a compiled managed app or a declared sink the device is local-only, and nothing leaves it.
- A declared sink overrides the compiled default for this device. A deployment pinned to its own managed app refuses tickets for a different app id, so a hosted product cannot be silently re-pointed.
- One store, one active sink. Declaring a different sink over an existing one is refused; clear the declaration first. Local data and elections survive clearing.
- The store answers before enrollment is kept. Enrollment runs a bounded metadata preflight
against the node: a store with no schema deployed for this app, or a rejected ticket, rolls the
declaration back and throws
SyncEnrollmentError— the device stays exactly as it was. A store that is merely unreachable enrolls anyway, with the warning recorded asstoreStatusin runtime diagnostics, because a flaky network must not block a legitimate ticket. - The election is pinned to the account that made it. Electing sync records the electing
account's fingerprint; a boot that finds a different account in hand opens local-only with
transport suppressed, and the session reports
syncOwnerMismatchrather than letting a second identity write into the owner's store. Electing under the mismatch throwsSyncOwnerError. Stopping sync releases the pin; an explicit restore ceremony adopts it for the restored account. - What the server can see is documented, not implied. The threat model states the server's view, the user's holdings, and the ticket/XSS custody story in one place.
- The ticket URL is a bearer credential. It is used verbatim as the sync server (its secret path
is what authorizes transport) and never exposed through the session snapshot —
session.sinkcarries only the source, host, and label. At rest the declaration persists only as a sealed envelope under a device-bound key, so nothing bearer-shaped sits in storage in cleartext. That protects the record against disk images, backups, and storage exfiltration; it does not protect it from script running on the same origin, which can drive the silent open itself. - Tickets carry a scope. A plain (or
scope: "sync") ticket is transport only; ascope: "provision"ticket additionally administers the store through the node's gate — the basis for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather than silently granted less than it claims. - A provision ticket is split before anything persists. Enrollment asks the node's scope-down
exchange for a derived sync ticket; that becomes the declared sink, and the provision capability
is only held in memory. Sealing it at rest is a separate, explicit passkey ceremony
(
sealProvisionCapability/unlockProvisionCapability), attempted rather than capability-detected; on a device whose authenticator cannot evaluate the WebAuthn PRF extension, nothing persists and the durable copy is the ticket in the user's password manager. The packagedTicketEnrollForm(@nzip/lofi/preact) is shaped for exactly that custody — the ticket is acurrent-passwordfield a manager saves on first paste and autofills later. Against a node without the exchange, the ticket enrolls as pasted, exactly as before. - The derived ticket binds to a device key when the node supports it. The scope-down exchange is offered the public half of a non-extractable device keypair; a node that accepts records the binding, and every boot thereafter proves possession — a fresh challenge signature — before connecting through a short-lived connect token. A ticket string lifted from the device then no longer connects from anywhere else. A failed proof (revocation, a reset node, a wiped device key) behaves like a revoked ticket; losing the device key is deliberately unrecoverable, and re-enrollment is the recovery.
- If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored sink as dead and surface re-enrollment rather than retrying silently.
Restoring on a fresh device
Recovery has two halves, held in two places: the passkey or phrase recovers who you are (the account identity), and the app-connect ticket names where your data lives. A passkey backup stores exactly the 32-byte account secret, so on a deployment without a compiled managed app a fresh device restores to a local-only account and then connects the sync location — paste the ticket saved in your password manager, or have the node re-issue one. The starter account UI offers both steps in either order; synced data arrives once the location is enrolled. Deployments with a compiled managed app restore straight to syncing.
The user's account journey
Data created before opt-in carries forward because enabling sync does not replace the account secret.
Recovery guarantees and limits
- The recoverable passkey stores the same account secret inside a resident, user-verifying
credential. Restoring it replaces the Jazz client and confirms the same
session.user_id. - Passkey availability is provider- and RP-ID-dependent. Pin
passkey.rpIdto the canonical production hostname. A passkey created for a preview hostname cannot move to another RP-ID. - iCloud Keychain, Google Password Manager, and third-party managers have different platform boundaries. lofi does not promise universal iOS/Android/browser/provider portability.
- The recovery phrase is the account authority. Anyone with all the words can act as that account.
- lofi does not retain material that can reconstruct the account for the user.
- Losing both the device and every copy of the phrase loses the account.
- Recovering a phrase can retrieve data that was synced. It cannot reconstruct writes that existed only in storage on a lost device.
- Restoring replaces the current device account, so the generated UI confirms before abandoning an unsynced local identity.
- Older lofi guard-only credentials protect phrase reveal on one device; they do not contain the Jazz secret and are not recoverable account backups.
Read Identity and recovery model for the detailed state machine and custody model.
Before shipping sync
- Customize the account copy without weakening the recovery warnings.
- Confirm the production build receives the intended public configuration.
- Run
deno task buildand verify the secret scan passes. - Test recovery using throwaway data and a second browser profile or device.
- Run the two-client convergence example described in Testing.
- Decide how users will store their phrase safely; there is no server-assisted reset flow.