# lofi-node documentation > Deployed at https://lofi.host/node. Source: https://github.com/FelineStateMachine/lofi-node --- # Your data, on your node You use lofi apps; lofi-node is how you decide where their data lives. This is not something an app's developer sets up for you, and it is not a way to host the app itself — the app stays wherever it is deployed. The node is yours: [lofi-node](https://github.com/FelineStateMachine/lofi-node) is one daemon that embeds a real Jazz sync server, [iroh](https://iroh.computer) node-to-node transport, and a ticket-based access gate. Browsers keep speaking Jazz's protocol; only the server URL changes, and that URL is data you hand the app at runtime, not configuration its developer compiled in. What the node gives you: - **A real Jazz sync server** — SQLite-backed, health-checked, running exactly the pinned Jazz version the apps you use run. Any lofi app you use can sync into it. - **Tickets, not accounts.** A `lofisync1.` app-connect ticket carries location and access in one string; possession is transport access, revocation is one command. A provision-scoped ticket additionally administers the store, with the admin secret never leaving the node. - **Node-to-node replication over iroh.** Two homes converge by pairing tickets: dialed by public key, hole-punched, no static IPs, no cloud dependency — down to [the relay itself, which you can run](beyond-the-lan.md). - **No silent degradation.** If the native transport layer can't load, the Jazz server still runs LAN-only and `status()` says exactly why pairing is off. ## Where to go - Never run a node before? Start with [Self-host your first sync node](self-host-a-sync-node.md). - Two locations that should hold the same data: [Pair two homes](pair-two-homes.md). - Setting up the store an app syncs into — including a second app joining a store that already holds data: [Provision a store](provision-a-store.md). - The mental model behind all of it — slices, shared stores, and why apps can't clobber each other: [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md). - What the store guarantees when it refuses a write, and how apps observe it: [Write verdicts](write-verdicts.md). The app-side enrollment flow (pasting a ticket into a lofi app) is part of the framework docs: [Sync and recovery](/docs/sync-and-recovery). --- # Self-host your first sync node By the end of this tutorial you will have a sync node running on your own machine, an app-connect ticket in hand, and a lofi app you use syncing against it. You are the app's user here, not its developer: the app stays exactly as it was deployed, and pointing it at your node is entirely your call. Nothing below requires a cloud account, a static IP, or TLS on a trusted LAN — one binary and one paste. ## 1. Install lofi-node Install the tool from [JSR](https://jsr.io/@nzip/lofi-node), which puts `lofi-node` on your PATH: ```sh deno install -g -A -n lofi-node jsr:@nzip/lofi-node/cli ``` For a single self-contained binary instead, compile from a checkout of [lofi-node](https://github.com/FelineStateMachine/lofi-node): ```sh deno task compile # → dist/lofi-node ``` Either way, full support covers macOS arm64 and Linux x86_64 (including the [container image](beyond-the-lan.md)); on Windows and arm64 Linux the node is limited ([why](troubleshooting.md)). ## 2. Initialize and start ```sh lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:4802 lofi-node start --dir ./data ``` `init` writes `data/config.json`: a generated Jazz app id, the node's secrets, your access mode, and storage choice. New inits are **ticket-gated** — only requests carrying an issued app-ticket secret reach the store; `--open` opts out for dev setups. The embedded Jazz server binds loopback-only; the node's access gate owns the public port, so the gate is both enforcement and reachability. `--public-url` is the base address that will be embedded in the tickets you issue — use the address other devices on your network can reach. `--storage-path /mnt/nas/lofi` puts the store on any mounted location; `--memory` keeps everything ephemeral ([storage choices](storage-choices.md)). `start` prints the gate URL and, when the mesh is up, the node-pairing ticket for [pairing a second node](pair-two-homes.md). ## 3. Issue an app ticket ```sh lofi-node ticket issue --dir ./data --label phone # → lofisync1.eyJ2IjoxLCJhcHBJZCI6… (shown once; the secret is never stored) ``` The ticket is one string carrying the store's app id and the gate URL with an access secret embedded in its path. It is displayed exactly once — the node keeps only a digest. Issue one per device or context (`--label` is for your own bookkeeping), so revocation stays scoped: ```sh lofi-node ticket list --dir ./data lofi-node ticket revoke --dir ./data # live connections close within seconds ``` Scopes matter later: a plain ticket is transport-only; `ticket issue --provision` mints one that can also [set up the store's schema](provision-a-store.md). What the string actually contains and why the secret lives in the URL path: [Tickets explained](tickets-explained.md). ## 4. Enroll the ticket in a lofi app Paste the ticket into the app — a lofi app with sync exposes enrollment in its account UI. That declares your node as the device's sync location and elects sync in one step; the data you already made in the app pushes up under the same account identity. Nothing about the app changes or redeploys — pointing it at your node is your call, not the app developer's. (If you are the app developer: the framework side is one call, `enrollSyncTicket`, documented in [Sync and recovery](/docs/sync-and-recovery).) ## 5. Verify - `GET /health` answers without a ticket — safe for liveness probes. - `GET /store-status` answers for any valid ticket with metadata about the store's schema. A fresh node reports `{ "schema": { "deployed": false } }` — that is expected, and it is exactly the state the [store provisioning tutorial](provision-a-store.md) resolves. An app should not sync against a store with no schema; its writes would hang rather than fail. - `lofi-node status --dir ./data` shows configuration, tickets, upstream, and mesh state without starting anything. ## Where you are You have a node, a ticket, and an enrolled app — but the store may not have a schema yet. Continue with [Provision a store](provision-a-store.md), or connect a second node with [Pair two homes](pair-two-homes.md). --- # Pair two homes Two nodes in two places — a home server and a studio machine, say — can hold the same store and converge over [iroh](https://iroh.computer): dialed by public key, hole-punched through NATs, no static IPs, no port forwarding, no cloud dependency. Devices keep syncing against whichever node is closest; the nodes reconcile with each other. ## Pairing Every node with the mesh up prints a **node-pairing ticket** on start — an iroh `endpoint…` string, distinct from the `lofisync1.` app tickets users enroll. On the second node: ```sh lofi-node pair endpoint… --dir ./data ``` or at runtime, without a restart and without the public port changing: ```ts await node.pair(otherNodeTicket); ``` Pairing is an **upstream election**: the paired node becomes a leaf that forwards to the root. Sync WebSockets and catalogue HTTP (schema reads, deploys) tunnel over iroh, so administering either node reaches the root — [provisioning through a leaf](provision-a-store.md) lands on the root's store, verified end to end in lofi-node's test suite. ## What the tunnel carries One iroh connection carries exactly one WebSocket or one HTTP request. Each connection opens with a small HELLO frame naming what it carries (`ws` with path and subprotocol, or `http` with method, path, and headers); frames then bridge both ways until either side closes. This is why the browser-facing protocol never changes: the leaf re-originates exactly what Jazz expects. ## Observing the mesh ```ts node.status().mesh; // { state: "up", nodeId, ticket, connections: [{ direction, rtt, paths }] } ``` `connections` reports each live tunnel with its round-trip time and path counts — direct versus relay — so you can see whether hole-punching succeeded or traffic is riding an iroh relay. The other two states are honest degradations: `{ state: "off" }` when the mesh is disabled, and `{ state: "unavailable", reason }` when the native layer could not load — the Jazz server still runs LAN-only, and `ticket()`/`pair()` throw `MeshUnavailableError` rather than pretending. ## The relay's role Relays assist that hole-punching (address discovery) and carry traffic only for the connections that cannot go direct; in practice the large majority of iroh connections are direct. The relay is a **per-node election** (`relay` in [config.json](configuration.md)): whichever relay a node elects travels inside its pairing ticket, so the dialing side needs no matching configuration. The default is n0-computer's public relays, which are rate-limited and meant for development. For a production mesh, [bring your own](beyond-the-lan.md). ## Choosing a root The root holds the authoritative store; leaves relay to it. Put the root on the machine with the most reliable storage ([storage choices](storage-choices.md)) and pair outward from there. A leaf's election can be redone at runtime (`pair` again) if the topology changes. --- # Provision a store A fresh node has no schema deployed, and an app must never sync against a store in that state — the engine's writes hang rather than fail. Provisioning is the explicit step you opt into that creates or updates the store's schema, and this tutorial walks it end to end: first app in, then a **second app joining the store that already holds the first app's data**. Every step here is also executable — lofi-node's `tests/provisioning_test.ts` runs this exact flow through the gate in CI. ## 0. The opt-in: a provision ticket ```sh lofi-node ticket issue --dir ./data --provision --label laptop-admin ``` A provision-scoped ticket is a strict superset of a sync ticket: transport plus store administration. For provisioning requests the gate injects the node's own admin secret and strips any inbound one — the secret never transits the client, and possession of the ticket **is** the opt-in. Issue one per provisioning context, not per device. ## 1. Preflight Any valid ticket — sync scope included — may ask the node where the store stands: ``` GET /store-status → { "v": 1, "appId": "…", "schema": { "deployed": false } } ``` On the app side this is `readTicketStoreStatus(ticketUrl)` — a browser app obtains the provision URL from its capability custody (held after enrollment, or unlocked through its passkey ceremony; see [Sync and recovery](/docs/sync-and-recovery)) — and with administration in hand the richer classifier is `readStoreStatus(root, target)`, which reports one of `ok`, `no_schema`, `schema_out_of_date`, or `schema_drift`. `deployed: false` here means `no_schema`: create. ## 2. First app: create With the app's nested schema and merged permissions bundle: ```ts import { provisionStore } from "@nzip/lofi/schema/store"; import { permissions, root } from "./schema.ts"; await provisionStore({ app: root, permissions, target: { serverUrl: provisionTicketUrl, appId: ticketAppId }, }); // → { status: "created", headHash: "…" } ``` Note the absent `adminSecret` — the provision ticket's gate injection carries administration. `store-status` now reports `deployed: true` with the head hash, and enrolled devices sync normally. ## 3. Second app: merge into an occupied store Time passes; the store holds `taskapp` data. Now a notes app — its own namespaces, its own deployment — enrolls against the same store. Its preflight reports `schema_out_of_date` with exactly its missing tables, and its opt-in provisioning **merges its slice** rather than replacing anything: - The stored head schema is fetched verbatim and only extended — the first app's tables keep their exact serialization. - The union deploys as an ordinary migration advancing the store's one permissions head, with the first app's policies preserved unchanged and the second app's swapped in for its own tables only. - The joining app's own compiled schema is registered and connected to the head, so its clients are never disconnected. ```ts await provisionStore({ app: notesRoot, permissions: notesPermissions, target }); // → { status: "updated", headHash: "…" } ``` The first app's devices keep working untouched across the merge — their schema stays connected through the migration chain — and its access rules still hold: a user who couldn't read a gated `taskapp` row before the merge still can't after it. Why apps can't step on each other here is the subject of [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md). ## 4. What provisioning refuses - **Drift is never auto-repaired.** If the store's copy of an app's own namespaces differs from what the app declares, provisioning throws with the differing tables. A human decides. - **Out-of-namespace changes are hard errors** before any request is made. - **Sync-scoped tickets cannot provision**: admin routes answer the same `401 {"error":"invalid_ticket"}` as an unknown ticket — nothing to enumerate. ## Through a paired leaf Provisioning against a [paired](pair-two-homes.md) leaf lands on the root: catalogue writes ride the iroh tunnel's HTTP path. Deploy wherever is convenient; the root's store is what advances. --- # Sliceable apps and shared stores Your store is shared infrastructure. You bring the store; the apps you use are tenants in it. This guide is the mental model that makes that safe: what a slice is, how one store's schema grows app by app, and why an app cannot damage a neighbor even though the store trusts its administrator. ## A slice is a naming discipline, not a partition Jazz's pinned DSL compiles **one schema per store** — one hash, one migration lineage, one query planner. `defineSliceableApp` derives typed sub-app views over that single schema, and lofi's `s.defineNestedApp` builds the naming layer on top: an app declares namespaces, its tables flatten to global names with a reserved separator (`taskapp` + `tasks` → `taskapp__tasks`), and app code uses unprefixed typed handles as if the namespace were its whole world. Nothing at the storage layer partitions those tables — every table is a sibling of every other in the one store. The **slice** is the set of tables under an app's declared namespaces, and the namespace prefix is what makes ownership checkable. ## The store's schema grows slice by slice The store enforces exactly one schema at a time — the head of a migration lineage. When a second app joins an occupied store, its provisioning _merges_: the stored schema is fetched verbatim, the app's missing tables are appended, and the union deploys as an ordinary migration advancing the head. Devices running the previous schema keep working, because their schema stays connected to the head through the migration chain. Four states describe any store relative to any app — the same taxonomy the tooling reports: | State | Meaning | | -------------------- | ------------------------------------------------------------------- | | `ok` | The enforced schema carries this app's slice exactly. | | `no_schema` | Nothing is deployed. Writes would hang; never sync in this state. | | `schema_out_of_date` | The store lacks some of this app's tables. Remedy: opt-in update. | | `schema_drift` | The store's copy of the app's own namespaces differs unexplainably. | `no_schema` deserves its emphasis: against an empty store the engine's writes hang rather than fail. That is why the node exposes a metadata-only `store-status` preflight any valid ticket can call, and why lofi classifies before attaching sync instead of writing and hoping. `schema_drift` is the opposite discipline — it is surfaced with the differing tables and **never** auto-repaired; a store whose copy of your namespace changed under you needs a human, not a merge. ## The honesty invariant An app's provisioning may create tables **only under its own declared namespaces**. Everything else in the store — sibling apps' tables _and their access policies_ — carries through a merge byte-for-byte. Any generated change naming a table outside the app's namespaces is a hard error before a single request is made. A flat (non-nested) app declares no namespace and therefore gets no tenant rights: it may only provision a store it wholly owns. Two properties fall out, both verified against a real server in lofi's conformance suite: - **Continuity.** The first app's devices write and read across a second app's merge untouched. - **Policy preservation.** A user who couldn't read a gated row of the first app before the merge still can't after it — the joining app re-publishes the union permissions with the sibling's policies recovered from the store, unchanged. Stated honestly: this is a _framework_ boundary, not cryptography. Store administration (the admin secret, or a provision-scoped ticket) can technically do anything; the invariant keeps apps built on lofi honest through the surface they actually use. The node-side complement is that a provision ticket never exposes the admin secret itself — the gate injects it — so holding provisioning power never means holding a credential you could leak. ## Where each piece lives - Declaring namespaces and per-namespace permissions: [nested app namespaces](/docs/examples/nested-namespaces) in the framework docs. - The classifier and merge flow an app runs: [store provisioning](/docs/store-provisioning). - Doing it against a node with a provision ticket: [Provision a store](provision-a-store.md). - The ticket semantics underneath: [Tickets explained](tickets-explained.md) and the [app-ticket contract](/node/docs/app-ticket). --- # Tickets explained lofi-node uses two kinds of ticket, and confusing them is the most common early stumble. This guide is the conceptual tour; the [app-ticket contract](/node/docs/app-ticket) is the normative spec the lofi side implements against. ## Two ticket kinds - A **node-pairing ticket** (`endpoint…`) is an iroh endpoint address. It names a _node_ so another node can dial it. It is used by `lofi-node pair` and never by browsers or apps. - An **app-connect ticket** (`lofisync1.…`) is the credential _you_ hand to a lofi app so it syncs against your node. It carries the store's app id and the gate URL with a 256-bit secret embedded in its path. ## Why the secret lives in the URL path A header was the obvious alternative and it does not survive contact with the platform: the browser's WebSocket API cannot set request headers, so a header-borne credential would need a different authentication path for exactly the connection that matters most. Jazz clients preserve a base path in their server URL and reject query parameters. By making the ticket's URL `http(s)://host:port/t/`, every request the client ever makes — sync WebSocket connects, catalogue reads, admin calls — carries the secret with **zero client changes**. The cost of the choice is that the secret rides in a URL, which is why proxy access logs need [redaction](beyond-the-lan.md) and why the gate compares digests rather than logging paths. The gate validates the secret (timing-safe, digest against digest), strips the prefix, and proxies to the loopback-only Jazz server. The lofi app uses the ticket URL verbatim as its sync server; that is the whole integration. ## Scopes - **`sync`** (the default, and what an absent scope means — every pre-scope ticket keeps meaning transport-only): sync, catalogue reads that transport needs, and the metadata-only `store-status` preflight. Admin routes answer the same `401 {"error":"invalid_ticket"}` as an unknown secret, so a probe learns nothing. - **`provision`**: a strict superset — everything above plus store administration. The gate injects the node's own `X-Jazz-Admin-Secret` for provisioning requests and strips any inbound one, so the admin secret never transits the client. Possession of a provision ticket **is** the store-administration opt-in; issue one per provisioning context, not per device. A ticket with an unrecognized scope is rejected outright by the lofi parser — never silently granted less than it claims. ## Revocation - The node stores only SHA-256 digests; the ticket string is displayable once, at issuance. - Unknown and revoked secrets are indistinguishable to probers: both get `401 {"error":"invalid_ticket"}`, on HTTP and on the WebSocket upgrade. - Revoking a ticket mid-session closes its live WebSockets with close code **4001** within a couple of seconds. The app treats the stored sink as dead and surfaces re-enrollment — it never silently retries forever. ## The security posture, honestly An app ticket is a **bearer credential** with 256-bit entropy. Anyone holding it can sync as an authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on top, enforced by the deployed policy, not by the ticket. Plain http is acceptable on a trusted LAN; beyond one, front the gate with TLS — installed PWAs generally require a secure origin anyway. Issue tickets per device or context so revocation is scoped. ## What the app does with it You paste; the app does the rest. The ticket is validated (against the same rules this node enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local record (encrypted at rest under a device-bound key) and never surfaces it through its session snapshot — only the host and your label show. A provision ticket gets split first: the app asks the node for a derived sync ticket to store, and the provision original is either sealed behind your passkey or kept only in memory with your password manager holding the durable copy. The derived ticket is linked to its parent — revoking the provision ticket revokes it too. For app developers, the framework call behind that paste is `enrollSyncTicket`, documented in [Sync and recovery](/docs/sync-and-recovery). --- # Storage choices The node's store is where your synced data durably lives, and the choice is deliberately narrow because the underlying engine's honest surface is narrow: jazz-napi supports exactly a SQLite directory or memory today. The `storage.type` discriminator in `config.json` is the seam future providers slot into. ## SQLite on a path you choose ```sh lofi-node init --storage-path /mnt/nas/lofi ``` ```jsonc // config.json "storage": { "type": "sqlite", "path": "/mnt/nas/lofi" } ``` The path can be any mounted location — a NAS volume, a synced directory, an external disk. It is probed writable at boot; an unwritable location fails fast by name instead of silently degrading. Omitting the path keeps the store inside the data directory (`/jazz`). ## Memory ```sh lofi-node init --memory ``` Everything is ephemeral — useful for tests, demos, and throwaway topologies. A restart is a fresh, schema-less store: enrolled apps will classify it `no_schema` again and prompt for [provisioning](provision-a-store.md). ## Off-site durability For cloud-grade durability without a cloud sync service, replicate the SQLite file: a Litestream-style continuous replica to S3-compatible storage, or snapshots of the volume the path lives on. [Pairing a second node](pair-two-homes.md) is the other half of the story — replication at the Jazz layer rather than the file layer — and the two compose. One honest caveat: back up the **data directory** alongside the store. `config.json` holds the node's secrets and app id, and `tickets.json` holds the digests that make issued tickets valid; a restored store without them is reachable by nobody. --- # Deploying beyond the LAN A node on a trusted LAN needs nothing beyond the tutorial. Leaving the LAN — a VPS, a reachable home server, tickets that travel — adds four concerns: the address you pin into tickets, TLS, the relay your mesh rides, and the binary you actually run. ## Pin the public URL ```sh lofi-node init --public-url https://sync.example.net ``` `publicUrl` is the base embedded in every issued ticket. It must be the address enrolled devices will reach — not `localhost`, not an internal IP that stops resolving off-network. It can be changed per-issuance (`ticket issue --url …`), but pinning it at init keeps tickets consistent. ## Front the gate with TLS The gate speaks plain HTTP; beyond a trusted LAN, put TLS in front of it (a reverse proxy such as Caddy or nginx terminating `https://` and forwarding to the gate's port) and issue tickets with the `https://` public URL. Two reasons, one hard and one practical: the ticket URL is a bearer credential and should not transit networks unencrypted; and installed PWAs generally require a secure origin, so an app enrolled over plain http may work in a tab and fail as an installed app. Note what TLS does **not** change: the ticket still gates access, revocation still works the same, and the WebSocket upgrade still rides the same URL — the browser client derives its endpoints from the http(s) base. ## Redact ticket secrets from proxy logs Every gated request carries `/t/` in its path, and a reverse proxy's default access log writes request paths — so a stock logging config quietly collects bearer credentials on disk. Redact the path before it is logged (or disable access logging for the gate's host). Caddy, with its log field filter: ```caddyfile node.example.com { reverse_proxy 127.0.0.1:4802 log { format filter { wrap console fields { request>uri regexp /t/[A-Za-z0-9_-]{43} /t/ } } } } ``` nginx, by logging a scrubbed copy of the URI: ```nginx map $request_uri $redacted_uri { "~^(?
.*/t/)[A-Za-z0-9_-]{43}(?.*)$" "${pre}${post}";
    default $request_uri;
}
log_format redacted '$remote_addr - [$time_local] '
                    '"$request_method $redacted_uri $server_protocol" '
                    '$status $body_bytes_sent';
