A self-host section on lofi.host sourced two ways per the one-home rule: site-voice tutorials, guides, and reference pages live in docs/node/, and contract pages render verbatim from a pinned lofi-node checkout with provenance headers and links rewritten to the pinned ref. tools/node_docs.ts assembles both into a third docs instance; tools/api_docs.ts --node generates /node/api from the checkout's JSDoc; tools/site_llms.ts emits the node-scoped llms.txt pair linked from the root index. site.yml checks the pinned ref out alongside the repo, with the bump procedure documented at the pin.
- commit
- 27a5b6ee8ff321fb3a2d2e612ab18151417a0ed8
- parent
- 7db5c0f5620796b649991db6a61528686ebba114
- author
- Dami <dami.etoile@gmail.com>
@@ -11,6 +11,7 @@ on:
1111 - "package/**"1212 - "tools/api_docs.ts"1313 - "tools/site_llms.ts"14+ - "tools/node_docs.ts"1415 - "tools/entrypoints.ts"1516 - "deno.json"1617 - ".github/workflows/site.yml"@@ -18,11 +19,25 @@ on:
1819 permissions:1920 contents: read2021 22+# The /node section renders from a PINNED lofi-node checkout so site builds are23+# reproducible; content changes there ride an explicit bump of this ref.24+# Bump procedure: point LOFI_NODE_REF at the new lofi-node commit SHA in the25+# same PR that reviews the resulting /node pages.26+env:27+ LOFI_NODE_REF: f022775fde8d5990514a24aa128adcb98a76dab728+2129 jobs:2230 build:2331 runs-on: ubuntu-latest32+ env:33+ LOFI_NODE_DIR: lofi-node-checkout2434 steps:2535 - uses: actions/checkout@v436+ - uses: actions/checkout@v437+ with:38+ repository: FelineStateMachine/lofi-node39+ ref: ${{ env.LOFI_NODE_REF }}40+ path: lofi-node-checkout2641 - uses: denoland/setup-deno@v22742 with:2843 deno-version: v2.x@@ -31,10 +46,8 @@ jobs:
3146 node-version: 223247 cache: npm3348 cache-dependency-path: website/package-lock.json34- - name: Generate API reference from JSDoc35- run: deno task site:api36- - name: Generate llms.txt and llms-full.txt37- run: deno task site:llms49+ - name: Generate API references, /node docs, and llms.txt pairs50+ run: deno task site:gen3851 - name: Install site dependencies3952 run: npm ci --prefix website4053 - name: Build site (fails on broken links)@@ -64,9 +64,12 @@
6464 "test:golden": "deno run -A tools/golden.ts --source local",6565 "test:golden:registry": "deno run -A tools/golden.ts --source registry",6666 "site:api": "deno run --allow-read --allow-run --allow-write=website/api-gen tools/api_docs.ts",67+ "site:api:node": "deno run --allow-read --allow-run --allow-env=LOFI_NODE_DIR --allow-write=website/node-api-gen tools/api_docs.ts --node",68+ "site:node": "deno run --allow-read --allow-run=git --allow-env=LOFI_NODE_DIR,LOFI_NODE_REF --allow-write=website/node-docs-gen tools/node_docs.ts",6769 "site:llms": "deno run --allow-read --allow-write=website/static --allow-env=SITE_URL tools/site_llms.ts",68- "site:build": "deno task site:api && deno task site:llms && npm run build --prefix website",69- "site:dev": "deno task site:api && deno task site:llms && npm start --prefix website",70+ "site:gen": "deno task site:api && deno task site:api:node && deno task site:node && deno task site:llms",71+ "site:build": "deno task site:gen && npm run build --prefix website",72+ "site:dev": "deno task site:gen && npm start --prefix website",7073 "publish:dry": "deno publish --dry-run"7174 },7275 "fmt": {@@ -44,6 +44,8 @@ shortest product overview and command summary. AI agents can ingest these docs a
4444 - [Schema evolution](examples/schema-evolution.md)4545 - [Nested app namespaces](examples/nested-namespaces.md)4646 - [Store provisioning](store-provisioning.md)47+- [Self-hosting with lofi-node](https://lofi.host/node) — run the sync backend yourself; tutorials,48+ guides, and the node reference live in the site's `/node` section4749 - [Optional installed-app recipes](recipes/README.md)4850 - [Advanced device-auth primitive](auth.md)4951 @@ -159,6 +159,10 @@ Every prerendered route is included in the shell precache. While offline, a dire
159159 as `/field-notes/settings/` resolves its cached `settings/index.html`; if that route was not160160 emitted, the worker falls back to the cached application root.161161 162+The app is the only thing that needs hosting: sync is optional infrastructure the user can point at163+a managed Jazz app or at [a self-hosted sync node](/node) they run themselves — the deployment does164+not change either way.165+162166 ### Offline cache policy163167 164168 The build's precache manifest contains required shell resources only. Shell assets are fetched@@ -0,0 +1,35 @@
1+# Self-hosting with lofi-node2+3+<!-- Source: FelineStateMachine/lofi-node README.md; site-voice page owned by lofi docs/node/. -->4+5+lofi apps work locally with no server at all, and sync is an election the user makes — so the6+natural end of that road is owning the sync location too.7+[lofi-node](https://github.com/FelineStateMachine/lofi-node) is the first-class way to do it: one8+daemon that embeds a real Jazz sync server, [iroh](https://iroh.computer) node-to-node transport,9+and a ticket-based access gate. Browsers keep speaking Jazz's protocol; only the server URL changes,10+and that URL is user-selected data the app enrolls at runtime, not configuration a developer11+compiled in.12+13+What the node gives you:14+15+- **A real Jazz sync server** — SQLite-backed, health-checked, serving exactly the pinned Jazz16+ version your lofi apps run. Any lofi app can use it by URL.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.22+- **No silent degradation.** If the native transport layer can't load, the Jazz server still runs23+ LAN-only and `status()` says exactly why pairing is off.24+25+## Where to go26+27+- Never run a node before? Start with [Self-host your first sync node](self-host-a-sync-node.md).28+- Two locations that should hold the same data: [Pair two homes](pair-two-homes.md).29+- Setting up the store an app syncs into — including a second app joining a store that already holds30+ data: [Provision a store](provision-a-store.md).31+- The mental model behind all of it — slices, shared stores, and why apps can't clobber each other:32+ [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md).33+34+The app-side enrollment flow (pasting a ticket into a lofi app) is part of the framework docs:35+[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,52 @@
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 three concerns: the address you pin into tickets, TLS, and the8+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+## One binary33+34+```sh35+deno task compile # → dist/lofi-node36+```37+38+The compiled binary embeds the prebuilt native transport matrix — macOS arm64 and x86_64, Linux39+x86_64 and aarch64 — and extracts the right library to a version-keyed OS cache on first run.40+Artifact digests are pinned in the source, so an extraction that doesn't match its pin fails loudly.41+42+**Windows** is a documented gap: the native layer's build path needs a `libnode.dll` import library43+the upstream toolchain doesn't ship for this configuration. On Windows the node runs LAN-only — the44+Jazz server works, `status().mesh` reports `{ state: "unavailable", reason: … }`, and45+`ticket()`/`pair()` throw typed errors rather than degrade silently.46+47+## The version invariant48+49+A node pins the **exact** Jazz alpha of the apps it serves; version bumps are coordinated changes,50+never drive-by. Before pointing production apps at a node, confirm the pins match — a mismatched51+pair fails in protocol-level ways no amount of network configuration fixes. See52+[troubleshooting](troubleshooting.md) for what the mismatches look like.@@ -0,0 +1,62 @@
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+ lofi-node start [--dir <dataDir>]12+ lofi-node pair <node-ticket> [--dir <dataDir>]13+ lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]14+ lofi-node ticket list [--dir <dataDir>]15+ lofi-node ticket revoke <id> [--dir <dataDir>]16+ lofi-node status [--dir <dataDir>]17+```18+19+The data directory defaults to `./lofi-node-data` everywhere.20+21+## init22+23+Creates the data directory and writes [`config.json`](configuration.md): a generated Jazz app id (or24+`--app-id` to pin one), fresh backend and admin secrets, and your choices.25+26+| Flag | Meaning |27+| ----------------------- | --------------------------------------------------------------------------------------------------------- |28+| `--port <n>` | Fixed public port for the gate; auto-allocated when omitted. |29+| `--public-url <base>` | Base URL embedded in issued tickets (use the reachable address). |30+| `--open` | Opt out of ticket gating — anyone who can reach the port can sync. CLI inits default to **ticket-gated**. |31+| `--storage-path <path>` | SQLite store on any mounted location; probed writable at boot. |32+| `--memory` | Ephemeral in-memory store. |33+34+## start35+36+Starts the daemon from an initialized directory: boots the loopback-only Jazz server, opens the37+public gate, loads the native transport when available, and prints the gate URL plus the38+node-pairing ticket when the mesh is up.39+40+## pair41+42+`lofi-node pair <node-ticket>` elects the peer as this node's upstream — the node becomes a leaf43+relaying sync and catalogue traffic over iroh. The argument is a node-pairing `endpoint…` string44+from the peer's `start` output, not an app ticket (the CLI rejects app tickets with a pointed45+message). Re-electable at runtime; the public port does not change.46+47+## ticket issue / list / revoke48+49+`issue` mints an app-connect ticket and prints it **exactly once** — the node stores only a digest.50+`--label` names it for your bookkeeping, `--url` overrides the embedded base for this ticket, and51+`--provision` mints a provision-scoped ticket that can also administer the store52+([scopes](tickets-explained.md)).53+54+`list` shows every issued ticket: id, scope, label, issued time, and revocation state. `revoke55+<id>`56+invalidates one; a running daemon picks the change up within seconds and closes the ticket's live57+connections with close code 4001 — no restart, no IPC.58+59+## status60+61+Prints configuration and live state without changing anything: access mode, storage, issued tickets,62+upstream election, and mesh state (up with connection stats, off, or unavailable with the reason).@@ -0,0 +1,65 @@
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+ "allowLocalFirstAuth": true23+}24+```25+26+- **`access`** — `ticket` puts the access gate in front of everything; `open` proxies27+ unauthenticated (the gate still owns the public port and the Jazz server still binds28+ loopback-only).29+- **`storage`** — the honest engine surface: a SQLite directory (optional path, probed writable at30+ boot) or memory. See [storage choices](storage-choices.md).31+- **`upstream`** — `"none"` for a root node; `{ "peer": … }` for an iroh-paired leaf32+ ([pairing](pair-two-homes.md)); `{ "url": … }` for a plain HTTP(S) upstream Jazz server.33+- **Secrets** stay in this file and never transit issued tickets; a provision-scoped ticket causes34+ the gate to inject `adminSecret` server-side rather than revealing it.35+36+**v1 migration**: v1 files load transparently with legacy defaults applied (`access: "open"`, SQLite37+storage) and are rewritten as v2 on the next save.38+39+## tickets.json40+41+```jsonc42+{43+ "v": 1,44+ "tickets": [45+ {46+ "id": "a1b2c3d4e5f6", // first 12 hex chars of the secret's SHA-25647+ "scope": "provision", // absent means "sync"48+ "label": "laptop-admin",49+ "secretHash": "…", // full SHA-256 hex — the secret itself is never stored50+ "createdAt": "2026-07-18T…",51+ "revokedAt": "2026-07-19T…" // present only once revoked52+ }53+ ]54+}55+```56+57+The CLI writes this file; a running daemon only reads it, picking up changes by modification time —58+which is why `ticket issue` and `ticket revoke` need no IPC and no restart.59+60+## Environment variables61+62+| Name | Effect |63+| ----------------- | --------------------------------------------------------------------- |64+| `LOFI_NODE_DEBUG` | `1` enables `[gate]`/`[tunnel]` debug logging to stderr. |65+| `LOFI_NODE_IROH` | Explicit path to the native transport library, overriding resolution. |@@ -0,0 +1,59 @@
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+20+The `<secret>` is a 43-character base64url path segment; the gate compares digests in constant time,21+strips the prefix, preserves the query string, and forwards.22+23+## store-status24+25+```jsonc26+// 20027+{28+ "v": 1,29+ "appId": "…",30+ "schema": {31+ "deployed": true, // false → the object carries only { "deployed": false }32+ "headHash": "ff85ac…", // newest stored schema hash (ordered by publishedAt)33+ "permissionsHead": "0195…" // current permissions head object id, or null34+ }35+}36+37+// 502 — the node is up but its store is not38+{ "error": "store_unavailable" }39+```40+41+Metadata only — never schema contents, policies, or secrets. This endpoint exists so a sync-scoped42+client can classify a store (most importantly `no_schema`, where writes would hang) without any43+admin capability.44+45+## Errors and close codes46+47+| Signal | Meaning |48+| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |49+| `401 {"error":"invalid_ticket"}` | Unknown ticket, revoked ticket, or a sync-scoped ticket on an admin route — deliberately indistinguishable. On WebSocket, the 401 **is** the upgrade response. |50+| 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. |51+| WS close `1011` | Upstream dial or pump failure inside the gate. |52+| `502 {"error":"store_unavailable"}` | Gate up, Jazz (or the tunnel to the root) unreachable. |53+54+## Header handling55+56+Inbound `X-Jazz-Admin-Secret` headers are **stripped unconditionally** in ticket mode; for57+provision-scoped requests on admin routes the gate injects its own. Hop-by-hop headers58+(`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `host`, `content-length`) are managed59+by the gate in both directions; everything else passes through.@@ -0,0 +1,54 @@
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+## Choosing a root51+52+The root holds the authoritative store; leaves relay to it. Put the root on the machine with the53+most reliable storage ([storage choices](storage-choices.md)) and pair outward from there. A leaf's54+election can be redone at runtime (`pair` again) if the topology changes.@@ -0,0 +1,89 @@
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, user-opt-in step 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)`, and with administration in hand the33+richer classifier is `readStoreStatus(root, target)`, which reports one of `ok`, `no_schema`,34+`schema_out_of_date`, or `schema_drift`. `deployed: false` here means `no_schema`: create.35+36+## 2. First app: create37+38+With the app's nested schema and merged permissions bundle:39+40+```ts41+import { provisionStore } from "@nzip/lofi/schema/store";42+import { permissions, root } from "./schema.ts";43+44+await provisionStore({45+ app: root,46+ permissions,47+ target: { serverUrl: provisionTicketUrl, appId: ticketAppId },48+});49+// → { status: "created", headHash: "…" }50+```51+52+Note the absent `adminSecret` — the provision ticket's gate injection carries administration.53+`store-status` now reports `deployed: true` with the head hash, and enrolled devices sync normally.54+55+## 3. Second app: merge into an occupied store56+57+Time passes; the store holds `taskapp` data. Now a notes app — its own namespaces, its own58+deployment — enrolls against the same store. Its preflight reports `schema_out_of_date` with exactly59+its missing tables, and its opt-in provisioning **merges its slice** rather than replacing anything:60+61+- The stored head schema is fetched verbatim and only extended — the first app's tables keep their62+ exact serialization.63+- The union deploys as an ordinary migration advancing the store's one permissions head, with the64+ first app's policies preserved unchanged and the second app's swapped in for its own tables only.65+- The joining app's own compiled schema is registered and connected to the head, so its clients are66+ never disconnected.67+68+```ts69+await provisionStore({ app: notesRoot, permissions: notesPermissions, target });70+// → { status: "updated", headHash: "…" }71+```72+73+The first app's devices keep working untouched across the merge — their schema stays connected74+through the migration chain — and its access rules still hold: a user who couldn't read a gated75+`taskapp` row before the merge still can't after it. Why apps can't step on each other here is the76+subject of [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md).77+78+## 4. What provisioning refuses79+80+- **Drift is never auto-repaired.** If the store's copy of an app's own namespaces differs from what81+ the app declares, provisioning throws with the differing tables. A human decides.82+- **Out-of-namespace changes are hard errors** before any request is made.83+- **Sync-scoped tickets cannot provision**: admin routes answer the same84+ `401 {"error":"invalid_ticket"}` as an unknown ticket — nothing to enumerate.85+86+## Through a paired leaf87+88+Provisioning against a [paired](pair-two-homes.md) leaf lands on the root: catalogue writes ride the89+iroh tunnel's HTTP path. Deploy wherever is convenient; the root's store is what advances.@@ -0,0 +1,82 @@
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 syncing against it. Nothing here requires a cloud account, a static7+IP, or TLS on a trusted LAN — one binary and one paste.8+9+## 1. Get the binary10+11+From a checkout of [lofi-node](https://github.com/FelineStateMachine/lofi-node):12+13+```sh14+deno task compile15+```16+17+This produces a self-contained `dist/lofi-node` with the native transport layer embedded (macOS18+arm64/x86_64 and Linux x86_64/aarch64; on Windows the node runs LAN-only —19+[why](troubleshooting.md)). You can also run the CLI directly with `deno run -A cli.ts`.20+21+## 2. Initialize and start22+23+```sh24+lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:480225+lofi-node start --dir ./data26+```27+28+`init` writes `data/config.json`: a generated Jazz app id, the node's secrets, your access mode, and29+storage choice. New inits are **ticket-gated** — only requests carrying an issued app-ticket secret30+reach the store; `--open` opts out for dev setups. The embedded Jazz server binds loopback-only; the31+node's access gate owns the public port, so the gate is both enforcement and reachability.32+33+`--public-url` is the base address that will be embedded in the tickets you issue — use the address34+other devices on your network can reach. `--storage-path /mnt/nas/lofi` puts the store on any35+mounted location; `--memory` keeps everything ephemeral ([storage choices](storage-choices.md)).36+37+`start` prints the gate URL and, when the mesh is up, the node-pairing ticket for38+[pairing a second node](pair-two-homes.md).39+40+## 3. Issue an app ticket41+42+```sh43+lofi-node ticket issue --dir ./data --label phone44+# → lofisync1.eyJ2IjoxLCJhcHBJZCI6… (shown once; the secret is never stored)45+```46+47+The ticket is one string carrying the store's app id and the gate URL with an access secret embedded48+in its path. It is displayed exactly once — the node keeps only a digest. Issue one per device or49+context (`--label` is for your own bookkeeping), so revocation stays scoped:50+51+```sh52+lofi-node ticket list --dir ./data53+lofi-node ticket revoke <id> --dir ./data # live connections close within seconds54+```55+56+Scopes matter later: a plain ticket is transport-only; `ticket issue --provision` mints one that can57+also [set up the store's schema](provision-a-store.md). What the string actually contains and why58+the secret lives in the URL path: [Tickets explained](tickets-explained.md).59+60+## 4. Enroll the ticket in a lofi app61+62+The user pastes the ticket into the app. On the app side that is one call —63+`enrollSyncTicket(pastedTicket)` — which declares the node as the device's sync location and elects64+sync in one step; the app's existing local data pushes up under the same account identity. The65+enrollment flow, its guardrails, and the UI surface are covered in the framework docs:66+[Sync and recovery](/docs/sync-and-recovery).67+68+## 5. Verify69+70+- `GET <gate-url>/health` answers without a ticket — safe for liveness probes.71+- `GET <ticket-url>/store-status` answers for any valid ticket with metadata about the store's72+ schema. A fresh node reports `{ "schema": { "deployed": false } }` — that is expected, and it is73+ exactly the state the [store provisioning tutorial](provision-a-store.md) resolves. An app should74+ not sync against a store with no schema; its writes would hang rather than fail.75+- `lofi-node status --dir ./data` shows configuration, tickets, upstream, and mesh state without76+ starting anything.77+78+## Where you are79+80+You have a node, a ticket, and an enrolled app — but the store may not have a schema yet. Continue81+with [Provision a store](provision-a-store.md), or connect a second node with82+[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+A self-hosted store is shared infrastructure. The user brings the store; apps are tenants in it.7+This guide is the mental model that makes that safe: what a slice is, how one store's schema grows8+app by 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 handing a user provisioning64+power does not hand them a credential to 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 users' synced data durably lives, and the choice is deliberately7+narrow 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,64 @@
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 a _user_ hands to a lofi app so it15+ syncs against their node. It carries the store's app id and the gate URL with a 256-bit secret16+ embedded 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+On the app side, enrollment is `enrollSyncTicket(pastedTicket)`: the ticket is validated (against61+the same rules this node enforces — the two repos share machine-readable conformance fixtures),62+declared as the device's data sink, and sync is elected in one step. The ticket URL is stored in a63+device-local record and never surfaced through the app's session snapshot — only its host and label64+are. The full app-side semantics live in [Sync and recovery](/docs/sync-and-recovery).@@ -0,0 +1,60 @@
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+The compiled binary embeds digest-pinned prebuilts for macOS arm64/x86_64 and Linux x86_64/aarch64;22+a cache extraction that doesn't match its pin fails loudly rather than loading.23+24+## 401 `invalid_ticket`25+26+Unknown ticket, revoked ticket, or a sync-scoped ticket on an admin route — deliberately one answer27+for all three. If a previously working app starts seeing it, the ticket was revoked: re-enroll with28+a fresh one. If provisioning sees it, the ticket lacks `provision` scope.29+30+## WebSocket closes with 400131+32+The ticket was revoked mid-session. This is the live-revocation path working as designed; the app33+should treat its stored sync location as dead and surface re-enrollment, not retry.34+35+## 502 `store_unavailable`36+37+The gate is up but its store is not — the loopback Jazz process, or on a leaf, the tunnel to the38+root. Check `lofi-node status` and, for a leaf, `status().mesh.connections`.39+40+## Writes hang; nothing errors41+42+Almost always a store with **no deployed schema** — the engine hangs rather than fails in this43+state. `GET <ticket-url>/store-status` reporting `{ "deployed": false }` confirms it; the remedy is44+[provisioning](provision-a-store.md). lofi apps classify before attaching sync for exactly this45+reason.46+47+## Version mismatch48+49+A node must run the **same Jazz alpha** as the apps it serves. Mismatches surface as protocol-level50+failures (schema hash and catalogue errors) that no network configuration fixes. Confirm both pins51+before debugging anything else.52+53+## Compatibility status, honestly54+55+The hosting flow is validated end to end against lofi's reference app with real browser clients:56+build, boot, offline edits, reconvergence, and the full provisioning lifecycle through the gate and57+through an iroh-paired leaf. One known upstream finding: lofi's opt-in browser convergence gate58+fails at its final stage with a catalogue error **identically against lofi-node and against lofi's59+own first-party dev server** — a controlled experiment placing the issue in the pinned Jazz alpha,60+not the node. lofi-node's contract docs carry the current validation table.@@ -6,7 +6,8 @@ back up and sync from the generated `AccountGate` UI.
66 77 There are two ways a device gets a sync location: the deployment can compile one in (a managed Jazz88 app — the _default sink_), or the user can declare one at runtime by enrolling a ticket from a9-self-hosted node. Both feed the same election flow.9+self-hosted node. Both feed the same election flow. Running that node yourself — one binary,10+tickets, optional node-to-node replication — is covered in the [self-hosting section](/node).1011 1112 ## Provision a managed Jazz app1213 @@ -3,9 +3,24 @@
33 * into `website/api-gen/`. One page per entry in { entrypoints}, with the44 * six CLI command wrappers sharing a single page. Run via `deno task site:api`.55 */6-import { entrypoints } from "./entrypoints.ts";6+import { type Entrypoint, entrypoints } from "./entrypoints.ts";77 8-const OUT_DIR = "website/api-gen";8+// `--node` renders the lofi-node API instead: same renderer, sourced from a9+// checkout of FelineStateMachine/lofi-node (LOFI_NODE_DIR, default10+// ../lofi-node), emitted into the /node/api docs instance. lofi-node is not11+// published to a registry; the checkout is the source of truth.12+const NODE_MODE = Deno.args.includes("--node");13+const NODE_DIR = NODE_MODE ? (Deno.env.get("LOFI_NODE_DIR") ?? "../lofi-node") : "";14+15+const nodeEntrypoints: readonly Entrypoint[] = [16+ { jsrName: ".", file: `${NODE_DIR}/mod.ts`, page: "node", title: "Sync node" },17+ { jsrName: "./testing", file: `${NODE_DIR}/testing/mod.ts`, page: "testing", title: "Testing" },18+];19+20+const activeEntrypoints = NODE_MODE ? nodeEntrypoints : entrypoints;21+const packageName = NODE_MODE ? "@nzip/lofi-node" : "@nzip/lofi";22+23+const OUT_DIR = NODE_MODE ? "website/node-api-gen" : "website/api-gen";924 1025 type JsDocTag = {1126 kind: string;@@ -425,14 +440,16 @@ type PageGroup = {
425440 entries: typeof entrypoints[number][];426441 };427442 428-const pageOrder = ["runtime", "astro", "access", "preact", "testing", "cli"];443+const pageOrder = NODE_MODE444+ ? ["node", "testing"]445+ : ["runtime", "astro", "access", "preact", "testing", "cli"];429446 const groups = new Map<string, PageGroup>();430-for (const entry of entrypoints) {447+for (const entry of activeEntrypoints) {431448 const group = groups.get(entry.page) ?? {432449 page: entry.page,433450 position: entry.page.startsWith("recipes/")434451 ? 1 +435- [...entrypoints.filter((e) => e.page.startsWith("recipes/"))].findIndex((e) =>452+ [...activeEntrypoints.filter((e) => e.page.startsWith("recipes/"))].findIndex((e) =>436453 e.page === entry.page437454 )438455 : 1 + pageOrder.indexOf(entry.page),@@ -442,12 +459,14 @@ for (const entry of entrypoints) {
442459 groups.set(entry.page, group);443460 }444461 445-const denoJson = JSON.parse(await Deno.readTextFile("deno.json")) as {462+const denoJson = JSON.parse(463+ await Deno.readTextFile(NODE_MODE ? `${NODE_DIR}/deno.json` : "deno.json"),464+) as {446465 version: string;447466 description: string;448467 };449468 450-await Deno.mkdir(`${OUT_DIR}/recipes`, { recursive: true });469+await Deno.mkdir(NODE_MODE ? OUT_DIR : `${OUT_DIR}/recipes`, { recursive: true });451470 452471 const indexRows: string[] = [];453472 let totalSymbols = 0;@@ -484,14 +503,18 @@ for (const group of groups.values()) {
484503 totalSymbols += symbols.length;485504 486505 const jsrSpecifier = entry.jsrName === "."487- ? "@nzip/lofi"488- : `@nzip/lofi${entry.jsrName.slice(1)}`;506+ ? packageName507+ : `${packageName}${entry.jsrName.slice(1)}`;489508 if (isCommand) {490509 sections.push(`## ${entry.title}`);491510 sections.push(`\`\`\`sh\ndeno run -A jsr:${jsrSpecifier}\n\`\`\``);492511 if (module.module_doc?.doc) sections.push(renderJsDocText(module.module_doc.doc.trim()));493512 } else {494- sections.push(`\`\`\`ts\nimport * as mod from "jsr:${jsrSpecifier}";\n\`\`\``);513+ sections.push(514+ NODE_MODE515+ ? `\`\`\`ts\nimport * as mod from "${jsrSpecifier}"; // from a lofi-node repo checkout\n\`\`\``516+ : `\`\`\`ts\nimport * as mod from "jsr:${jsrSpecifier}";\n\`\`\``,517+ );495518 if (module.module_doc?.doc) sections.push(renderJsDocText(module.module_doc.doc.trim()));496519 sections.push(...symbols.map(renderSymbol));497520 }@@ -512,16 +535,23 @@ for (const group of groups.values()) {
512535 await Deno.writeTextFile(`${OUT_DIR}/${group.page}.md`, body);513536 }514537 538+const indexIntro = NODE_MODE539+ ? `Generated from the JSDoc of [\`@nzip/lofi-node\`](https://github.com/FelineStateMachine/lofi-node) v${denoJson.version}.540+${denoJson.description}541+542+lofi-node is consumed from a repository checkout; it is not yet published to a registry.`543+ : `Generated from the JSDoc of [\`@nzip/lofi\`](https://jsr.io/@nzip/lofi) v${denoJson.version}.544+${denoJson.description}`;545+515546 const index = `---516547 title: "API reference"517548 sidebar_position: 0518549 slug: /519550 ---520551 521-# API reference552+# ${NODE_MODE ? "lofi-node API reference" : "API reference"}522553 523-Generated from the JSDoc of [\`@nzip/lofi\`](https://jsr.io/@nzip/lofi) v${denoJson.version}.524-${denoJson.description}554+${indexIntro}525555 526556 | Export | Summary |527557 | --- | --- |@@ -529,16 +559,18 @@ ${indexRows.join("\n")}
529559 `;530560 await Deno.writeTextFile(`${OUT_DIR}/index.md`, index);531561 532-await Deno.writeTextFile(533- `${OUT_DIR}/recipes/_category_.json`,534- `${535- JSON.stringify(536- { label: "Recipes", position: 8, collapsed: true },537- null,538- 2,539- )540- }\n`,541-);562+if (!NODE_MODE) {563+ await Deno.writeTextFile(564+ `${OUT_DIR}/recipes/_category_.json`,565+ `${566+ JSON.stringify(567+ { label: "Recipes", position: 8, collapsed: true },568+ null,569+ 2,570+ )571+ }\n`,572+ );573+}542574 543575 console.log(544576 `Generated ${groups.size + 1} API pages (${totalSymbols} exported symbols) into ${OUT_DIR}/.`,@@ -25,6 +25,10 @@ for (const markdown of markdownFiles) {
2525 for (const match of source.matchAll(linkPattern)) {2626 const raw = match[1].replace(/^<|>$/g, "");2727 if (raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue;28+ // Site-absolute routes (e.g. /node/docs/app-ticket) point at deployed29+ // pages, some assembled at build time; the Docusaurus build validates30+ // them via onBrokenLinks, not the repository tree.31+ if (raw.startsWith("/")) continue;2832 const target = decodeURIComponent(raw.split("#", 1)[0]);2933 if (!target) continue;3034 try {@@ -0,0 +1,115 @@
1+/**2+ * Assembles the `/node/docs` section into `website/node-docs-gen/` from two3+ * sources, per `website/node-docs-manifest.json`:4+ *5+ * - `docs/node/*.md` in this repo — site-voice tutorials, guides, and6+ * reference pages (each names its lofi-node source material inline).7+ * - Contract pages from a lofi-node checkout (`source` values prefixed8+ * `lofi-node:`) — rendered verbatim with a provenance header and relative9+ * links rewritten to the pinned checkout on GitHub, so the source of truth10+ * stays in the lofi-node repo where its CI gates it.11+ *12+ * The checkout location comes from `LOFI_NODE_DIR` (default `../lofi-node`);13+ * the ref used in provenance links comes from `LOFI_NODE_REF` (default: the14+ * checkout's current commit). Run via `deno task site:node`.15+ */16+17+const OUT_DIR = "website/node-docs-gen";18+19+type ManifestItem = { id: string; label: string; source: string };20+type ManifestSection = { label: string | null; items: ManifestItem[] };21+type Manifest = { checkoutRepo: string; sections: ManifestSection[] };22+23+const manifest = JSON.parse(24+ await Deno.readTextFile("website/node-docs-manifest.json"),25+) as Manifest;26+27+const checkoutDir = Deno.env.get("LOFI_NODE_DIR") ?? "../lofi-node";28+29+async function checkoutRef(): Promise<string> {30+ const configured = Deno.env.get("LOFI_NODE_REF");31+ if (configured) return configured;32+ try {33+ const result = await new Deno.Command("git", {34+ args: ["-C", checkoutDir, "rev-parse", "HEAD"],35+ stdout: "piped",36+ stderr: "null",37+ }).output();38+ const ref = new TextDecoder().decode(result.stdout).trim();39+ if (ref) return ref;40+ } catch {41+ // Fall through to the branch default below.42+ }43+ return "main";44+}45+46+const ref = await checkoutRef();47+48+/** Rewrites a contract page's relative links to the pinned checkout on GitHub. */49+function rewriteContractLinks(markdown: string, sourceDir: string): string {50+ return markdown.replace(51+ /\]\((?!https?:|\/|#)([^)]+)\)/g,52+ (_match, target: string) =>53+ `](https://github.com/${manifest.checkoutRepo}/blob/${ref}/${sourceDir}/${target})`,54+ );55+}56+57+function provenanceHeader(sourcePath: string): string {58+ const url = `https://github.com/${manifest.checkoutRepo}/blob/${ref}/${sourcePath}`;59+ return `:::info[Contract page]\nRendered from [\`${sourcePath}\`](${url}) in the ` +60+ `\`${manifest.checkoutRepo}\` repository at the site's pinned ref — that file is the ` +61+ `source of truth, gated by lofi-node's own CI.\n:::\n\n`;62+}63+64+// Docusaurus derives ids from file paths; the manifest is authoritative, so a65+// page that exists on disk but not in the manifest (or vice versa) is drift.66+const manifestItems = manifest.sections.flatMap((section) => section.items);67+68+await Deno.remove(OUT_DIR, { recursive: true }).catch(() => undefined);69+await Deno.mkdir(OUT_DIR, { recursive: true });70+71+let assembled = 0;72+for (const item of manifestItems) {73+ let markdown: string;74+ if (item.source.startsWith("lofi-node:")) {75+ const relative = item.source.slice("lofi-node:".length);76+ const path = `${checkoutDir}/${relative}`;77+ try {78+ markdown = await Deno.readTextFile(path);79+ } catch {80+ console.error(81+ `Missing lofi-node checkout file ${path}. Set LOFI_NODE_DIR to a checkout of ` +82+ `${manifest.checkoutRepo} (default ../lofi-node).`,83+ );84+ Deno.exit(1);85+ }86+ const sourceDir = relative.split("/").slice(0, -1).join("/");87+ markdown = provenanceHeader(relative) + rewriteContractLinks(markdown, sourceDir);88+ } else {89+ try {90+ markdown = await Deno.readTextFile(item.source);91+ } catch {92+ console.error(`Manifest names missing page source ${item.source} (id "${item.id}").`);93+ Deno.exit(1);94+ }95+ }96+ await Deno.writeTextFile(`${OUT_DIR}/${item.id}.md`, markdown);97+ assembled++;98+}99+100+// Site-voice pages not named by the manifest would silently miss the sidebar101+// and the llms corpus — treat them as drift.102+for await (const entry of Deno.readDir("docs/node")) {103+ if (!entry.isFile || !entry.name.endsWith(".md")) continue;104+ const source = `docs/node/${entry.name}`;105+ if (!manifestItems.some((item) => item.source === source)) {106+ console.error(`docs/node/${entry.name} is not listed in website/node-docs-manifest.json.`);107+ Deno.exit(1);108+ }109+}110+111+console.log(112+ `Assembled ${assembled} /node/docs pages into ${OUT_DIR}/ (lofi-node checkout: ${checkoutDir} @ ${113+ ref.slice(0, 12)114+ }).`,115+);@@ -79,7 +79,7 @@ async function collectApiDir(dir: string, urlPrefix: string): Promise<void> {
7979 const markdown = await Deno.readTextFile(sourcePath);8080 const stem = entry.name.slice(0, -".md".length);8181 const position = Number(markdown.match(/^sidebar_position:\s*(\d+)$/m)?.[1] ?? 99);82- const url = stem === "index" ? `${siteUrl}/api` : `${urlPrefix}/${stem}`;82+ const url = stem === "index" ? urlPrefix : `${urlPrefix}/${stem}`;8383 apiEntries.push({8484 title: firstHeading(markdown) ?? stem,8585 url,@@ -110,6 +110,8 @@ const shortLines: string[] = [
110110 `lofi is published on JSR as \`@nzip/lofi\` (v${denoJson.version}).`,111111 `This file indexes the documentation deployed at ${siteUrl} for this exact version.`,112112 `The full documentation corpus is available at ${siteUrl}/llms-full.txt.`,113+ `The self-hosted sync node (lofi-node) is indexed separately at ${siteUrl}/node/llms.txt` +114+ ` (full corpus: ${siteUrl}/node/llms-full.txt).`,113115 "",114116 ];115117 for (const section of sections) {@@ -145,8 +147,91 @@ for (const doc of allDocs) {
145147 146148 await Deno.writeTextFile(`${OUT_DIR}/llms.txt`, shortLines.join("\n"));147149 await Deno.writeTextFile(`${OUT_DIR}/llms-full.txt`, fullParts.join("\n"));150+const mainApiCount = apiEntries.length;151+152+// The node-scoped pair: agents enrolling an app against a self-hosted node153+// should not have to ingest the full framework corpus, and vice versa. Pages154+// come from the assembled /node/docs set (tools/node_docs.ts) so contract155+// pages rendered from the lofi-node checkout are included verbatim.156+const NODE_DOCS_DIR = "website/node-docs-gen";157+const NODE_API_DIR = "website/node-api-gen";158+159+const nodeManifest = JSON.parse(160+ await Deno.readTextFile("website/node-docs-manifest.json"),161+) as Manifest;162+163+function nodeDocUrl(id: string): string {164+ return id === "README" ? `${siteUrl}/node/docs` : `${siteUrl}/node/docs/${id}`;165+}166+167+const nodeSections: Section[] = [];168+for (const section of nodeManifest.sections) {169+ const docs: DocEntry[] = [];170+ for (const item of section.items) {171+ const sourcePath = `${NODE_DOCS_DIR}/${item.id}.md`;172+ let markdown: string;173+ try {174+ markdown = await Deno.readTextFile(sourcePath);175+ } catch {176+ console.error(`Missing ${sourcePath}; run \`deno task site:node\` first.`);177+ Deno.exit(1);178+ }179+ docs.push({180+ title: firstHeading(markdown) ?? item.label,181+ url: nodeDocUrl(item.id),182+ sourcePath,183+ });184+ }185+ nodeSections.push({ label: section.label ?? "Documentation", docs });186+}187+188+apiEntries.length = 0;189+try {190+ await collectApiDir(NODE_API_DIR, `${siteUrl}/node/api`);191+} catch (error) {192+ if (!(error instanceof Deno.errors.NotFound)) throw error;193+ console.error(`Missing ${NODE_API_DIR}; run \`deno task site:api:node\` first.`);194+ Deno.exit(1);195+}196+const nodeApiEntries = [...apiEntries];197+nodeApiEntries.sort((a, b) => a.position - b.position || a.url.localeCompare(b.url));198+199+const nodeShort: string[] = [200+ "# lofi-node",201+ "",202+ "> Self-host the sync backend for lofi apps: one daemon embedding a Jazz sync server, iroh node-to-node transport, and ticket-gated access.",203+ "",204+ `This file indexes the lofi-node documentation deployed at ${siteUrl}/node.`,205+ `The full corpus is available at ${siteUrl}/node/llms-full.txt; the lofi framework itself is indexed at ${siteUrl}/llms.txt.`,206+ "",207+];208+for (const section of nodeSections) {209+ nodeShort.push(`## ${section.label}`, "");210+ for (const doc of section.docs) nodeShort.push(`- [${doc.title}](${doc.url})`);211+ nodeShort.push("");212+}213+nodeShort.push("## API reference", "");214+for (const entry of nodeApiEntries) nodeShort.push(`- [${entry.title}](${entry.url})`);215+nodeShort.push("");216+217+const nodeFull: string[] = [218+ "# lofi-node documentation",219+ "",220+ `> Deployed at ${siteUrl}/node. Source: https://github.com/FelineStateMachine/lofi-node`,221+ "",222+];223+for (const doc of [...nodeSections.flatMap((section) => section.docs), ...nodeApiEntries]) {224+ const markdown = stripFrontmatter(await Deno.readTextFile(doc.sourcePath)).trim();225+ nodeFull.push("---", "", `<!-- Source: ${doc.url} -->`, "", markdown, "");226+}227+228+await Deno.mkdir(`${OUT_DIR}/node`, { recursive: true });229+await Deno.writeTextFile(`${OUT_DIR}/node/llms.txt`, nodeShort.join("\n"));230+await Deno.writeTextFile(`${OUT_DIR}/node/llms-full.txt`, nodeFull.join("\n"));148231 149232 const docCount = sections.reduce((sum, section) => sum + section.docs.length, 0);233+const nodeDocCount = nodeSections.reduce((sum, section) => sum + section.docs.length, 0);150234 console.log(151- `Generated llms.txt (${docCount} docs + ${apiEntries.length} API pages) and llms-full.txt into ${OUT_DIR}/.`,235+ `Generated llms.txt (${docCount} docs + ${mainApiCount} API pages), node/llms.txt ` +236+ `(${nodeDocCount} docs + ${nodeApiEntries.length} API pages), and the -full pair into ${OUT_DIR}/.`,152237 );@@ -49,8 +49,8 @@ const config: Config = {
4949 {5050 hashed: true,5151 indexBlog: false,52- docsRouteBasePath: ["docs", "api"],53- docsDir: ["../docs", "api-gen"],52+ docsRouteBasePath: ["docs", "api", "node/docs", "node/api"],53+ docsDir: ["../docs", "api-gen", "node-docs-gen", "node-api-gen"],5454 },5555 ],5656 ],@@ -89,6 +89,29 @@ const config: Config = {
8989 editUrl: undefined,9090 },9191 ],92+ [93+ "@docusaurus/plugin-content-docs",94+ {95+ // Assembled by tools/node_docs.ts from docs/node/ plus contract pages96+ // rendered out of the pinned lofi-node checkout; no edit URL because97+ // pages have two possible homes — each page names its source.98+ id: "node",99+ path: "node-docs-gen",100+ routeBasePath: "node/docs",101+ sidebarPath: "./sidebars-node.ts",102+ editUrl: undefined,103+ },104+ ],105+ [106+ "@docusaurus/plugin-content-docs",107+ {108+ id: "node-api",109+ path: "node-api-gen",110+ routeBasePath: "node/api",111+ sidebarPath: "./sidebars-node-api.ts",112+ editUrl: undefined,113+ },114+ ],92115 ],93116 94117 presets: [@@ -106,6 +129,8 @@ const config: Config = {
106129 "devx-contract.md",107130 "seed.md",108131 "assets/**",132+ // Served under /node/docs by the "node" plugin instance instead.133+ "node/**",109134 "**/_*.{js,jsx,ts,tsx,md,mdx}",110135 "**/_*/**",111136 ],@@ -180,6 +205,7 @@ const config: Config = {
180205 items: [181206 { type: "docSidebar", sidebarId: "docs", position: "left", label: "Docs" },182207 { to: "/api", label: "API", position: "left" },208+ { to: "/node", label: "Self-host", position: "left" },183209 { href: "https://jsr.io/@nzip/lofi", label: "JSR", position: "right" },184210 {185211 href: "https://github.com/FelineStateMachine/lofi",@@ -205,6 +231,7 @@ const config: Config = {
205231 { label: "Permissions", to: "/docs/permissions" },206232 { label: "Sync and recovery", to: "/docs/sync-and-recovery" },207233 { label: "API reference", to: "/api" },234+ { label: "Self-host a sync node", to: "/node" },208235 ],209236 },210237 {@@ -0,0 +1,90 @@
1+{2+ "siteUrl": "https://lofi.host",3+ "checkoutRepo": "FelineStateMachine/lofi-node",4+ "sections": [5+ {6+ "label": null,7+ "items": [8+ { "id": "README", "label": "Overview", "source": "docs/node/README.md" }9+ ]10+ },11+ {12+ "label": "Tutorials",13+ "collapsed": false,14+ "items": [15+ {16+ "id": "self-host-a-sync-node",17+ "label": "Self-host your first sync node",18+ "source": "docs/node/self-host-a-sync-node.md"19+ },20+ {21+ "id": "pair-two-homes",22+ "label": "Pair two homes",23+ "source": "docs/node/pair-two-homes.md"24+ },25+ {26+ "id": "provision-a-store",27+ "label": "Provision a store",28+ "source": "docs/node/provision-a-store.md"29+ }30+ ]31+ },32+ {33+ "label": "Guides",34+ "collapsed": false,35+ "items": [36+ {37+ "id": "sliceable-apps-and-shared-stores",38+ "label": "Sliceable apps and shared stores",39+ "source": "docs/node/sliceable-apps-and-shared-stores.md"40+ },41+ {42+ "id": "tickets-explained",43+ "label": "Tickets explained",44+ "source": "docs/node/tickets-explained.md"45+ },46+ {47+ "id": "storage-choices",48+ "label": "Storage choices",49+ "source": "docs/node/storage-choices.md"50+ },51+ {52+ "id": "beyond-the-lan",53+ "label": "Deploying beyond the LAN",54+ "source": "docs/node/beyond-the-lan.md"55+ }56+ ]57+ },58+ {59+ "label": "Reference",60+ "items": [61+ { "id": "cli", "label": "CLI", "source": "docs/node/cli.md" },62+ {63+ "id": "configuration",64+ "label": "Configuration",65+ "source": "docs/node/configuration.md"66+ },67+ {68+ "id": "app-ticket",69+ "label": "App-ticket contract",70+ "source": "lofi-node:docs/app-ticket.md"71+ },72+ {73+ "id": "http-surface",74+ "label": "HTTP surface",75+ "source": "docs/node/http-surface.md"76+ },77+ {78+ "id": "architecture",79+ "label": "Architecture and provenance",80+ "source": "docs/node/architecture.md"81+ },82+ {83+ "id": "troubleshooting",84+ "label": "Troubleshooting and compatibility",85+ "source": "docs/node/troubleshooting.md"86+ }87+ ]88+ }89+ ]90+}@@ -0,0 +1,10 @@
1+import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";2+3+// The lofi-node API reference under website/node-api-gen/ is generated by4+// tools/api_docs.ts --node; ordering comes from the emitted sidebar_position5+// frontmatter.6+const sidebars: SidebarsConfig = {7+ nodeApi: [{ type: "autogenerated", dirName: "." }],8+};9+10+export default sidebars;@@ -0,0 +1,44 @@
1+import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";2+import manifest from "./node-docs-manifest.json";3+4+// Mirrors sidebars.ts: the /node/docs sidebar derives from5+// node-docs-manifest.json so navigation and the generated /node/llms.txt6+// (tools/site_llms.ts) can never disagree about which pages are published or7+// in what order. Page files themselves are assembled by tools/node_docs.ts.8+9+type ManifestItem = { id: string; label: string; source: string };10+type ManifestSection = {11+ label: string | null;12+ collapsed?: boolean;13+ items: ManifestItem[];14+};15+16+type SidebarItem =17+ | { type: "doc"; id: string; label: string }18+ | {19+ type: "category";20+ label: string;21+ collapsed: boolean;22+ items: SidebarItem[];23+ };24+25+const nodeSidebar: SidebarItem[] = (manifest.sections as ManifestSection[]).flatMap(26+ (section) => {27+ const items: SidebarItem[] = section.items.map((item) => ({28+ type: "doc" as const,29+ id: item.id,30+ label: item.label,31+ }));32+ if (section.label === null) return items;33+ return [{34+ type: "category" as const,35+ label: section.label,36+ collapsed: section.collapsed ?? true,37+ items,38+ }];39+ },40+);41+42+const sidebars: SidebarsConfig = { node: nodeSidebar };43+44+export default sidebars;@@ -0,0 +1,139 @@
1+import type { ReactNode } from "react";2+import Link from "@docusaurus/Link";3+import Layout from "@theme/Layout";4+5+// The /node homepage: what lofi-node is and where its docs live. Content is6+// sourced from the lofi-node README (FelineStateMachine/lofi-node); the7+// tutorial, guide, and reference pages under /node/docs are assembled by8+// tools/node_docs.ts.9+10+const QUICK_START = [11+ ["deno task compile", "one self-contained binary (dist/lofi-node)"],12+ ["dist/lofi-node init --port 4802", "ticket-gated by default; --open opts out"],13+ ["dist/lofi-node start", "gate URL + node-pairing ticket"],14+ ["dist/lofi-node ticket issue --label phone", "app-connect ticket (shown once)"],15+] as const;16+17+const CARDS: Array<{ title: string; body: string; to: string; link: string }> = [18+ {19+ title: "Self-host your first sync node",20+ body:21+ "Compile the binary, start a ticket-gated node, issue an app ticket, and enroll it in a lofi app.",22+ to: "/node/docs/self-host-a-sync-node",23+ link: "Start the tutorial",24+ },25+ {26+ title: "Sliceable apps and shared stores",27+ body:28+ "One store as shared infrastructure: slices, the namespace honesty invariant, and the four store states.",29+ to: "/node/docs/sliceable-apps-and-shared-stores",30+ link: "Read the guide",31+ },32+ {33+ title: "App-ticket contract",34+ body:35+ "The lofisync1 format, scopes, revocation semantics, and the store-status preflight — the contract the lofi side implements.",36+ to: "/node/docs/app-ticket",37+ link: "Read the contract",38+ },39+ {40+ title: "API reference",41+ body:42+ "createSyncNode, SyncNode, ticket codecs, and the test mesh — generated from the lofi-node sources.",43+ to: "/node/api",44+ link: "Browse the API",45+ },46+];47+48+export default function NodeHome(): ReactNode {49+ return (50+ <Layout51+ title="Self-host with lofi-node"52+ description="One daemon that self-hosts a lofi app's sync backend: an embedded Jazz sync server, iroh node-to-node transport, and a ticket-based access gate."53+ >54+ <main className="container margin-vert--lg">55+ <div className="row">56+ <div className="col col--8 col--offset-2">57+ <h1>Own your sync location</h1>58+ <p>59+ <strong>lofi-node</strong>{" "}60+ is the first-class way to self-host the sync backend for lofi apps: one daemon61+ embedding a real Jazz sync server, <a href="https://iroh.computer">iroh</a>{" "}62+ node-to-node transport, and a ticket-based access gate. Browsers keep speaking Jazz's63+ protocol — only the server URL changes, and that URL is{" "}64+ <em>user-selected data, not developer configuration</em>: the app stores the ticket65+ you issue and syncs where you point it.66+ </p>67+ <ul>68+ <li>69+ <strong>A real Jazz sync server</strong>{" "}70+ — SQLite-backed, health-checked, usable by any lofi app by URL.71+ </li>72+ <li>73+ <strong>Node-to-node replication over iroh</strong>{" "}74+ — two homes converge by ticket: dialed by key, hole-punched, no static IPs, no75+ cloud dependency.76+ </li>77+ <li>78+ <strong>Tickets, not accounts</strong>{" "}79+ — possession of a <code>lofisync1.</code>{" "}80+ app ticket is transport access; a provision-scoped ticket additionally administers81+ the store, with the admin secret never leaving the node.82+ </li>83+ <li>84+ <strong>One binary</strong>{" "}85+ — <code>deno task compile</code>{" "}86+ embeds the prebuilt native matrix and runs on macOS and Linux.87+ </li>88+ </ul>89+90+ <h2>Quick start</h2>91+ <pre>92+ <code>93+ {QUICK_START.map(([cmd, note]) => `$ ${cmd}\n # ${note}\n`).join("")}94+ </code>95+ </pre>96+ <p>97+ The issued ticket carries <em>location + access</em>; the user pastes it into their98+ lofi app, which stores it and uses its URL as the sync server. Enrollment on the app99+ side is{" "}100+ <Link to="/docs/sync-and-recovery">101+ documented with the rest of sync and recovery102+ </Link>.103+ </p>104+105+ <div className="row margin-top--lg">106+ {CARDS.map((card) => (107+ <div className="col col--6 margin-bottom--lg" key={card.to}>108+ <div className="card" style={{ height: "100%" }}>109+ <div className="card__header">110+ <h3>{card.title}</h3>111+ </div>112+ <div className="card__body">113+ <p>{card.body}</p>114+ </div>115+ <div className="card__footer">116+ <Link className="button button--secondary button--block" to={card.to}>117+ {card.link}118+ </Link>119+ </div>120+ </div>121+ </div>122+ ))}123+ </div>124+125+ <p>126+ Full documentation: <Link to="/node/docs">/node/docs</Link> · agents can ingest{" "}127+ <Link to="/node/llms.txt" target="_blank">/node/llms.txt</Link> or{" "}128+ <Link to="/node/llms-full.txt" target="_blank">/node/llms-full.txt</Link> · source:129+ {" "}130+ <a href="https://github.com/FelineStateMachine/lofi-node">131+ FelineStateMachine/lofi-node132+ </a>133+ </p>134+ </div>135+ </div>136+ </main>137+ </Layout>138+ );139+}