clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / docs / sync-and-recovery.md
sync-and-recovery.md 10.3K rendered
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, isSyncEnrollmentError } 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 if (isSyncEnrollmentError(error)) showEnrollmentProblem(error.code, error.message);58 else throw error;59}60```6162Enrollment declares the ticket as this device's _data sink_ and elects sync in one step: the local63data pushes up under the same account identity, exactly as electing against a compiled managed app64does. `parseSyncTicket` validates a pasted string without enrolling it (returns `null` on any65malformed input), and `readDeclaredSink` / `clearDeclaredSink` manage the declaration directly. A66non-ticket location can be declared with `await declareDataSink({ appId, serverUrl })` —67declarations are asynchronous because the record is sealed before it is stored.6869Semantics to rely on:7071- **First boot is unchanged.** Without a compiled managed app or a declared sink the device is72 local-only, and nothing leaves it.73- **A declared sink overrides the compiled default** for this device. A deployment pinned to its own74 managed app refuses tickets for a different app id, so a hosted product cannot be silently75 re-pointed.76- **One store, one active sink.** Declaring a different sink over an existing one is refused; clear77 the declaration first. Local data and elections survive clearing.78- **The store answers before enrollment is kept.** Enrollment runs a bounded metadata preflight79 against the node: a store with no schema deployed for this app, or a rejected ticket, rolls the80 declaration back and throws `SyncEnrollmentError` — the device stays exactly as it was. A store81 that is merely unreachable enrolls anyway, with the warning recorded as `storeStatus` in runtime82 diagnostics, because a flaky network must not block a legitimate ticket.83- **The election is pinned to the account that made it.** Electing sync records the electing84 account's fingerprint; a boot that finds a different account in hand opens local-only with85 transport suppressed, and the session reports `syncOwnerMismatch` rather than letting a second86 identity write into the owner's store. Electing under the mismatch throws `SyncOwnerError`.87 Stopping sync releases the pin; an explicit restore ceremony adopts it for the restored account.88- **What the server can see is documented, not implied.** The [threat model](threat-model.md) states89 the server's view, the user's holdings, and the ticket/XSS custody story in one place.90- **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path91 is what authorizes transport) and never exposed through the session snapshot — `session.sink`92 carries only the source, host, and label. At rest the declaration persists only as a sealed93 envelope under a device-bound key, so nothing bearer-shaped sits in storage in cleartext. That94 protects the record against disk images, backups, and storage exfiltration; it does not protect it95 from script running on the same origin, which can drive the silent open itself.96- **Tickets carry a scope.** A plain (or `scope: "sync"`) ticket is transport only; a97 `scope: "provision"` ticket additionally administers the store through the node's gate — the basis98 for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather99 than silently granted less than it claims.100- **A provision ticket is split before anything persists.** Enrollment asks the node's scope-down101 exchange for a derived sync ticket; that becomes the declared sink, and the provision capability102 is only _held_ in memory. Sealing it at rest is a separate, explicit passkey ceremony103 (`sealProvisionCapability` / `unlockProvisionCapability`), attempted rather than104 capability-detected; on a device whose authenticator cannot evaluate the WebAuthn PRF extension,105 nothing persists and the durable copy is the ticket in the user's password manager. The packaged106 `TicketEnrollForm` (`@nzip/lofi/preact`) is shaped for exactly that custody — the ticket is a107 `current-password` field a manager saves on first paste and autofills later. Against a node108 without the exchange, the ticket enrolls as pasted, exactly as before.109- **The derived ticket binds to a device key when the node supports it.** The scope-down exchange is110 offered the public half of a non-extractable device keypair; a node that accepts records the111 binding, and every boot thereafter proves possession — a fresh challenge signature — before112 connecting through a short-lived connect token. A ticket string lifted from the device then no113 longer connects from anywhere else. A failed proof (revocation, a reset node, a wiped device key)114 behaves like a revoked ticket; losing the device key is deliberately unrecoverable, and115 re-enrollment is the recovery.116- If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored117 sink as dead and surface re-enrollment rather than retrying silently.118119### Restoring on a fresh device120121Recovery has two halves, held in two places: the passkey or phrase recovers _who you are_ (the122account identity), and the app-connect ticket names _where your data lives_. A passkey backup stores123exactly the 32-byte account secret, so on a deployment without a compiled managed app a fresh device124restores to a local-only account and then connects the sync location — paste the ticket saved in125your password manager, or have the node re-issue one. The starter account UI offers both steps in126either order; synced data arrives once the location is enrolled. Deployments with a compiled managed127app restore straight to syncing.128129## The user's account journey130131```mermaid132stateDiagram-v2133 [*] --> LocalOnly: First boot134 state "Local-only<br/>Private on-device account<br/>Durable local data" as LocalOnly135 LocalOnly --> Syncing: User creates recoverable passkey and saves phrase136 state "Syncing<br/>Same account identity<br/>Local and global durability" as Syncing137 Syncing --> LocalOnly: Stop syncing138 Syncing --> Recovered: Use passkey or enter phrase139 state "Recovered<br/>Same identity restored<br/>Synced data downloads" as Recovered140 Recovered --> Syncing141```142143Data created before opt-in carries forward because enabling sync does not replace the account144secret.145146## Recovery guarantees and limits147148- The recoverable passkey stores the same account secret inside a resident, user-verifying149 credential. Restoring it replaces the Jazz client and confirms the same `session.user_id`.150- Passkey availability is provider- and RP-ID-dependent. Pin `passkey.rpId` to the canonical151 production hostname. A passkey created for a preview hostname cannot move to another RP-ID.152- iCloud Keychain, Google Password Manager, and third-party managers have different platform153 boundaries. lofi does not promise universal iOS/Android/browser/provider portability.154- The recovery phrase is the account authority. Anyone with all the words can act as that account.155- lofi does not retain material that can reconstruct the account for the user.156- Losing both the device and every copy of the phrase loses the account.157- Recovering a phrase can retrieve data that was synced. It cannot reconstruct writes that existed158 only in storage on a lost device.159- Restoring replaces the current device account, so the generated UI confirms before abandoning an160 unsynced local identity.161- Older lofi guard-only credentials protect phrase reveal on one device; they do not contain the162 Jazz secret and are not recoverable account backups.163164Read [Identity and recovery model](auth-identity.md) for the detailed state machine and custody165model.166167## Before shipping sync168169- Customize the account copy without weakening the recovery warnings.170- Confirm the production build receives the intended public configuration.171- Run `deno task build` and verify the secret scan passes.172- Test recovery using throwaway data and a second browser profile or device.173- Run the two-client convergence example described in [Testing](testing.md).174- Decide how users will store their phrase safely; there is no server-assisted reset flow.