access_log /var/log/nginx/access.log redacted;
```

The same concern applies to anything else that records URLs on the request path — tracing
middleware, error reporters, CDN logs. The node itself compares digests and logs ticket ids, never
secrets; keep the perimeter to the same standard. If a secret does land in a log, revoke that ticket
(`lofi-node ticket revoke `) — reissuing is one command and live sockets close within seconds.

## Bring your own relay

Paired nodes hole-punch direct connections wherever possible; an
[iroh relay](https://docs.iroh.computer/concepts/relays) assists that hole-punching and carries the
traffic that cannot go direct. Out of the box a node uses n0-computer's **public relays**: shared by
every iroh developer, rate-limited, no uptime guarantees. That default exists so the tutorial works
in thirty seconds; n0 blesses it for development and testing, and a production mesh should not lean
on it.

```sh
lofi-node init --relay https://relay.example.net
```

The relay is part of what you self-host, and it fits the machine you already have: `iroh-relay` is a
single open-source binary with built-in Let's Encrypt support, cheap to run because it only carries
the non-direct residue of your own mesh's traffic. The publicly reachable machine hosting your root
node is its natural home. Each node elects its **own** relay (`relay` in
[config.json](configuration.md)), and the election travels inside that node's pairing tickets, so
nothing has to be coordinated across the mesh — no lofi-run infrastructure, no shared choke point.
Your mesh's availability depends only on machines you operate.

`--relay` accepts several URLs (comma-separated or repeated) for failover. `--no-relay` opts out
entirely; defensible for nodes that are all mutually reachable (static IPs, a VPN), but it also
forgoes relay-assisted address discovery, so expect hole-punching to suffer across NATs.

## One binary

```sh
deno task compile   # → dist/lofi-node
```

The compiled binary embeds the prebuilt native transport layer and extracts it to a version-keyed OS
cache on first run. Artifact digests are pinned in the source, so an extraction that doesn't match
its pin fails loudly. Release binaries cover macOS arm64 and Linux x86_64.

**Windows** is a documented gap: the native layer's build path needs a `libnode.dll` import library
the upstream toolchain doesn't ship for this configuration. On Windows the node runs LAN-only — the
Jazz server works, `status().mesh` reports `{ state: "unavailable", reason: … }`, and
`ticket()`/`pair()` throw typed errors rather than degrade silently.

**Linux aarch64** is a second documented gap: the Jazz engine (jazz-napi) publishes no
linux-arm64-gnu build, so neither a compiled binary nor a native container image can boot on arm64
Linux. The iroh transport layer is built for the platform already; the target returns when the
upstream package appears.

## Run it in Docker

The container image wraps the same compiled binary in a minimal glibc base:
`ghcr.io/felinestatemachine/lofi-node`, published per release, linux/amd64 (per the platform note
above; Docker Desktop and qemu run it on arm64 hosts under emulation).

```sh
docker run --rm -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node \
  init --dir /data --port 4802 --public-url https://sync.example.net
