The tutorials, guides, and reference pages for the docs site's /node section move here from the lofi repo's docs/node/, so they version with the code they document and this repo's CI gates them; the site build pulls them from its pinned lofi-node checkout like the app-ticket contract page. Folded in while moving, covering the scope-down exchange: http-surface gains the POST /t/<secret>/derive-sync-ticket route and its parent-revocation semantics, cli and tickets-explained state the revocation lineage, and provision-a-store points browser flows at the app's capability custody for the provision URL. Part of FelineStateMachine/lofi#123.
- commit
- 54b8e1b5ecd2d73f437db49a0cd78fe0b428c634
- parent
- 24706fa75783c06501f68cc8500a1c8b794b67b7
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 36 | +++++++++ |
| A | docs/ | 60 | +++++++++++++++ |
| A | docs/ | 118 | ++++++++++++++++++++++++++++++ |
| A | docs/ | 67 | +++++++++++++++++ |
| A | docs/ | 72 | ++++++++++++++++++ |
| A | docs/ | 77 | ++++++++++++++++++++ |
| A | docs/ | 63 | ++++++++++++++++ |
| A | docs/ | 91 | +++++++++++++++++++++++ |
| A | docs/ | 93 | ++++++++++++++++++++++++ |
| A | docs/ | 73 | +++++++++++++++++++ |
| A | docs/ | 45 | +++++++++++ |
| A | docs/ | 69 | ++++++++++++++++++ |
| A | docs/ | 64 | ++++++++++++++++ |
@@ -0,0 +1,36 @@
1+# Your data, on your node2+3+<!-- Source: FelineStateMachine/lofi-node README.md; site-voice page owned by lofi docs/node/. -->4+5+You use lofi apps; lofi-node is how you decide where their data lives. This is not something an6+app's developer sets up for you, and it is not a way to host the app itself — the app stays wherever7+it is deployed. The node is yours: [lofi-node](https://github.com/FelineStateMachine/lofi-node) is8+one daemon that embeds a real Jazz sync server, [iroh](https://iroh.computer) node-to-node9+transport, and a ticket-based access gate. Browsers keep speaking Jazz's protocol; only the server10+URL changes, and that URL is data you hand the app at runtime, not configuration its developer11+compiled in.12+13+What the node gives you:14+15+- **A real Jazz sync server** — SQLite-backed, health-checked, running exactly the pinned Jazz16+ version the apps you use run. Any lofi app you use can sync into it.17+- **Tickets, not accounts.** A `lofisync1.` app-connect ticket carries location and access in one18+ string; possession is transport access, revocation is one command. A provision-scoped ticket19+ additionally administers the store, with the admin secret never leaving the node.20+- **Node-to-node replication over iroh.** Two homes converge by pairing tickets: dialed by public21+ key, hole-punched, no static IPs, no cloud dependency — down to22+ [the relay itself, which you can run](beyond-the-lan.md).23+- **No silent degradation.** If the native transport layer can't load, the Jazz server still runs24+ LAN-only and `status()` says exactly why pairing is off.25+26+## Where to go27+28+- Never run a node before? Start with [Self-host your first sync node](self-host-a-sync-node.md).29+- Two locations that should hold the same data: [Pair two homes](pair-two-homes.md).30+- Setting up the store an app syncs into — including a second app joining a store that already holds31+ data: [Provision a store](provision-a-store.md).32+- The mental model behind all of it — slices, shared stores, and why apps can't clobber each other:33+ [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md).34+35+The app-side enrollment flow (pasting a ticket into a lofi app) is part of the framework docs:36+[Sync and recovery](/docs/sync-and-recovery).@@ -0,0 +1,60 @@
1+# Architecture and provenance2+3+<!-- Source: FelineStateMachine/lofi-node README.md, src/{gate,tunnel,node}.ts,4+ native/iroh-js/UPSTREAM.md, docs/port-iroh-js.md, CONTRIBUTING.md. -->5+6+One daemon, three layers, each with a single job:7+8+```mermaid9+flowchart LR10+ subgraph Device["User devices"]11+ App["lofi app (browser)"]12+ end13+ subgraph Node["lofi-node daemon"]14+ Gate["Access gate<br/>public port, tickets"]15+ Jazz["Jazz sync server<br/>loopback-only, SQLite"]16+ Tunnel["iroh tunnel<br/>node-to-node"]17+ end18+ subgraph Peer["Paired node"]19+ Root["Root store"]20+ end21+22+ App -->|"WS + HTTP with /t/(secret)"| Gate23+ Gate --> Jazz24+ Jazz --- Tunnel25+ Tunnel -->|"QUIC, dialed by key"| Root26+```27+28+- **The gate is both enforcement and reachability.** The embedded Jazz server binds loopback-only29+ and is verified to; the only path to it is through the gate, which validates tickets, strips and30+ injects admin headers, and re-originates WebSockets. Access control is therefore not a mode the31+ store honors — it is the shape of the network.32+- **The tunnel carries exactly what Jazz speaks.** One iroh connection per WebSocket or HTTP33+ request, opened with a HELLO frame naming its kind, path, and headers. Sync and catalogue traffic34+ both ride it, which is why deploying against a leaf lands on the root without either side35+ special-casing anything.36+- **Pairing is upstream election.** A leaf's Jazz server chains to the root; re-election at runtime37+ restarts Jazz on the same internal port while the gate keeps the public port stable.38+39+## The version invariant40+41+The data plane is Jazz 2.0 **alpha**, pinned exactly. A node must run the same alpha as the apps it42+serves — treat version bumps as coordinated, release-lockstep changes, never drive-by. This is a43+stated contract of the lofi-node repo (its contributing guide calls version pins decisions, with the44+full suite as evidence).45+46+## Native layer provenance47+48+The iroh transport is a vendored port of the upstream `n0-computer/iroh-ffi` napi crate at module49+granularity, with every local edit marked and an `UPSTREAM.md` recording provenance and the50+tag-bump-by-diff procedure. Prebuilt libraries for the supported platform matrix are committed51+in-repo, digest-pinned, and embedded into the compiled binary; at first run the right one extracts52+to a version-keyed OS cache. Contributors changing the native layer start from53+`native/iroh-js/UPSTREAM.md` and `docs/port-iroh-js.md` in the lofi-node repo.54+55+## The library surface56+57+Everything the CLI does, an embedder can do: `createSyncNode()` returns a node whose58+`url`/`ticket()`/`issueTicket()`/`pair()`/`status()`/`stop()` are the same operations, and a test59+mesh helper stands up paired topologies for suites. The generated [API reference](/node/api) covers60+the full surface.@@ -0,0 +1,118 @@
1+# Deploying beyond the LAN2+3+<!-- Source: FelineStateMachine/lofi-node README.md, docs/hosting-lofi-apps.md,4+ deno.json compile task, src/native/loader.ts. -->5+6+A node on a trusted LAN needs nothing beyond the tutorial. Leaving the LAN — a VPS, a reachable home7+server, tickets that travel — adds four concerns: the address you pin into tickets, TLS, the relay8+your mesh rides, and the binary you actually run.9+10+## Pin the public URL11+12+```sh13+lofi-node init --public-url https://sync.example.net14+```15+16+`publicUrl` is the base embedded in every issued ticket. It must be the address enrolled devices17+will reach — not `localhost`, not an internal IP that stops resolving off-network. It can be changed18+per-issuance (`ticket issue --url …`), but pinning it at init keeps tickets consistent.19+20+## Front the gate with TLS21+22+The gate speaks plain HTTP; beyond a trusted LAN, put TLS in front of it (a reverse proxy such as23+Caddy or nginx terminating `https://` and forwarding to the gate's port) and issue tickets with the24+`https://` public URL. Two reasons, one hard and one practical: the ticket URL is a bearer25+credential and should not transit networks unencrypted; and installed PWAs generally require a26+secure origin, so an app enrolled over plain http may work in a tab and fail as an installed app.27+28+Note what TLS does **not** change: the ticket still gates access, revocation still works the same,29+and the WebSocket upgrade still rides the same URL — the browser client derives its endpoints from30+the http(s) base.31+32+## Bring your own relay33+34+Paired nodes hole-punch direct connections wherever possible; an35+[iroh relay](https://docs.iroh.computer/concepts/relays) assists that hole-punching and carries the36+traffic that cannot go direct. Out of the box a node uses n0-computer's **public relays**: shared by37+every iroh developer, rate-limited, no uptime guarantees. That default exists so the tutorial works38+in thirty seconds; n0 blesses it for development and testing, and a production mesh should not lean39+on it.40+41+```sh42+lofi-node init --relay https://relay.example.net43+```44+45+The relay is part of what you self-host, and it fits the machine you already have: `iroh-relay` is a46+single open-source binary with built-in Let's Encrypt support, cheap to run because it only carries47+the non-direct residue of your own mesh's traffic. The publicly reachable machine hosting your root48+node is its natural home. Each node elects its **own** relay (`relay` in49+[config.json](configuration.md)), and the election travels inside that node's pairing tickets, so50+nothing has to be coordinated across the mesh — no lofi-run infrastructure, no shared choke point.51+Your mesh's availability depends only on machines you operate.52+53+`--relay` accepts several URLs (comma-separated or repeated) for failover. `--no-relay` opts out54+entirely; defensible for nodes that are all mutually reachable (static IPs, a VPN), but it also55+forgoes relay-assisted address discovery, so expect hole-punching to suffer across NATs.56+57+## One binary58+59+```sh60+deno task compile # → dist/lofi-node61+```62+63+The compiled binary embeds the prebuilt native transport layer and extracts it to a version-keyed OS64+cache on first run. Artifact digests are pinned in the source, so an extraction that doesn't match65+its pin fails loudly. Release binaries cover macOS arm64 and Linux x86_64.66+67+**Windows** is a documented gap: the native layer's build path needs a `libnode.dll` import library68+the upstream toolchain doesn't ship for this configuration. On Windows the node runs LAN-only — the69+Jazz server works, `status().mesh` reports `{ state: "unavailable", reason: … }`, and70+`ticket()`/`pair()` throw typed errors rather than degrade silently.71+72+**Linux aarch64** is a second documented gap: the Jazz engine (jazz-napi) publishes no73+linux-arm64-gnu build, so neither a compiled binary nor a native container image can boot on arm6474+Linux. The iroh transport layer is built for the platform already; the target returns when the75+upstream package appears.76+77+## Run it in Docker78+79+The container image wraps the same compiled binary in a minimal glibc base:80+`ghcr.io/felinestatemachine/lofi-node`, published per release, linux/amd64 (per the platform note81+above; Docker Desktop and qemu run it on arm64 hosts under emulation).82+83+```sh84+docker run --rm -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node \85+ init --dir /data --port 4802 --public-url https://sync.example.net86+docker run -d --name lofi-node --network host \87+ -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node88+```89+90+Everything durable lives in the `/data` volume: `config.json`, the node identity key,91+`tickets.json`, and the SQLite store. CLI verbs run through `docker exec` against the same directory92+and take effect without a restart, exactly as outside a container:93+94+```sh95+docker exec lofi-node lofi-node ticket issue --dir /data --label phone96+```97+98+Two container-specific choices matter:99+100+- **Networking.** Host networking keeps Docker's bridge NAT out of iroh's hole-punching path. A101+ bridge network with a published port (`-p 4802:4802`) serves apps through the gate without issue,102+ but paired nodes behind it are more likely to fall back to relayed traffic103+ ([the relay's role](pair-two-homes.md)). Host networking is a Linux feature; Docker Desktop104+ supports it from version 4.34.105+- **The health check** probes `http://127.0.0.1:4802/health`. A node initialized with a different106+ `--port` needs the check overridden in compose.107+108+The image runs as uid 1000. Named volumes inherit that ownership; a bind mount must be writable by109+uid 1000 (`chown 1000:1000 <dir>`) or the storage probe fails at boot, naming the path. The110+repository's [compose.yaml](https://github.com/FelineStateMachine/lofi-node/blob/main/compose.yaml)111+is the reference deployment.112+113+## The version invariant114+115+A node pins the **exact** Jazz alpha of the apps it serves; version bumps are coordinated changes,116+never drive-by. Before pointing the apps you rely on at your node, confirm the pins match — a117+mismatched pair fails in protocol-level ways no amount of network configuration fixes. See118+[troubleshooting](troubleshooting.md) for what the mismatches look like.@@ -0,0 +1,67 @@
1+# CLI2+3+<!-- Source: FelineStateMachine/lofi-node cli.ts (USAGE block and argument parsing). -->4+5+```6+lofi-node — self-hostable sync node for lofi apps7+8+Usage:9+ lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]10+ [--public-url <base>] [--open] [--storage-path <path>] [--memory]11+ [--relay <url[,url…]>] [--no-relay]12+ lofi-node start [--dir <dataDir>]13+ lofi-node pair <node-ticket> [--dir <dataDir>]14+ lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]15+ lofi-node ticket list [--dir <dataDir>]16+ lofi-node ticket revoke <id> [--dir <dataDir>]17+ lofi-node status [--dir <dataDir>]18+```19+20+The data directory defaults to `./lofi-node-data` everywhere.21+22+## init23+24+Creates the data directory and writes [`config.json`](configuration.md): a generated Jazz app id (or25+`--app-id` to pin one), fresh backend and admin secrets, and your choices.26+27+| Flag | Meaning |28+| ----------------------- | --------------------------------------------------------------------------------------------------------- |29+| `--port <n>` | Fixed public port for the gate; auto-allocated when omitted. |30+| `--public-url <base>` | Base URL embedded in issued tickets (use the reachable address). |31+| `--open` | Opt out of ticket gating — anyone who can reach the port can sync. CLI inits default to **ticket-gated**. |32+| `--storage-path <path>` | SQLite store on any mounted location; probed writable at boot. |33+| `--memory` | Ephemeral in-memory store. |34+| `--relay <url[,url…]>` | Use your own iroh relay(s) instead of the public n0 servers ([why](beyond-the-lan.md)). Repeatable. |35+| `--no-relay` | No relays at all: direct connections only. |36+37+## start38+39+Starts the daemon from an initialized directory: boots the loopback-only Jazz server, opens the40+public gate, loads the native transport when available, and prints the gate URL plus the41+node-pairing ticket when the mesh is up.42+43+## pair44+45+`lofi-node pair <node-ticket>` elects the peer as this node's upstream — the node becomes a leaf46+relaying sync and catalogue traffic over iroh. The argument is a node-pairing `endpoint…` string47+from the peer's `start` output, not an app ticket (the CLI rejects app tickets with a pointed48+message). Re-electable at runtime; the public port does not change.49+50+## ticket issue / list / revoke51+52+`issue` mints an app-connect ticket and prints it **exactly once** — the node stores only a digest.53+`--label` names it for your bookkeeping, `--url` overrides the embedded base for this ticket, and54+`--provision` mints a provision-scoped ticket that can also administer the store55+([scopes](tickets-explained.md)).56+57+`list` shows every issued ticket: id, scope, label, issued time, and revocation state — including58+tickets revoked through their lineage, since a [derived sync ticket](tickets-explained.md) dies with59+its parent provision ticket. `revoke <id>` invalidates one (and everything derived from it); a60+running daemon picks the change up within seconds and closes the affected live connections with61+close code 4001 — no restart, no IPC.62+63+## status64+65+Prints configuration and live state without changing anything: access mode, storage, relay election,66+issued tickets, upstream election, and mesh state (up with connection stats, off, or unavailable67+with the reason).@@ -0,0 +1,72 @@
1+# Configuration2+3+<!-- Source: FelineStateMachine/lofi-node src/config.ts (NodeConfig, migration) and4+ src/appticket.ts (tickets.json). -->5+6+A node's durable state is two JSON files in its data directory, plus a small set of environment7+variables. Both files are owned by the node; edit `config.json` only with the daemon stopped.8+9+## config.json (v2)10+11+```jsonc12+{13+ "v": 2,14+ "appId": "cfe52e44-…", // Jazz app id, generated at init (UUID — required by the engine)15+ "backendSecret": "lofi_backend_…", // Jazz credential; server-side only16+ "adminSecret": "lofi_admin_…", // authorizes schema/permission administration17+ "listenPort": 4802, // optional fixed public port; auto-allocated when absent18+ "access": "ticket", // "ticket" (CLI-init default) | "open" (dev; library default)19+ "storage": { "type": "sqlite", "path": "/mnt/nas/lofi" }, // or { "type": "memory" }20+ "publicUrl": "http://192.168.1.10:4802", // base embedded in issued tickets21+ "upstream": "none", // "none" | { "url": "wss://…" } | { "peer": "endpoint…" }22+ "relay": "n0", // "n0" (default when absent) | { "urls": ["https://…"] } | "disabled"23+ "allowLocalFirstAuth": true24+}25+```26+27+- **`access`** — `ticket` puts the access gate in front of everything; `open` proxies28+ unauthenticated (the gate still owns the public port and the Jazz server still binds29+ loopback-only).30+- **`storage`** — the honest engine surface: a SQLite directory (optional path, probed writable at31+ boot) or memory. See [storage choices](storage-choices.md).32+- **`upstream`** — `"none"` for a root node; `{ "peer": … }` for an iroh-paired leaf33+ ([pairing](pair-two-homes.md)); `{ "url": … }` for a plain HTTP(S) upstream Jazz server.34+- **`relay`** — which relay servers the node's iroh endpoint uses for hole-punching assistance and35+ as a fallback path when a direct connection cannot be established. `"n0"` (the default when36+ absent) uses the public n0-computer relays: rate-limited, no SLA, meant for development and37+ testing. `{ "urls": […] }` points at relays you run ([bring your own relay](beyond-the-lan.md));38+ `"disabled"` forgoes relays entirely, including relay-assisted address discovery. The elected39+ relay travels inside this node's pairing tickets, so peers need no matching configuration.40+- **Secrets** stay in this file and never transit issued tickets; a provision-scoped ticket causes41+ the gate to inject `adminSecret` server-side rather than revealing it.42+43+**v1 migration**: v1 files load transparently with legacy defaults applied (`access: "open"`, SQLite44+storage) and are rewritten as v2 on the next save.45+46+## tickets.json47+48+```jsonc49+{50+ "v": 1,51+ "tickets": [52+ {53+ "id": "a1b2c3d4e5f6", // first 12 hex chars of the secret's SHA-25654+ "scope": "provision", // absent means "sync"55+ "label": "laptop-admin",56+ "secretHash": "…", // full SHA-256 hex — the secret itself is never stored57+ "createdAt": "2026-07-18T…",58+ "revokedAt": "2026-07-19T…" // present only once revoked59+ }60+ ]61+}62+```63+64+The CLI writes this file; a running daemon only reads it, picking up changes by modification time —65+which is why `ticket issue` and `ticket revoke` need no IPC and no restart.66+67+## Environment variables68+69+| Name | Effect |70+| ----------------- | --------------------------------------------------------------------- |71+| `LOFI_NODE_DEBUG` | `1` enables `[gate]`/`[tunnel]` debug logging to stderr. |72+| `LOFI_NODE_IROH` | Explicit path to the native transport library, overriding resolution. |@@ -0,0 +1,77 @@
1+# HTTP surface2+3+<!-- Source: FelineStateMachine/lofi-node src/gate.ts; normative ticket semantics in the4+ app-ticket contract. -->5+6+The gate is the node's public face: it owns the public port, validates tickets, and proxies to the7+loopback-only Jazz server. This page is the route-level reference for a **ticket-gated** node; in8+`open` mode the same routes exist without the `/t/<secret>` prefix or scope checks.9+10+## Routes11+12+| Route | Ticket | Behavior |13+| ------------------------------------- | ----------- | ------------------------------------------------------------------------- |14+| `GET /health` | none | Liveness; proxied to Jazz. `200`, or `502` when the store is unreachable. |15+| `GET /t/<secret>/store-status` | any scope | Metadata-only schema preflight (below). |16+| `/t/<secret>/apps/<appId>/ws` | any scope | WebSocket sync; re-originated toward Jazz with subprotocol forwarded. |17+| `/t/<secret>/apps/<appId>/…` | any scope | Catalogue reads and other app routes, prefix-stripped and proxied. |18+| `/t/<secret>/apps/<appId>/admin/…` | `provision` | Store administration; the node's admin secret is injected server-side. |19+| `POST /t/<secret>/derive-sync-ticket` | `provision` | Mints a parent-linked, sync-scoped ticket (below). |20+21+The `<secret>` is a 43-character base64url path segment; the gate compares digests in constant time,22+strips the prefix, preserves the query string, and forwards.23+24+## store-status25+26+```jsonc27+// 20028+{29+ "v": 1,30+ "appId": "…",31+ "schema": {32+ "deployed": true, // false → the object carries only { "deployed": false }33+ "headHash": "ff85ac…", // newest stored schema hash (ordered by publishedAt)34+ "permissionsHead": "0195…" // current permissions head object id, or null35+ }36+}37+38+// 502 — the node is up but its store is not39+{ "error": "store_unavailable" }40+```41+42+Metadata only — never schema contents, policies, or secrets. This endpoint exists so a sync-scoped43+client can classify a store (most importantly `no_schema`, where writes would hang) without any44+admin capability.45+46+## derive-sync-ticket47+48+```jsonc49+// POST /t/<provision-secret>/derive-sync-ticket50+// optional body: { "label": "phone (sync)" }51+52+// 20053+{ "v": 1, "id": "<ticket id>", "ticket": "lofisync1.…" }54+```55+56+The scope-down exchange: a provision-scoped secret mints a complete sync-scoped ticket linked to its57+parent, so an app that enrolls one pasted provision ticket can persist transport-only credential58+material and custody the provision original separately. The label defaults to59+`<parent label> (sync)`. Sync-scoped and unknown secrets get the standard `401` (nothing to60+enumerate); non-POST methods get `405`. Revoking the parent revokes every ticket derived from it —61+live derived sockets close with `4001` like any revocation.62+63+## Errors and close codes64+65+| Signal | Meaning |66+| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |67+| `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. |68+| 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. |69+| WS close `1011` | Upstream dial or pump failure inside the gate. |70+| `502 {"error":"store_unavailable"}` | Gate up, Jazz (or the tunnel to the root) unreachable. |71+72+## Header handling73+74+Inbound `X-Jazz-Admin-Secret` headers are **stripped unconditionally** in ticket mode; for75+provision-scoped requests on admin routes the gate injects its own. Hop-by-hop headers76+(`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `host`, `content-length`) are managed77+by the gate in both directions; everything else passes through.@@ -0,0 +1,63 @@
1+# Pair two homes2+3+<!-- Source: FelineStateMachine/lofi-node README.md, src/tunnel.ts, src/node.ts (pair/status). -->4+5+Two nodes in two places — a home server and a studio machine, say — can hold the same store and6+converge over [iroh](https://iroh.computer): dialed by public key, hole-punched through NATs, no7+static IPs, no port forwarding, no cloud dependency. Devices keep syncing against whichever node is8+closest; the nodes reconcile with each other.9+10+## Pairing11+12+Every node with the mesh up prints a **node-pairing ticket** on start — an iroh `endpoint…` string,13+distinct from the `lofisync1.` app tickets users enroll. On the second node:14+15+```sh16+lofi-node pair endpoint… --dir ./data17+```18+19+or at runtime, without a restart and without the public port changing:20+21+```ts22+await node.pair(otherNodeTicket);23+```24+25+Pairing is an **upstream election**: the paired node becomes a leaf that forwards to the root. Sync26+WebSockets and catalogue HTTP (schema reads, deploys) tunnel over iroh, so administering either node27+reaches the root — [provisioning through a leaf](provision-a-store.md) lands on the root's store,28+verified end to end in lofi-node's test suite.29+30+## What the tunnel carries31+32+One iroh connection carries exactly one WebSocket or one HTTP request. Each connection opens with a33+small HELLO frame naming what it carries (`ws` with path and subprotocol, or `http` with method,34+path, and headers); frames then bridge both ways until either side closes. This is why the35+browser-facing protocol never changes: the leaf re-originates exactly what Jazz expects.36+37+## Observing the mesh38+39+```ts40+node.status().mesh;41+// { state: "up", nodeId, ticket, connections: [{ direction, rtt, paths }] }42+```43+44+`connections` reports each live tunnel with its round-trip time and path counts — direct versus45+relay — so you can see whether hole-punching succeeded or traffic is riding an iroh relay. The other46+two states are honest degradations: `{ state: "off" }` when the mesh is disabled, and47+`{ state: "unavailable", reason }` when the native layer could not load — the Jazz server still runs48+LAN-only, and `ticket()`/`pair()` throw `MeshUnavailableError` rather than pretending.49+50+## The relay's role51+52+Relays assist that hole-punching (address discovery) and carry traffic only for the connections that53+cannot go direct; in practice the large majority of iroh connections are direct. The relay is a54+**per-node election** (`relay` in [config.json](configuration.md)): whichever relay a node elects55+travels inside its pairing ticket, so the dialing side needs no matching configuration. The default56+is n0-computer's public relays, which are rate-limited and meant for development. For a production57+mesh, [bring your own](beyond-the-lan.md).58+59+## Choosing a root60+61+The root holds the authoritative store; leaves relay to it. Put the root on the machine with the62+most reliable storage ([storage choices](storage-choices.md)) and pair outward from there. A leaf's63+election can be redone at runtime (`pair` again) if the topology changes.@@ -0,0 +1,91 @@
1+# Provision a store2+3+<!-- Source: lofi docs/store-provisioning.md + FelineStateMachine/lofi-node4+ tests/provisioning_test.ts (the executable version of this tutorial). -->5+6+A fresh node has no schema deployed, and an app must never sync against a store in that state — the7+engine's writes hang rather than fail. Provisioning is the explicit step you opt into that creates8+or updates the store's schema, and this tutorial walks it end to end: first app in, then a **second9+app joining the store that already holds the first app's data**. Every step here is also executable10+— lofi-node's `tests/provisioning_test.ts` runs this exact flow through the gate in CI.11+12+## 0. The opt-in: a provision ticket13+14+```sh15+lofi-node ticket issue --dir ./data --provision --label laptop-admin16+```17+18+A provision-scoped ticket is a strict superset of a sync ticket: transport plus store19+administration. For provisioning requests the gate injects the node's own admin secret and strips20+any inbound one — the secret never transits the client, and possession of the ticket **is** the21+opt-in. Issue one per provisioning context, not per device.22+23+## 1. Preflight24+25+Any valid ticket — sync scope included — may ask the node where the store stands:26+27+```28+GET <ticket-url>/store-status29+→ { "v": 1, "appId": "…", "schema": { "deployed": false } }30+```31+32+On the app side this is `readTicketStoreStatus(ticketUrl)` — a browser app obtains the provision URL33+from its capability custody (held after enrollment, or unlocked through its passkey ceremony; see34+[Sync and recovery](/docs/sync-and-recovery)) — and with administration in hand the richer35+classifier is `readStoreStatus(root, target)`, which reports one of `ok`, `no_schema`,36+`schema_out_of_date`, or `schema_drift`. `deployed: false` here means `no_schema`: create.37+38+## 2. First app: create39+40+With the app's nested schema and merged permissions bundle:41+42+```ts43+import { provisionStore } from "@nzip/lofi/schema/store";44+import { permissions, root } from "./schema.ts";45+46+await provisionStore({47+ app: root,48+ permissions,49+ target: { serverUrl: provisionTicketUrl, appId: ticketAppId },50+});51+// → { status: "created", headHash: "…" }52+```53+54+Note the absent `adminSecret` — the provision ticket's gate injection carries administration.55+`store-status` now reports `deployed: true` with the head hash, and enrolled devices sync normally.56+57+## 3. Second app: merge into an occupied store58+59+Time passes; the store holds `taskapp` data. Now a notes app — its own namespaces, its own60+deployment — enrolls against the same store. Its preflight reports `schema_out_of_date` with exactly61+its missing tables, and its opt-in provisioning **merges its slice** rather than replacing anything:62+63+- The stored head schema is fetched verbatim and only extended — the first app's tables keep their64+ exact serialization.65+- The union deploys as an ordinary migration advancing the store's one permissions head, with the66+ first app's policies preserved unchanged and the second app's swapped in for its own tables only.67+- The joining app's own compiled schema is registered and connected to the head, so its clients are68+ never disconnected.69+70+```ts71+await provisionStore({ app: notesRoot, permissions: notesPermissions, target });72+// → { status: "updated", headHash: "…" }73+```74+75+The first app's devices keep working untouched across the merge — their schema stays connected76+through the migration chain — and its access rules still hold: a user who couldn't read a gated77+`taskapp` row before the merge still can't after it. Why apps can't step on each other here is the78+subject of [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md).79+80+## 4. What provisioning refuses81+82+- **Drift is never auto-repaired.** If the store's copy of an app's own namespaces differs from what83+ the app declares, provisioning throws with the differing tables. A human decides.84+- **Out-of-namespace changes are hard errors** before any request is made.85+- **Sync-scoped tickets cannot provision**: admin routes answer the same86+ `401 {"error":"invalid_ticket"}` as an unknown ticket — nothing to enumerate.87+88+## Through a paired leaf89+90+Provisioning against a [paired](pair-two-homes.md) leaf lands on the root: catalogue writes ride the91+iroh tunnel's HTTP path. Deploy wherever is convenient; the root's store is what advances.@@ -0,0 +1,93 @@
1+# Self-host your first sync node2+3+<!-- Source: FelineStateMachine/lofi-node docs/hosting-lofi-apps.md and cli.ts; validated flow. -->4+5+By the end of this tutorial you will have a sync node running on your own machine, an app-connect6+ticket in hand, and a lofi app you use syncing against it. You are the app's user here, not its7+developer: the app stays exactly as it was deployed, and pointing it at your node is entirely your8+call. Nothing below requires a cloud account, a static IP, or TLS on a trusted LAN — one binary and9+one paste.10+11+## 1. Install lofi-node12+13+Install the tool from [JSR](https://jsr.io/@nzip/lofi-node), which puts `lofi-node` on your PATH:14+15+```sh16+deno install -g -A -n lofi-node jsr:@nzip/lofi-node/cli17+```18+19+For a single self-contained binary instead, compile from a checkout of20+[lofi-node](https://github.com/FelineStateMachine/lofi-node):21+22+```sh23+deno task compile # → dist/lofi-node24+```25+26+Either way, full support covers macOS arm64 and Linux x86_64 (including the27+[container image](beyond-the-lan.md)); on Windows and arm64 Linux the node is limited28+([why](troubleshooting.md)).29+30+## 2. Initialize and start31+32+```sh33+lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:480234+lofi-node start --dir ./data35+```36+37+`init` writes `data/config.json`: a generated Jazz app id, the node's secrets, your access mode, and38+storage choice. New inits are **ticket-gated** — only requests carrying an issued app-ticket secret39+reach the store; `--open` opts out for dev setups. The embedded Jazz server binds loopback-only; the40+node's access gate owns the public port, so the gate is both enforcement and reachability.41+42+`--public-url` is the base address that will be embedded in the tickets you issue — use the address43+other devices on your network can reach. `--storage-path /mnt/nas/lofi` puts the store on any44+mounted location; `--memory` keeps everything ephemeral ([storage choices](storage-choices.md)).45+46+`start` prints the gate URL and, when the mesh is up, the node-pairing ticket for47+[pairing a second node](pair-two-homes.md).48+49+## 3. Issue an app ticket50+51+```sh52+lofi-node ticket issue --dir ./data --label phone53+# → lofisync1.eyJ2IjoxLCJhcHBJZCI6… (shown once; the secret is never stored)54+```55+56+The ticket is one string carrying the store's app id and the gate URL with an access secret embedded57+in its path. It is displayed exactly once — the node keeps only a digest. Issue one per device or58+context (`--label` is for your own bookkeeping), so revocation stays scoped:59+60+```sh61+lofi-node ticket list --dir ./data62+lofi-node ticket revoke <id> --dir ./data # live connections close within seconds63+```64+65+Scopes matter later: a plain ticket is transport-only; `ticket issue --provision` mints one that can66+also [set up the store's schema](provision-a-store.md). What the string actually contains and why67+the secret lives in the URL path: [Tickets explained](tickets-explained.md).68+69+## 4. Enroll the ticket in a lofi app70+71+Paste the ticket into the app — a lofi app with sync exposes enrollment in its account UI. That72+declares your node as the device's sync location and elects sync in one step; the data you already73+made in the app pushes up under the same account identity. Nothing about the app changes or74+redeploys — pointing it at your node is your call, not the app developer's.75+76+(If you are the app developer: the framework side is one call, `enrollSyncTicket`, documented in77+[Sync and recovery](/docs/sync-and-recovery).)78+79+## 5. Verify80+81+- `GET <gate-url>/health` answers without a ticket — safe for liveness probes.82+- `GET <ticket-url>/store-status` answers for any valid ticket with metadata about the store's83+ schema. A fresh node reports `{ "schema": { "deployed": false } }` — that is expected, and it is84+ exactly the state the [store provisioning tutorial](provision-a-store.md) resolves. An app should85+ not sync against a store with no schema; its writes would hang rather than fail.86+- `lofi-node status --dir ./data` shows configuration, tickets, upstream, and mesh state without87+ starting anything.88+89+## Where you are90+91+You have a node, a ticket, and an enrolled app — but the store may not have a schema yet. Continue92+with [Provision a store](provision-a-store.md), or connect a second node with93+[Pair two homes](pair-two-homes.md).@@ -0,0 +1,73 @@
1+# Sliceable apps and shared stores2+3+<!-- Source: lofi #109 (design + conformance), lofi docs/store-provisioning.md and4+ docs/examples/nested-namespaces.md, lofi-node #2; jazz-tools typed-app surface. -->5+6+Your store is shared infrastructure. You bring the store; the apps you use are tenants in it. This7+guide is the mental model that makes that safe: what a slice is, how one store's schema grows app by8+app, and why an app cannot damage a neighbor even though the store trusts its administrator.9+10+## A slice is a naming discipline, not a partition11+12+Jazz's pinned DSL compiles **one schema per store** — one hash, one migration lineage, one query13+planner. `defineSliceableApp` derives typed sub-app views over that single schema, and lofi's14+`s.defineNestedApp` builds the naming layer on top: an app declares namespaces, its tables flatten15+to global names with a reserved separator (`taskapp` + `tasks` → `taskapp__tasks`), and app code16+uses unprefixed typed handles as if the namespace were its whole world.17+18+Nothing at the storage layer partitions those tables — every table is a sibling of every other in19+the one store. The **slice** is the set of tables under an app's declared namespaces, and the20+namespace prefix is what makes ownership checkable.21+22+## The store's schema grows slice by slice23+24+The store enforces exactly one schema at a time — the head of a migration lineage. When a second app25+joins an occupied store, its provisioning _merges_: the stored schema is fetched verbatim, the app's26+missing tables are appended, and the union deploys as an ordinary migration advancing the head.27+Devices running the previous schema keep working, because their schema stays connected to the head28+through the migration chain.29+30+Four states describe any store relative to any app — the same taxonomy the tooling reports:31+32+| State | Meaning |33+| -------------------- | ------------------------------------------------------------------- |34+| `ok` | The enforced schema carries this app's slice exactly. |35+| `no_schema` | Nothing is deployed. Writes would hang; never sync in this state. |36+| `schema_out_of_date` | The store lacks some of this app's tables. Remedy: opt-in update. |37+| `schema_drift` | The store's copy of the app's own namespaces differs unexplainably. |38+39+`no_schema` deserves its emphasis: against an empty store the engine's writes hang rather than fail.40+That is why the node exposes a metadata-only `store-status` preflight any valid ticket can call, and41+why lofi classifies before attaching sync instead of writing and hoping. `schema_drift` is the42+opposite discipline — it is surfaced with the differing tables and **never** auto-repaired; a store43+whose copy of your namespace changed under you needs a human, not a merge.44+45+## The honesty invariant46+47+An app's provisioning may create tables **only under its own declared namespaces**. Everything else48+in the store — sibling apps' tables _and their access policies_ — carries through a merge49+byte-for-byte. Any generated change naming a table outside the app's namespaces is a hard error50+before a single request is made. A flat (non-nested) app declares no namespace and therefore gets no51+tenant rights: it may only provision a store it wholly owns.52+53+Two properties fall out, both verified against a real server in lofi's conformance suite:54+55+- **Continuity.** The first app's devices write and read across a second app's merge untouched.56+- **Policy preservation.** A user who couldn't read a gated row of the first app before the merge57+ still can't after it — the joining app re-publishes the union permissions with the sibling's58+ policies recovered from the store, unchanged.59+60+Stated honestly: this is a _framework_ boundary, not cryptography. Store administration (the admin61+secret, or a provision-scoped ticket) can technically do anything; the invariant keeps apps built on62+lofi honest through the surface they actually use. The node-side complement is that a provision63+ticket never exposes the admin secret itself — the gate injects it — so holding provisioning power64+never means holding a credential you could leak.65+66+## Where each piece lives67+68+- Declaring namespaces and per-namespace permissions:69+ [nested app namespaces](/docs/examples/nested-namespaces) in the framework docs.70+- The classifier and merge flow an app runs: [store provisioning](/docs/store-provisioning).71+- Doing it against a node with a provision ticket: [Provision a store](provision-a-store.md).72+- The ticket semantics underneath: [Tickets explained](tickets-explained.md) and the73+ [app-ticket contract](/node/docs/app-ticket).@@ -0,0 +1,45 @@
1+# Storage choices2+3+<!-- Source: FelineStateMachine/lofi-node docs/hosting-lofi-apps.md ("Storage choice") and4+ src/config.ts (StorageConfig). -->5+6+The node's store is where your synced data durably lives, and the choice is deliberately narrow7+because the underlying engine's honest surface is narrow: jazz-napi supports exactly a SQLite8+directory or memory today. The `storage.type` discriminator in `config.json` is the seam future9+providers slot into.10+11+## SQLite on a path you choose12+13+```sh14+lofi-node init --storage-path /mnt/nas/lofi15+```16+17+```jsonc18+// config.json19+"storage": { "type": "sqlite", "path": "/mnt/nas/lofi" }20+```21+22+The path can be any mounted location — a NAS volume, a synced directory, an external disk. It is23+probed writable at boot; an unwritable location fails fast by name instead of silently degrading.24+Omitting the path keeps the store inside the data directory (`<dataDir>/jazz`).25+26+## Memory27+28+```sh29+lofi-node init --memory30+```31+32+Everything is ephemeral — useful for tests, demos, and throwaway topologies. A restart is a fresh,33+schema-less store: enrolled apps will classify it `no_schema` again and prompt for34+[provisioning](provision-a-store.md).35+36+## Off-site durability37+38+For cloud-grade durability without a cloud sync service, replicate the SQLite file: a39+Litestream-style continuous replica to S3-compatible storage, or snapshots of the volume the path40+lives on. [Pairing a second node](pair-two-homes.md) is the other half of the story — replication at41+the Jazz layer rather than the file layer — and the two compose.42+43+One honest caveat: back up the **data directory** alongside the store. `config.json` holds the44+node's secrets and app id, and `tickets.json` holds the digests that make issued tickets valid; a45+restored store without them is reachable by nobody.@@ -0,0 +1,69 @@
1+# Tickets explained2+3+<!-- Source: FelineStateMachine/lofi-node docs/app-ticket.md (the normative contract) and4+ src/ticket.ts; lofi package/runtime/data-sink.ts for the app side. -->5+6+lofi-node uses two kinds of ticket, and confusing them is the most common early stumble. This guide7+is the conceptual tour; the [app-ticket contract](/node/docs/app-ticket) is the normative spec the8+lofi side implements against.9+10+## Two ticket kinds11+12+- A **node-pairing ticket** (`endpoint…`) is an iroh endpoint address. It names a _node_ so another13+ node can dial it. It is used by `lofi-node pair` and never by browsers or apps.14+- An **app-connect ticket** (`lofisync1.…`) is the credential _you_ hand to a lofi app so it syncs15+ against your node. It carries the store's app id and the gate URL with a 256-bit secret embedded16+ in its path.17+18+## Why the secret lives in the URL path19+20+Jazz clients preserve a base path in their server URL and reject query parameters. By making the21+ticket's URL `http(s)://host:port/t/<secret>`, every request the client ever makes — sync WebSocket22+connects, catalogue reads, admin calls — carries the secret with **zero client changes**. The gate23+validates the secret (timing-safe, digest against digest), strips the prefix, and proxies to the24+loopback-only Jazz server. The lofi app uses the ticket URL verbatim as its sync server; that is the25+whole integration.26+27+## Scopes28+29+- **`sync`** (the default, and what an absent scope means — every pre-scope ticket keeps meaning30+ transport-only): sync, catalogue reads that transport needs, and the metadata-only `store-status`31+ preflight. Admin routes answer the same `401 {"error":"invalid_ticket"}` as an unknown secret, so32+ a probe learns nothing.33+- **`provision`**: a strict superset — everything above plus store administration. The gate injects34+ the node's own `X-Jazz-Admin-Secret` for provisioning requests and strips any inbound one, so the35+ admin secret never transits the client. Possession of a provision ticket **is** the36+ store-administration opt-in; issue one per provisioning context, not per device.37+38+A ticket with an unrecognized scope is rejected outright by the lofi parser — never silently granted39+less than it claims.40+41+## Revocation42+43+- The node stores only SHA-256 digests; the ticket string is displayable once, at issuance.44+- Unknown and revoked secrets are indistinguishable to probers: both get45+ `401 {"error":"invalid_ticket"}`, on HTTP and on the WebSocket upgrade.46+- Revoking a ticket mid-session closes its live WebSockets with close code **4001** within a couple47+ of seconds. The app treats the stored sink as dead and surfaces re-enrollment — it never silently48+ retries forever.49+50+## The security posture, honestly51+52+An app ticket is a **bearer credential** with 256-bit entropy. Anyone holding it can sync as an53+authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on54+top, enforced by the deployed policy, not by the ticket. Plain http is acceptable on a trusted LAN;55+beyond one, front the gate with TLS — installed PWAs generally require a secure origin anyway. Issue56+tickets per device or context so revocation is scoped.57+58+## What the app does with it59+60+You paste; the app does the rest. The ticket is validated (against the same rules this node61+enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync62+location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local63+record (encrypted at rest under a device-bound key) and never surfaces it through its session64+snapshot — only the host and your label show. A provision ticket gets split first: the app asks the65+node for a derived sync ticket to store, and the provision original is either sealed behind your66+passkey or kept only in memory with your password manager holding the durable copy. The derived67+ticket is linked to its parent — revoking the provision ticket revokes it too. For app developers,68+the framework call behind that paste is `enrollSyncTicket`, documented in69+[Sync and recovery](/docs/sync-and-recovery).@@ -0,0 +1,64 @@
1+# Troubleshooting and compatibility2+3+<!-- Source: FelineStateMachine/lofi-node README.md, docs/hosting-lofi-apps.md4+ ("Validation status"), src/errors.ts, src/native/loader.ts. -->5+6+The node's failure modes are designed to be nameable. Work from the signal you see.7+8+## Mesh: `unavailable`9+10+`status().mesh` reports `{ state: "unavailable", reason: … }` when the native transport could not11+load; the Jazz server still runs LAN-only and `ticket()`/`pair()` throw `MeshUnavailableError`12+rather than degrade silently. Causes, in resolution order:13+14+1. An explicit `LOFI_NODE_IROH` path that doesn't exist.15+2. Running from source without building the native layer (`cargo build --release` in16+ `native/iroh-js`).17+3. An unsupported platform — most notably **Windows**, a documented gap (the `*-gnu` build path18+ needs a `libnode.dll` import library the upstream toolchain ships only for msvc). Windows nodes19+ run LAN-only until that resolves.20+21+On **arm64 Linux** the limitation sits one layer deeper: the Jazz engine (jazz-napi) publishes no22+linux-arm64-gnu build, so the node cannot start at all there. Release binaries and the container23+image cover macOS arm64 and Linux x86_64 ([platform notes](beyond-the-lan.md)).24+25+The compiled binary embeds digest-pinned prebuilts; a cache extraction that doesn't match its pin26+fails loudly rather than loading.27+28+## 401 `invalid_ticket`29+30+Unknown ticket, revoked ticket, or a sync-scoped ticket on an admin route — deliberately one answer31+for all three. If a previously working app starts seeing it, the ticket was revoked: re-enroll with32+a fresh one. If provisioning sees it, the ticket lacks `provision` scope.33+34+## WebSocket closes with 400135+36+The ticket was revoked mid-session. This is the live-revocation path working as designed; the app37+should treat its stored sync location as dead and surface re-enrollment, not retry.38+39+## 502 `store_unavailable`40+41+The gate is up but its store is not — the loopback Jazz process, or on a leaf, the tunnel to the42+root. Check `lofi-node status` and, for a leaf, `status().mesh.connections`.43+44+## Writes hang; nothing errors45+46+Almost always a store with **no deployed schema** — the engine hangs rather than fails in this47+state. `GET <ticket-url>/store-status` reporting `{ "deployed": false }` confirms it; the remedy is48+[provisioning](provision-a-store.md). lofi apps classify before attaching sync for exactly this49+reason.50+51+## Version mismatch52+53+A node must run the **same Jazz alpha** as the apps it serves. Mismatches surface as protocol-level54+failures (schema hash and catalogue errors) that no network configuration fixes. Confirm both pins55+before debugging anything else.56+57+## Compatibility status, honestly58+59+The whole flow is validated end to end against lofi's reference app with real browser clients:60+build, boot, offline edits, reconvergence, and the full provisioning lifecycle through the gate and61+through an iroh-paired leaf. One known upstream finding: lofi's opt-in browser convergence gate62+fails at its final stage with a catalogue error **identically against lofi-node and against lofi's63+own first-party dev server** — a controlled experiment placing the issue in the pinned Jazz alpha,64+not the node. lofi-node's contract docs carry the current validation table.