clone

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

feat(site): /node section — lofi-node homepage, docs, api, and llms pair

Dami committed 27a5b6e
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>
28 files changed, +1406 -34
M.github/workflows/site.yml21++++-
M.gitignore3+
Mdeno.json7+-
Mdocs/README.md2+
Mdocs/deployment.md4+
Adocs/node/README.md35++++++++
Adocs/node/architecture.md60+++++++++++++
Adocs/node/beyond-the-lan.md52+++++++++++
Adocs/node/cli.md62+++++++++++++
Adocs/node/configuration.md65++++++++++++++
Adocs/node/http-surface.md59+++++++++++++
Adocs/node/pair-two-homes.md54++++++++++++
Adocs/node/provision-a-store.md89+++++++++++++++++++
Adocs/node/self-host-a-sync-node.md82++++++++++++++++++
Adocs/node/sliceable-apps-and-shared-stores.md73++++++++++++++++
Adocs/node/storage-choices.md45++++++++++
Adocs/node/tickets-explained.md64++++++++++++++
Adocs/node/troubleshooting.md60+++++++++++++
Mdocs/sync-and-recovery.md3+-
Mtools/api_docs.ts78++++++++++++-----
Mtools/docs_links.ts4+
Atools/node_docs.ts115+++++++++++++++++++++++++
Mtools/site_llms.ts89+++++++++++++++++++-
Mwebsite/docusaurus.config.ts31++++++-
Awebsite/node-docs-manifest.json90+++++++++++++++++++
Awebsite/sidebars-node-api.ts10++
Awebsite/sidebars-node.ts44+++++++++
Awebsite/src/pages/node.tsx139++++++++++++++++++++++++++++++
M .github/workflows/site.yml +17 -4
M .gitignore +3 -0
M deno.json +5 -2
M docs/README.md +2 -0
M docs/deployment.md +4 -0
A docs/node/README.md +35 -0
@@ -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).
A docs/node/architecture.md +60 -0
@@ -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.
A docs/node/beyond-the-lan.md +52 -0
@@ -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.
A docs/node/cli.md +62 -0
@@ -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).
A docs/node/configuration.md +65 -0
@@ -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. |
A docs/node/http-surface.md +59 -0
@@ -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.
A docs/node/pair-two-homes.md +54 -0
@@ -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.
A docs/node/provision-a-store.md +89 -0
@@ -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.
A docs/node/self-host-a-sync-node.md +82 -0
@@ -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).
A docs/node/sliceable-apps-and-shared-stores.md +73 -0
@@ -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).
A docs/node/storage-choices.md +45 -0
@@ -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.
A docs/node/tickets-explained.md +64 -0
@@ -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).
A docs/node/troubleshooting.md +60 -0
@@ -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.
M docs/sync-and-recovery.md +2 -1
M tools/api_docs.ts +55 -23
@@ -3,9 +3,24 @@
33 * into `website/api-gen/`. One page per entry in {@link 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}/.`,
M tools/docs_links.ts +4 -0
A tools/node_docs.ts +115 -0
@@ -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+);
M tools/site_llms.ts +87 -2
@@ -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 );
M website/docusaurus.config.ts +29 -2
@@ -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 {
A website/node-docs-manifest.json +90 -0
@@ -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+}
A website/sidebars-node-api.ts +10 -0
A website/sidebars-node.ts +44 -0
A website/src/pages/node.tsx +139 -0
@@ -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+}