docker run -d --name lofi-node --network host \
  -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node
```

Everything durable lives in the `/data` volume: `config.json`, the node identity key,
`tickets.json`, and the SQLite store. CLI verbs run through `docker exec` against the same directory
and take effect without a restart, exactly as outside a container:

```sh
docker exec lofi-node lofi-node ticket issue --dir /data --label phone
```

Two container-specific choices matter:

- **Networking.** Host networking keeps Docker's bridge NAT out of iroh's hole-punching path. A
  bridge network with a published port (`-p 4802:4802`) serves apps through the gate without issue,
  but paired nodes behind it are more likely to fall back to relayed traffic
  ([the relay's role](pair-two-homes.md)). Host networking is a Linux feature; Docker Desktop
  supports it from version 4.34.
- **The health check** probes `http://127.0.0.1:4802/health`. A node initialized with a different
  `--port` needs the check overridden in compose.

The image runs as uid 1000. Named volumes inherit that ownership; a bind mount must be writable by
uid 1000 (`chown 1000:1000 `) or the storage probe fails at boot, naming the path. The
repository's [compose.yaml](https://github.com/FelineStateMachine/lofi-node/blob/main/compose.yaml)
is the reference deployment.

## The version invariant

A node pins the **exact** Jazz alpha of the apps it serves; version bumps are coordinated changes,
never drive-by. Before pointing the apps you rely on at your node, confirm the pins match — a
mismatched pair fails in protocol-level ways no amount of network configuration fixes. See
[troubleshooting](troubleshooting.md) for what the mismatches look like.

