Skip to main content

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:

NameExposurePurpose
JAZZ_APP_IDClient-visibleIdentifies the managed Jazz application
JAZZ_SERVER_URLClient-visibleSelects the managed sync endpoint
JAZZ_ADMIN_SECRETServer-onlyAuthorizes schema/permission operations
BACKEND_SECRETServer-onlyReserved 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 as storeStatus in 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 syncOwnerMismatch rather than letting a second identity write into the owner's store. Electing under the mismatch throws SyncOwnerError. 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.sink carries 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; a scope: "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 packaged TicketEnrollForm (@nzip/lofi/preact) is shaped for exactly that custody — the ticket is a current-password field 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.rpId to 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 build and 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.