---



# CLI



```
lofi-node — self-hostable sync node for lofi apps

Usage:
  lofi-node init   [--dir ] [--app-id ] [--port ]
                   [--public-url ] [--open] [--storage-path ] [--memory]
                   [--relay ] [--no-relay]
  lofi-node start  [--dir ]
  lofi-node pair    [--dir ]
  lofi-node ticket issue  [--label ] [--url ] [--provision] [--dir ]
  lofi-node ticket list   [--dir ]
  lofi-node ticket revoke  [--dir ]
  lofi-node status [--dir ]
```

The data directory defaults to `./lofi-node-data` everywhere.

## init

Creates the data directory and writes [`config.json`](configuration.md): a generated Jazz app id (or
`--app-id` to pin one), fresh backend and admin secrets, and your choices.

| Flag                    | Meaning                                                                                                   |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `--port `            | Fixed public port for the gate; auto-allocated when omitted.                                              |
| `--public-url `   | Base URL embedded in issued tickets (use the reachable address).                                          |
| `--open`                | Opt out of ticket gating — anyone who can reach the port can sync. CLI inits default to **ticket-gated**. |
| `--storage-path ` | SQLite store on any mounted location; probed writable at boot.                                            |
| `--memory`              | Ephemeral in-memory store.                                                                                |
| `--relay `  | Use your own iroh relay(s) instead of the public n0 servers ([why](beyond-the-lan.md)). Repeatable.       |
| `--no-relay`            | No relays at all: direct connections only.                                                                |

## start

Starts the daemon from an initialized directory: boots the loopback-only Jazz server, opens the
public gate, loads the native transport when available, and prints the gate URL plus the
node-pairing ticket when the mesh is up.

## pair

`lofi-node pair ` elects the peer as this node's upstream — the node becomes a leaf
relaying sync and catalogue traffic over iroh. The argument is a node-pairing `endpoint…` string
from the peer's `start` output, not an app ticket (the CLI rejects app tickets with a pointed
message). Re-electable at runtime; the public port does not change.

## ticket issue / list / revoke

`issue` mints an app-connect ticket and prints it **exactly once** — the node stores only a digest.
`--label` names it for your bookkeeping, `--url` overrides the embedded base for this ticket, and
`--provision` mints a provision-scoped ticket that can also administer the store
([scopes](tickets-explained.md)).

`list` shows every issued ticket: id, scope, label, issued time, and revocation state — including
tickets revoked through their lineage, since a [derived sync ticket](tickets-explained.md) dies with
its parent provision ticket. `revoke ` invalidates one (and everything derived from it); a
running daemon picks the change up within seconds and closes the affected live connections with
close code 4001 — no restart, no IPC.

## status

Prints configuration and live state without changing anything: access mode, storage, relay election,
issued tickets, upstream election, and mesh state (up with connection stats, off, or unavailable
with the reason).

---



# Configuration



A node's durable state is two JSON files in its data directory, plus a small set of environment
variables. Both files are owned by the node; edit `config.json` only with the daemon stopped.

## config.json (v2)

```jsonc
{
  "v": 2,
  "appId": "cfe52e44-…", // Jazz app id, generated at init (UUID — required by the engine)
  "backendSecret": "lofi_backend_…", // Jazz credential; server-side only
  "adminSecret": "lofi_admin_…", // authorizes schema/permission administration
  "listenPort": 4802, // optional fixed public port; auto-allocated when absent
  "access": "ticket", // "ticket" (CLI-init default) | "open" (dev; library default)
  "storage": { "type": "sqlite", "path": "/mnt/nas/lofi" }, // or { "type": "memory" }
  "publicUrl": "http://192.168.1.10:4802", // base embedded in issued tickets
  "upstream": "none", // "none" | { "url": "wss://…" } | { "peer": "endpoint…" }
  "relay": "n0", // "n0" (default when absent) | { "urls": ["https://…"] } | "disabled"
  "allowLocalFirstAuth": true
}
```

- **`access`** — `ticket` puts the access gate in front of everything; `open` proxies
  unauthenticated (the gate still owns the public port and the Jazz server still binds
  loopback-only).
- **`storage`** — the honest engine surface: a SQLite directory (optional path, probed writable at
  boot) or memory. See [storage choices](storage-choices.md).
- **`upstream`** — `"none"` for a root node; `{ "peer": … }` for an iroh-paired leaf
  ([pairing](pair-two-homes.md)); `{ "url": … }` for a plain HTTP(S) upstream Jazz server.
- **`relay`** — which relay servers the node's iroh endpoint uses for hole-punching assistance and
  as a fallback path when a direct connection cannot be established. `"n0"` (the default when
  absent) uses the public n0-computer relays: rate-limited, no SLA, meant for development and
  testing. `{ "urls": […] }` points at relays you run ([bring your own relay](beyond-the-lan.md));
  `"disabled"` forgoes relays entirely, including relay-assisted address discovery. The elected
  relay travels inside this node's pairing tickets, so peers need no matching configuration.
- **Secrets** stay in this file and never transit issued tickets; a provision-scoped ticket causes
  the gate to inject `adminSecret` server-side rather than revealing it.

**v1 migration**: v1 files load transparently with legacy defaults applied (`access: "open"`, SQLite
storage) and are rewritten as v2 on the next save.

## tickets.json

```jsonc
{
  "v": 1,
  "tickets": [
    {
      "id": "a1b2c3d4e5f6", // first 12 hex chars of the secret's SHA-256
      "scope": "provision", // absent means "sync"
      "label": "laptop-admin",
      "secretHash": "…", // full SHA-256 hex — the secret itself is never stored
      "createdAt": "2026-07-18T…",
      "revokedAt": "2026-07-19T…" // present only once revoked
    }
  ]
}
```

The CLI writes this file; a running daemon only reads it, picking up changes by modification time —
which is why `ticket issue` and `ticket revoke` need no IPC and no restart.

## Environment variables

| Name              | Effect                                                                |
| ----------------- | --------------------------------------------------------------------- |
| `LOFI_NODE_DEBUG` | `1` enables `[gate]`/`[tunnel]` debug logging to stderr.              |
| `LOFI_NODE_IROH`  | Explicit path to the native transport library, overriding resolution. |

---



:::info[Contract page]
Rendered from [`docs/app-ticket.md`](https://github.com/FelineStateMachine/lofi-node/blob/622044ff8522b36c8515fefb253a76e97b2b450f/docs/app-ticket.md) in the `FelineStateMachine/lofi-node` repository at the site's pinned ref — that file is the source of truth, gated by lofi-node's own CI.
:::

# App-connect tickets — the contract

This is the format and semantics the lofi app side implements against. An app ticket is the
credential a user hands to a lofi app so it syncs against their self-hosted node. It is distinct
from the **node-pairing ticket** (the `endpoint…` string used by `lofi-node pair`).

## Ticket string

```
lofisync1.
```

```jsonc
{
  "v": 1,
  "appId": "cfe52e44-7a59-4232-8dbb-bf53f27aeed6",
  "url": "http://192.168.1.10:4802/t/", // ← the app's serverUrl, verbatim
  "scope": "provision", // optional: "sync" (default when absent) | "provision"
  "label": "phone", // optional, user-facing
  "node": "endpoint…" // optional: the node's iroh EndpointTicket (forward compat)
}
```

Parsers must treat unknown OPTIONAL fields as forward-compatible additions and an absent `scope` as
`"sync"` — every pre-scope ticket keeps meaning transport-only. Machine-readable conformance
fixtures: [fixtures/app-ticket-fixtures.json](https://github.com/FelineStateMachine/lofi-node/blob/622044ff8522b36c8515fefb253a76e97b2b450f/docs/fixtures/app-ticket-fixtures.json).

- `` is 32 random bytes base64url (43 chars, alphabet `[A-Za-z0-9_-]`) — safe as a URL path
  segment with no encoding.
- The node stores only the SHA-256 digest of the secret; the ticket string is displayable **once**,
  at issuance.

## Why the secret lives in the URL path

jazz clients preserve a base path in `serverUrl` (`appScopedUrl`) and reject query params. With
`serverUrl = `, every request the client makes carries the secret with **zero client
changes**:

- sync WebSocket → `…/t//apps//ws`
- catalogue reads → `…/t//apps//schemas`, `schema/`
- admin (deploys) → `…/t//apps//admin/…` (Jazz's `X-Jazz-Admin-Secret` is still
  required on top — the ticket gates transport, the admin secret gates administration)

The node's access gate validates the secret (timing-safe, digest vs digest), strips the
`/t/` prefix, and proxies to the internal Jazz server (which binds loopback-only).

## Scopes: sync vs provision

- **`sync`** (default): transport only. Admin/catalogue-mutating routes (`…/apps//admin/…`)
  answer the SAME `401 {"error":"invalid_ticket"}` as an unknown secret — nothing to enumerate.
  Enrolling a ticket attaches transport only and never mutates the store.
- **`provision`**: a strict superset of sync — everything above PLUS store administration. For
  provision-scoped HTTP requests the gate **injects the node's `X-Jazz-Admin-Secret` itself** (on
  `/admin/*` and on catalogue reads like `/schemas` and `/schema/`, which the merge-deploy
  flow needs to fetch the stored head schema verbatim). The admin secret therefore never leaves the
  node and never transits the client: a provisioning client passes any placeholder admin secret to
  jazz-tools `deploy` — the gate strips inbound `X-Jazz-Admin-Secret` headers in ticket mode and
  substitutes its own. Possession of a provision ticket IS the store-administration opt-in. Issue
  one per provisioning context (`ticket issue --provision --label
  laptop-admin`), not per device.

Revocation semantics are identical for both scopes (401 / WS 4001).

## Scope-down exchange (derive a sync ticket)

A provision ticket can mint a **derived sync ticket** from itself, so a user who pastes a single
provision ticket ends up with two capability tiers: the app persists the derived sync ticket at rest
and keeps the provision ticket sealed (passkey-PRF) or memory-only.

```
POST /derive-sync-ticket
```

- **Auth**: the provision-scoped secret in the URL path, like every gated request. A sync-scoped or
  unknown secret gets the same `401 {"error":"invalid_ticket"}` as any unauthorized request —
  nothing to enumerate. Non-POST methods on a provision ticket get `405`.
- **Request body** (optional): `{ "label": "laptop" }`. Absent, the label defaults to
  ` (sync)` (parent id when unlabelled).
- **Response**: `200 {"v": 1, "id": "", "ticket": "lofisync1.…"}` — a complete
  sync-scoped ticket string. Its `url` uses the node's configured public base (`--public-url`),
  exactly like CLI-issued tickets; the secret is embedded once and never stored.

The derived ticket's record carries the parent's id, and **revocation cascades**: once the parent is
revoked (or its record removed), every ticket derived from it fails verification exactly like a
revoked ticket — 401 on new requests, close code 4001 for live WebSockets. Revoking a derived ticket
alone leaves its parent untouched. Derived tickets are always sync scope, so derivation cannot
escalate and effective chains are one level deep; the parent check still walks the whole lineage.
`lofi-node ticket list` shows derived tickets as `[from ]` and reports them REVOKED once
their lineage is.

## Store-status preflight

Against a store with **no deployed schema, client writes hang indefinitely** — so a sync-only client
needs a preflight it can reach without the admin secret. Any valid ticket (sync scope included) may
call:

```
GET /store-status
```

```jsonc
{
  "v": 1,
  "appId": "…",
  "schema": {
    "deployed": true, // false → { "deployed": false } only
    "headHash": "ff85ac…", // newest stored schema hash
    "permissionsHead": "0195…" // current permissions head object id, or null
  }
}
```

Metadata only — never schema contents, never policies, never secrets. The node answers it itself (it
holds the admin secret and queries its loopback Jazz). lofi's store classifier maps this to
`no_schema` / hash-comparison states instead of hanging; `502 {"error":"store_unavailable"}` means
the node's Jazz is unreachable. On open-mode (ungated) nodes this endpoint does not exist — dev
setups hold the admin secret and can query Jazz directly.

## Enrollment flow (app side)

1. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`
   mirrors the validation: prefix, `v: 1`, http(s) URL with a `/t/<43-char-secret>` path).
2. A `provision`-scoped ticket is split before anything persists: the app calls the scope-down
   exchange (above) and declares the derived sync ticket as its sink; the provision original is held
   in memory and, on PRF-capable devices, sealed behind the user's passkey — otherwise the user's
   password manager keeps the durable copy. Against a node without the exchange, the ticket enrolls
   as pasted.
3. The declared sink persists only as a sealed envelope under a device-bound key (localStorage key
   `lofi:data-sink:`; nothing bearer-shaped is stored in cleartext). Boot opens it silently —
   no ceremony — and uses `ticket.url` as the runtime `serverUrl`. Unlocking sealed provision
   capability for an admin operation is a user-verifying passkey ceremony.
4. `ticket.appId` should match the app's own id; refuse enrollment otherwise.

## Revocation semantics

- Unknown **and** revoked secrets both get `401 {"error":"invalid_ticket"}` — indistinguishable to
  probers, on HTTP and on the WS upgrade (the 401 _is_ the upgrade response).
- A ticket revoked mid-session closes its live WebSockets with close code **4001**
  (`ticket revoked`) within a couple of seconds.
- On 401/4001 the app should treat the stored ticket as dead and surface re-enrollment (do not
  silently retry forever).
- `GET /health` (no secret) is open — safe for liveness probes.

## Security notes

- The ticket is a **bearer credential** (256-bit entropy). Anyone holding it can sync as an
  authorized transport peer; identity/permissions remain Jazz's local-first layer on top.
- Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS
  (the URL scheme in the ticket may be `https`). Installed PWAs generally require a secure origin
  anyway.
- Issue one ticket per device/context (`--label phone`) so revocation is scoped.

---



# HTTP surface



The gate is the node's public face: it owns the public port, validates tickets, and proxies to the
loopback-only Jazz server. This page is the route-level reference for a **ticket-gated** node; in
`open` mode the same routes exist without the `/t/` prefix or scope checks.

## Routes

| Route                                 | Ticket      | Behavior                                                               |
| ------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| `GET /health`                         | none        | Liveness (below). `200`, or `502` when the store is unreachable.       |
| `GET /t//health`              | any scope   | Same liveness under a ticket base; `401` once the ticket is revoked.   |
| `GET /t//store-status`        | any scope   | Metadata-only schema preflight (below).                                |
| `/t//apps//ws`         | any scope   | WebSocket sync; re-originated toward Jazz with subprotocol forwarded.  |
| `/t//apps//…`          | any scope   | Catalogue reads and other app routes, prefix-stripped and proxied.     |
| `/t//apps//admin/…`    | `provision` | Store administration; the node's admin secret is injected server-side. |
| `POST /t//derive-sync-ticket` | `provision` | Mints a parent-linked, sync-scoped ticket (below).                     |

The `` is a 43-character base64url path segment; the gate compares digests in constant time,
strips the prefix, preserves the query string, and forwards.

## health

```jsonc
// 200 — the store answered
{ "status": "healthy" }
```

One liveness contract, every connection mode:

- **Open mode** — Jazz serves `/health` directly on the public URL.
- **Ticket mode** — the gate proxies it, unauthenticated at the top level (reachable before
  enrollment) and under any valid ticket base, so `serverUrl`-relative polling works unchanged. A
  revoked ticket gets `401` on its own base while the top level stays `200`: "node up, access gone"
  stays distinguishable from "node unreachable".
- **Peer mode** — the request rides the iroh tunnel to the peer's Jazz like any other HTTP.

Responses are cheap in all modes (one loopback hop at most) and never hang on a dead store: the gate
answers `502` when Jazz is unreachable. The client contract is composition, not a new primitive — an
app derives its single connection observable from WebSocket lifecycle events (connect, close, auth
failure) plus periodic `/health` polls, and uses it to label pending writes `offline` rather than
leaving them silently waiting. Verdict semantics for those pending writes are on the
[write verdicts](write-verdicts.md) page.

## store-status

```jsonc
// 200
{
  "v": 1,
  "appId": "…",
  "schema": {
    "deployed": true, // false → the object carries only { "deployed": false }
    "headHash": "ff85ac…", // newest stored schema hash (ordered by publishedAt)
    "permissionsHead": "0195…" // current permissions head object id, or null
  }
}

// 502 — the node is up but its store is not
{ "error": "store_unavailable" }
```

Metadata only — never schema contents, policies, or secrets. This endpoint exists so a sync-scoped
client can classify a store (most importantly `no_schema`, where writes would hang) without any
admin capability.

## derive-sync-ticket

```jsonc
// POST /t//derive-sync-ticket
// optional body: { "label": "phone (sync)" }

// 200
{ "v": 1, "id": "", "ticket": "lofisync1.…" }
```

The scope-down exchange: a provision-scoped secret mints a complete sync-scoped ticket linked to its
parent, so an app that enrolls one pasted provision ticket can persist transport-only credential
material and custody the provision original separately. The label defaults to
` (sync)`. Sync-scoped and unknown secrets get the standard `401` (nothing to
enumerate); non-POST methods get `405`. Revoking the parent revokes every ticket derived from it —
live derived sockets close with `4001` like any revocation.

## Errors and close codes

| Signal                              | Meaning                                                                                                                                                                                                 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 {"error":"invalid_ticket"}`    | Unknown ticket, revoked ticket (directly or through its revoked parent), or a sync-scoped ticket on an admin route — deliberately indistinguishable. On WebSocket, the 401 **is** the upgrade response. |
| WS close `4001` (`ticket revoked`)  | The ticket was revoked mid-session; live sockets close within a couple of seconds. The app treats the sink as dead and surfaces re-enrollment.                                                          |
| WS close `1011`                     | Upstream dial or pump failure inside the gate.                                                                                                                                                          |
| `502 {"error":"store_unavailable"}` | Gate up, Jazz (or the tunnel to the root) unreachable.                                                                                                                                                  |

## Header handling

Inbound `X-Jazz-Admin-Secret` headers are **stripped unconditionally** in ticket mode; for
provision-scoped requests on admin routes the gate injects its own. Hop-by-hop headers
(`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `host`, `content-length`) are managed
by the gate in both directions; everything else passes through.

---



# Write verdicts



A lofi app treats every mutation as a durable write that eventually settles: accepted into the
store, or rejected by it. Rejection is what compensation logic (refunds, releasing holds) hangs on,
so its semantics are a contract of the node, not an implementation detail. This page states that
contract as it holds at the pinned Jazz alpha, verified by the lofi-node test suite.

## Where verdicts come from

Every write becomes a batch with a client-generated batch id. The store adjudicates the batch and
the client runtime records the outcome as the batch's fate. Two client surfaces carry a rejection:

- **Awaited writes.** `write.wait({ tier })` rejects with a typed error carrying the originating
  `batchId`, a machine-readable `code`, and a human-readable `reason`.
- **Un-awaited writes.** A fallback `onMutationError` event fires with the same `code`/`reason` plus
  the full batch record (`batch.batchId`, `batch.latestSettlement`), for writes whose awaiter no
  longer exists.

Both surfaces correlate to the originating write by batch id. The `reason` string is diagnostics
only; its wording changes between Jazz versions and must never be matched on.

## Local refusal is not a verdict

The client runtime enforces the policy it currently knows synchronously, at the call site: a write
that the local policy denies throws immediately and no batch ever exists. The store only adjudicates
writes made under a **stale** policy — accepted locally, then denied by a store whose permissions
tightened after the client last synced them. Author-facing effect handlers therefore see two
distinct failure shapes: a synchronous throw (a programming or policy error against known state) and
an asynchronous `rejected` verdict (the store overruled a write it had never seen).

## Rejection properties

Three properties hold, and the node's harness keeps them pinned:

1. **Correlation.** A rejected write surfaces its machine-readable code with the originating write's
   batch id, on both verdict surfaces.
2. **Re-derivability.** A write unacknowledged at disconnect is re-proposed on reconnect, and the
   rejection fires then. A disconnect cannot swallow a verdict; it only defers it.
3. **Determinism.** Waiting again on a rejected batch re-derives the same verdict, across further
   reconnects. An independent device issuing the same write against the same store state derives the
   same code.

One boundary at the pinned alpha: re-derivability holds for direct writes (plain inserts, updates,
deletes). A **transactional batch committed while offline** does not re-submit its seal on
reconnect, so the store never adjudicates it — the write stays unapplied and waiting on it fails
with a runtime error rather than a verdict. Transactions committed while connected settle normally.

## At-least-once, exactly-once: the consumer's duty

Delivery is at-least-once from the store's point of view (unacked batches are re-proposed on every
reconnect) but the fallback event fires **once per runtime lifetime** — a later reconnect does not
re-fire events for batches already settled. A write journal must therefore:

- persist the verdict when it first arrives, keyed by batch id;
- treat any duplicate delivery (for example after an app restart) as idempotent by batch id;
- re-derive a verdict on demand by waiting on the batch, not by expecting the event again.

Compensation handlers keyed by batch id run at most once even when the verdict is observed twice.

## The taxonomy: permanent or transient

A consumer classifies every mutation-error code into one of two actions, using the classification
the package exports (`classifyMutationError`, `MUTATION_ERROR_CLASSES`):

| Class       | Meaning                                              | Consumer action                             |
| ----------- | ---------------------------------------------------- | ------------------------------------------- |
| `permanent` | The store adjudicated the write and never accepts it | Verdict is `rejected`; compensation may run |
| `transient` | No adjudication happened (unreachable, timeout)      | Write stays pending; keep waiting           |

The pinned alpha's store can emit four rejection codes over the sync protocol:

| Code                       | Store meaning                                                                                                                                                                                                                                                                   | Class                        |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| `permission_denied`        | The store adjudicated the write and refused it: policy denial under a stale client policy, content that fails the store's column validation, or schema-resolution failure. The `reason` distinguishes them; the class does not.                                                 | `permanent`                  |
| `transaction_conflict`     | A transactional batch staged against a row whose visible frontier moved before the store validated the seal. The store never rebases or retries the losing batch; the row converges to the winner.                                                                              | `permanent`                  |
| `permissions_head_missing` | The store enforces permissions but none were ever published for the app (schema deployed without a permissions head). Publishing permissions later does not revisit the fate; only new batches see the new head.                                                                | `permanent`                  |
| `invalid_batch_submission` | The sealed submission itself is malformed. Not producible through the public API: every trigger is an envelope invariant the client runtime computes itself, and the one author-reachable shape (committing an empty transaction) throws synchronously before any batch exists. | unregistered, so `transient` |

The registry also carries `expired`, classified permanent; its semantics are the
[expiry contract below](#expiry). Codes not in the registry classify as transient by design — an
unrecognized code must never trigger irreversible compensation, because waiting is recoverable and a
wrong `rejected` verdict is not. `invalid_batch_submission` stays unregistered on the same
principle: no rejection carrying it has been observed, so no classification has been demonstrated.
As the upstream code space grows, codes enter the registry here rather than in every consumer.

## Expiry

A write may declare a deadline: a wall-clock instant after which the app no longer wants it
accepted. This section states the expiry contract — what a declared deadline means, how refusal
surfaces, and when a client may safely give up — independent of which component enforces it.
**Nothing enforces it at the pinned Jazz alpha**: no deadline travels on the wire today, no store
rejects a late write, and the `expired` code is never emitted. The enforcement design is in progress
at the node layer. The taxonomy entry and everything below define the behavior any enforcement
commits to, so apps written against this page need no changes when it arrives.

### The deadline is declared outside the payload, and client-asserted

The deadline is asserted by the writing client as a wall-clock time and travels apart from the
write's row data, so that enforcement never depends on interpreting application data: whichever
component enforces expiry reads only the declared deadline and its own arrival clock, never what the
write does. That keeps the contract intact for columns an app seals client-side, and keeps
enforcement implementable by a component that does not evaluate application schemas at all.

### The enforcement rule and the skew tolerance

The enforcing component compares its own arrival time against the declared deadline and refuses the
write when

```
arrival > expiresAt + tolerance
```

The refusal surfaces as a rejected batch fate with `code: "expired"`, through the same settlement
path as `permission_denied`, with the same three properties: correlated to the originating batch id,
re-derived on reconnect, and deterministic. `expired` classifies as **permanent**: late arrival is
final, because waiting or re-sending only arrives later still.

The tolerance is **two minutes**. Deadlines are client-asserted wall-clock times, so enforcement is
only as exact as the two clocks involved: devices with OS time sync sit within seconds of true time,
and two minutes is a wide margin above realistic drift while keeping the moment a client may safely
give up close to the intent's own deadline. A device whose clock is wrong by more than the tolerance
forfeits the guarantee for its own writes, which is consistent with the threat model below.

### What expiry is for — and what it is not

The threat model is the **honest client**. A deadline protects an app from its own stale intents: a
write composed before a crash, a queue drained days later, an offer that should not surface after
the window it was meant for. It is not an integrity control. The deadline is asserted by the writer,
so a malicious client simply omits it, or writes a fresh timestamp; nothing about expiry constrains
what a writer can do. Guarantees against hostile writers come from
[permissions](provision-a-store.md), never from expiry.

### The soundness property for clients

Client-side expiry alone is unsound: a client can never prove that a pending write is not already in
flight, or was not delivered before a crash, so compensating locally risks watching the write sync
anyway. Enforcement on the store's side of the connection inverts this. A late write is accepted
until `expiresAt` plus the tolerance on the enforcing clock, so once a client — granting its own
clock the same two-minute tolerance — observes its clock pass `expiresAt` plus twice the tolerance,
future acceptance is impossible by construction: one tolerance for the acceptance window, one for
the client's own possible offset. From that instant, retiring the write locally and running
compensation is safe even for a device that never reconnects.

Until enforcement exists, none of this holds: a declared deadline is ignored, an expired-looking
pending write may still be accepted whenever the device reconnects, and clients must keep treating
such writes as pending.

## Pending is not offline

A transient-pending write needs one more input before an app can honestly say "waiting for your sync
node": whether the node is reachable at all. That signal is the
[`/health` endpoint](http-surface.md#health), available in every connection mode, composed
client-side with WebSocket lifecycle events into a single connection observable.

---



# Architecture and provenance



One daemon, three layers, each with a single job:

```mermaid
flowchart LR
    subgraph Device["User devices"]
        App["lofi app (browser)"]
    end
    subgraph Node["lofi-node daemon"]
        Gate["Access gate
public port, tickets"] Jazz["Jazz sync server
loopback-only, SQLite"] Tunnel["iroh tunnel
node-to-node"] end subgraph Peer["Paired node"] Root["Root store"] end App -->|"WS + HTTP with /t/(secret)"| Gate Gate --> Jazz Jazz --- Tunnel Tunnel -->|"QUIC, dialed by key"| Root ``` - **The gate is both enforcement and reachability.** The embedded Jazz server binds loopback-only and is verified to; the only path to it is through the gate, which validates tickets, strips and injects admin headers, and re-originates WebSockets. Access control is therefore not a mode the store honors — it is the shape of the network. - **The tunnel carries exactly what Jazz speaks.** One iroh connection per WebSocket or HTTP request, opened with a HELLO frame naming its kind, path, and headers. Sync and catalogue traffic both ride it, which is why deploying against a leaf lands on the root without either side special-casing anything. - **Pairing is upstream election.** A leaf's Jazz server chains to the root; re-election at runtime restarts Jazz on the same internal port while the gate keeps the public port stable. ## The version invariant The data plane is Jazz 2.0 **alpha**, pinned exactly. A node must run the same alpha as the apps it serves — treat version bumps as coordinated, release-lockstep changes, never drive-by. This is a stated contract of the lofi-node repo (its contributing guide calls version pins decisions, with the full suite as evidence). ## Native layer provenance The iroh transport is a vendored port of the upstream `n0-computer/iroh-ffi` napi crate at module granularity, with every local edit marked and an `UPSTREAM.md` recording provenance and the tag-bump-by-diff procedure. Prebuilt libraries for the supported platform matrix are committed in-repo, digest-pinned, and embedded into the compiled binary; at first run the right one extracts to a version-keyed OS cache. Contributors changing the native layer start from `native/iroh-js/UPSTREAM.md` and `docs/port-iroh-js.md` in the lofi-node repo. ## The library surface Everything the CLI does, an embedder can do: `createSyncNode()` returns a node whose `url`/`ticket()`/`issueTicket()`/`pair()`/`status()`/`stop()` are the same operations, and a test mesh helper stands up paired topologies for suites. The generated [API reference](/node/api) covers the full surface. --- # Troubleshooting and compatibility The node's failure modes are designed to be nameable. Work from the signal you see. ## Mesh: `unavailable` `status().mesh` reports `{ state: "unavailable", reason: … }` when the native transport could not load; the Jazz server still runs LAN-only and `ticket()`/`pair()` throw `MeshUnavailableError` rather than degrade silently. Causes, in resolution order: 1. An explicit `LOFI_NODE_IROH` path that doesn't exist. 2. Running from source without building the native layer (`cargo build --release` in `native/iroh-js`). 3. An unsupported platform — most notably **Windows**, a documented gap (the `*-gnu` build path needs a `libnode.dll` import library the upstream toolchain ships only for msvc). Windows nodes run LAN-only until that resolves. On **arm64 Linux** the limitation sits one layer deeper: the Jazz engine (jazz-napi) publishes no linux-arm64-gnu build, so the node cannot start at all there. Release binaries and the container image cover macOS arm64 and Linux x86_64 ([platform notes](beyond-the-lan.md)). The compiled binary embeds digest-pinned prebuilts; a cache extraction that doesn't match its pin fails loudly rather than loading. ## 401 `invalid_ticket` Unknown ticket, revoked ticket, or a sync-scoped ticket on an admin route — deliberately one answer for all three. If a previously working app starts seeing it, the ticket was revoked: re-enroll with a fresh one. If provisioning sees it, the ticket lacks `provision` scope. ## WebSocket closes with 4001 The ticket was revoked mid-session. This is the live-revocation path working as designed; the app should treat its stored sync location as dead and surface re-enrollment, not retry. ## 502 `store_unavailable` The gate is up but its store is not — the loopback Jazz process, or on a leaf, the tunnel to the root. Check `lofi-node status` and, for a leaf, `status().mesh.connections`. ## Writes hang; nothing errors Almost always a store with **no deployed schema** — the engine hangs rather than fails in this state. `GET /store-status` reporting `{ "deployed": false }` confirms it; the remedy is [provisioning](provision-a-store.md). lofi apps classify before attaching sync for exactly this reason. ## Version mismatch A node must run the **same Jazz alpha** as the apps it serves. Mismatches surface as protocol-level failures (schema hash and catalogue errors) that no network configuration fixes. Confirm both pins before debugging anything else. ## Compatibility status, honestly The whole flow is validated end to end against lofi's reference app with real browser clients: build, boot, offline edits, reconvergence, and the full provisioning lifecycle through the gate and through an iroh-paired leaf. One known upstream finding: lofi's opt-in browser convergence gate fails at its final stage with a catalogue error **identically against lofi-node and against lofi's own first-party dev server** — a controlled experiment placing the issue in the pinned Jazz alpha, not the node. lofi-node's contract docs carry the current validation table. --- # lofi-node API reference Generated from the JSDoc of [`@nzip/lofi-node`](https://jsr.io/@nzip/lofi-node) v0.3.0. Self-hostable sync node for lofi apps: an embedded Jazz sync server with iroh peer-to-peer transport and ticket-gated access. Pair homes by ticket without static IPs or a cloud, issue revocable app tickets, and choose where your data lives. | Export | Summary | | --- | --- | | [`@nzip/lofi-node`](./node.md) | Self-hostable sync node for lofi apps: one daemon embedding a Jazz 2.0 sync server, iroh node-to-node transport, and a ticket-gated access layer. | | [`@nzip/lofi-node/testing`](./testing.md) | In-process test helpers for lofi-node consumers. | --- # Sync node ```ts import * as mod from "jsr:@nzip/lofi-node"; ``` Self-hostable sync node for lofi apps: one daemon embedding a Jazz 2.0 sync server, iroh node-to-node transport, and a ticket-gated access layer. Create a node, issue an app-connect ticket, and point a lofi app's `JAZZ_SERVER_URL` at the ticket URL: ```ts import { createSyncNode } from "@nzip/lofi-node"; const node = await createSyncNode({ appId: crypto.randomUUID(), backendSecret: "...", adminSecret: "...", dataDir: "./data", access: "ticket", }); const { ticket } = await node.issueTicket({ label: "phone" }); // the app seals `ticket` at rest and uses its URL as serverUrl ``` The CLI lives at the `./cli` export (`dx -A jsr:@nzip/lofi-node/cli`), and in-process test helpers at `./testing`. ## classifyMutationError function ```ts function classifyMutationError(code: string): MutationErrorClass ``` Classify a mutation-error code from a rejected batch fate — the `code` on `MutationErrorEvent`, on `PersistedWriteRejectedError`, or on a `BatchFate` of kind `rejected`. Unknown codes classify as `transient`: the consumer keeps the write pending rather than compensating on a code it cannot interpret. ## CLOSE_TICKET_REVOKED const ```ts const CLOSE_TICKET_REVOKED: 4001; ``` WS close code sent when a ticket is revoked mid-session; the app should surface re-enrollment (see docs/app-ticket.md). ## createSyncNode function ```ts async function createSyncNode(options: SyncNodeOptions): Promise ``` Start a sync node: an embedded Jazz server, optional iroh mesh, and (in ticket mode) the public access gate. The returned `SyncNode` is the whole operational surface: tickets, pairing, status, stop. ## decodeAppTicket function ```ts function decodeAppTicket(text: string): AppTicket | null ``` Parse a ticket string; null on any malformed input (paste path — never throws). ## encodeAppTicket function ```ts function encodeAppTicket(ticket: AppTicket): string ``` Serialize an app ticket to its `lofisync1.` string form. ## initConfig function ```ts async function initConfig(dataDir: string, overrides: Partial>): Promise ``` Create (or return the existing) daemon config; new inits default to ticket-gated access and SQLite storage, with generated secrets. ## isPermanentMutationError function ```ts function isPermanentMutationError(code: string): boolean ``` True when `classifyMutationError` classifies `code` as permanent — the write is `rejected` and compensation may run. ## loadConfig function ```ts async function loadConfig(dataDir: string): Promise ``` Read config.json (null when absent); v1 files are mapped to v2 in memory — persisted as v2 on the next explicit `saveConfig`. ## looksLikeAppTicket function ```ts function looksLikeAppTicket(text: string): boolean ``` Cheap prefix check — lets the CLI `pair` command reject app tickets with a pointed message (an app ticket is not a node-pairing ticket). ## looksLikeTicket function ```ts function looksLikeTicket(text: string): boolean ``` Cheap sanity check for a pasted ticket: single token, plausible length. Never throws; never a guarantee — the addon's parser is authoritative. ## MeshUnavailableError class ```ts class MeshUnavailableError extends Error { constructor(reason: string); } ``` The iroh mesh layer could not be brought up (missing addon, unsupported platform, wrong build). The Jazz server itself still works without it. ## MUTATION_ERROR_CLASSES const ```ts const MUTATION_ERROR_CLASSES: Readonly>; ``` The registry of known mutation-error codes. The pinned Jazz alpha's server can emit exactly four rejection codes through the batch-settlement path (source: `jazz-tools` crate at the alpha.53 publish commit; the code space is the set of string literals fed into `BatchFate::Rejected`, not a closed enum): - `permission_denied` — the store adjudicated the write and refused it. Despite the name this is the server's general write-denial code: policy denial under a stale client policy, content that fails the server's column validation, and schema-resolution failures all surface under it (the `reason` differs; the classification does not). Permanent. - `transaction_conflict` — a transactional batch whose staged row's visible parent frontier moved before the authority validated the seal. Observed in the harness: the fate is final and deterministic across reconnects; the engine does not rebase or retry. A retry is a new batch. Permanent. - `permissions_head_missing` — the store enforces permissions but has no published permissions head (schema deployed without permissions). Observed in the harness: a persisted, batch-correlated rejection on both verdict surfaces. Publishing permissions later does not revisit the fate. Permanent. - `invalid_batch_submission` — the sealed submission itself is malformed (empty member list, digest mismatch, rows off the declared branch, invalid row states, mixed direct/transactional rows). Every trigger is an envelope invariant the client runtime itself computes, and the one author-reachable shape (committing an empty transaction) throws synchronously at the call site — so the code is unreachable from the public API by construction. It stays out of the registry (classifying as transient) until a rejection can actually be observed: an unprovoked permanent entry could authorize compensation on a fate whose semantics were never demonstrated. Writes that violate the CURRENT locally-known policy or schema never produce a code at all — the client runtime refuses them synchronously at the call site, before any batch exists. `expired` (the store refused the write because it arrived past its declared deadline) is registered permanent: late arrival is final, since waiting or re-sending only arrives later still. No store emits it yet, so the entry is inert until one does. The pinned alpha does use the string `expired` on a different surface — the transport's unauthenticated response, for a rejected JWT — but that surface never reaches this classifier. ## saveConfig function ```ts async function saveConfig(dataDir: string, config: NodeConfig): Promise ``` Write config.json, owner-only (creates the data directory when needed). ## validateRelay function ```ts function validateRelay(relay: RelayConfig): void ``` Throws with the offending value when a relay election is malformed: custom mode needs at least one parseable http(s) relay URL. ## validateStorage function ```ts function validateStorage(storage: StorageConfig): void ``` Throws with an actionable message for storage types jazz-napi cannot back today (`sqlite` and `memory` are the supported set). ## AppTicket interface ```ts interface AppTicket { v: 1; appId: string; url: string; scope?: "sync" | "provision"; label?: string; node?: string; } ``` ## AppTicketInfo interface ```ts interface AppTicketInfo { id: string; scope: "sync" | "provision"; label?: string; parentId?: string; createdAt: string; revoked: boolean; } ``` Public view of an issued app-connect ticket (never the secret). ## MeshStatus type ```ts type MeshStatus = { state: "up"; nodeId: string; ticket: string; connections: TunnelConnStat[] } | { state: "off" } | { state: "unavailable"; reason: string }; ``` iroh mesh state: up (with pairing ticket + live tunnel stats), off, or unavailable with the precise reason (no silent degradation). ## MutationErrorClass type ```ts type MutationErrorClass = "permanent" | "transient"; ``` How a consumer must treat a mutation-error code. ## NodeConfig interface ```ts interface NodeConfig { v: 2; appId: string; backendSecret: string; adminSecret: string; listenPort?: number; access: "open" | "ticket"; storage: StorageConfig; publicUrl?: string; upstream: UpstreamConfig; relay?: RelayConfig; allowLocalFirstAuth: boolean; } ``` Persisted daemon configuration (`/config.json`, version 2 — v1 files migrate lazily on load). ## RelayConfig type ```ts type RelayConfig = "n0" | "disabled" | { urls: string[] }; ``` Which relay infrastructure the node's iroh endpoint uses. Relays assist holepunching (address discovery) and carry traffic only when a direct connection cannot be established. "n0": the public n0-computer relays — rate-limited, no SLA, blessed for development and testing. { urls }: operator-run or dedicated relay servers (iroh-relay); the chosen relay travels inside this node's pairing tickets, so peers need no matching config. "disabled": no relays at all — direct connections only, which also forgoes relay-assisted address discovery. ## StorageConfig type ```ts type StorageConfig = { type: "sqlite"; path?: string } | { type: "memory" }; ``` Where node data lives. jazz-napi supports exactly these two today; the `type` discriminator is the seam future providers slot into. `path` may be any mounted location (NAS, synced volume) — validated writable at boot. ## SyncNode interface ```ts interface SyncNode { url: string; port: number; appId: string; ticket(): string; issueTicket(options?: { label?: string; publicBase?: string; scope?: "sync" | "provision" }): Promise<{ id: string; ticket: string }>; revokeTicket(id: string): Promise; listTickets(): Promise; pair(peerTicket: string): Promise; status(): SyncNodeStatus; stop(): Promise; } ``` A running sync node — see `createSyncNode`. ## SyncNodeOptions interface ```ts interface SyncNodeOptions { appId: string; backendSecret: string; adminSecret: string; dataDir?: string; inMemory?: boolean; listen?: { port?: number }; access?: "open" | "ticket"; storage?: StorageConfig; publicUrl?: string; upstream?: UpstreamConfig; mesh?: "auto" | "off"; relay?: RelayConfig; allowLocalFirstAuth?: boolean; irohLibPath?: string; } ``` Options for `createSyncNode`. ## SyncNodeStatus interface ```ts interface SyncNodeStatus { appId: string; access: "open" | "ticket"; jazz: { url: string; port: number; storage: StorageConfig }; tickets: AppTicketInfo[]; upstream: UpstreamConfig; mesh: MeshStatus; } ``` Snapshot returned by `SyncNode.status`. ## UpstreamConfig type ```ts type UpstreamConfig = "none" | { url: string } | { peer: string }; ``` Where this node replicates to: nowhere, a direct Jazz server URL (e.g. Jazz Cloud), or a peer lofi-node named by its iroh pairing ticket. --- # Testing ```ts import * as mod from "jsr:@nzip/lofi-node/testing"; ``` In-process test helpers for lofi-node consumers. `createTestMesh` chains in-memory Jazz servers with direct loopback upstream URLs — no iroh, no native addon, no network beyond 127.0.0.1 — so app test suites can exercise multi-node sync topologies cheaply: ```ts import { createTestMesh } from "@nzip/lofi-node/testing"; const mesh = await createTestMesh({ nodes: 2 }); // point two clients at mesh.nodes[0].url / mesh.nodes[1].url … await mesh.stop(); ``` ## createTestMesh function ```ts async function createTestMesh(options: TestMeshOptions): Promise ``` Node 0 is the root; node i+1 uses node i as its direct upstream. All nodes share one appId and secrets (they model one user's fleet). ## TestMesh interface ```ts interface TestMesh { nodes: SyncNode[]; stop(): Promise; } ``` A running in-process mesh; stop() tears nodes down leaves-first. ## TestMeshOptions interface ```ts interface TestMeshOptions { nodes?: number; appId?: string; } ``` Options for `createTestMesh`.