* feat(site): source the /node docs section from the lofi-node checkout The site-voice node pages move to lofi-node docs/site/ (adopted there in FelineStateMachine/lofi-node#6), so node docs version with the node and its CI gates them, while the website stays here. The manifest now names every /node page as a lofi-node: source; the generator reads all pages from the pinned checkout, keeps links between manifest pages site-internal, rewrites other relative links to the pinned ref on GitHub, and reserves the provenance banner for contract pages (manifest "contract": true). The drift check enumerates the checkout's docs/site/ instead of the removed docs/node/. LOFI_NODE_REF bumps to the adopting lofi-node commit per the workflow's own procedure. Part of FelineStateMachine/lofi#123. * fix(site): pin the lofi-node checkout at the docs-adoption merge on main
- commit
- 02010bb5dbedfdd05ae713acd6b114114c28f773
- parent
- 1d6e5293761b1633d21722e7f14cb2677d2c32ff
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | .github/ | 2 | +- |
| D | docs/ | 36 | --------- |
| D | docs/ | 60 | --------------- |
| D | docs/ | 118 | ------------------------------ |
| D | docs/ | 66 | ----------------- |
| D | docs/ | 72 | ------------------ |
| D | docs/ | 59 | --------------- |
| D | docs/ | 63 | ---------------- |
| D | docs/ | 89 | ----------------------- |
| D | docs/ | 93 | ------------------------ |
| D | docs/ | 73 | ------------------- |
| D | docs/ | 45 | ----------- |
| D | docs/ | 68 | ----------------- |
| D | docs/ | 64 | ---------------- |
| M | tools/ | 112 | +++++++++++++++++++---------- |
| M | website/ | 2 | +- |
| M | website/ | 28 | ++++---- |
@@ -24,7 +24,7 @@ permissions:
2424 # Bump procedure: point LOFI_NODE_REF at the new lofi-node commit SHA in the2525 # same PR that reviews the resulting /node pages.2626 env:27- LOFI_NODE_REF: 1f83f723ce3cac025800407806aa5c4f51bc8a2327+ LOFI_NODE_REF: d60d5ed17c1279395ca74910bb078c3d8b33d6402828 2929 jobs:3030 build:@@ -1,36 +0,0 @@
1-# Your data, on your node2-3-<!-- Source: FelineStateMachine/lofi-node README.md; site-voice page owned by lofi docs/node/. -->4-5-You use lofi apps; lofi-node is how you decide where their data lives. This is not something an6-app's developer sets up for you, and it is not a way to host the app itself — the app stays wherever7-it is deployed. The node is yours: [lofi-node](https://github.com/FelineStateMachine/lofi-node) is8-one daemon that embeds a real Jazz sync server, [iroh](https://iroh.computer) node-to-node9-transport, and a ticket-based access gate. Browsers keep speaking Jazz's protocol; only the server10-URL changes, and that URL is data you hand the app at runtime, not configuration its developer11-compiled in.12-13-What the node gives you:14-15-- **A real Jazz sync server** — SQLite-backed, health-checked, running exactly the pinned Jazz16- version the apps you use run. Any lofi app you use can sync into it.17-- **Tickets, not accounts.** A `lofisync1.` app-connect ticket carries location and access in one18- string; possession is transport access, revocation is one command. A provision-scoped ticket19- additionally administers the store, with the admin secret never leaving the node.20-- **Node-to-node replication over iroh.** Two homes converge by pairing tickets: dialed by public21- key, hole-punched, no static IPs, no cloud dependency — down to22- [the relay itself, which you can run](beyond-the-lan.md).23-- **No silent degradation.** If the native transport layer can't load, the Jazz server still runs24- LAN-only and `status()` says exactly why pairing is off.25-26-## Where to go27-28-- Never run a node before? Start with [Self-host your first sync node](self-host-a-sync-node.md).29-- Two locations that should hold the same data: [Pair two homes](pair-two-homes.md).30-- Setting up the store an app syncs into — including a second app joining a store that already holds31- data: [Provision a store](provision-a-store.md).32-- The mental model behind all of it — slices, shared stores, and why apps can't clobber each other:33- [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md).34-35-The app-side enrollment flow (pasting a ticket into a lofi app) is part of the framework docs:36-[Sync and recovery](/docs/sync-and-recovery).@@ -1,60 +0,0 @@
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.@@ -1,118 +0,0 @@
1-# Deploying beyond the LAN2-3-<!-- Source: FelineStateMachine/lofi-node README.md, docs/hosting-lofi-apps.md,4- deno.json compile task, src/native/loader.ts. -->5-6-A node on a trusted LAN needs nothing beyond the tutorial. Leaving the LAN — a VPS, a reachable home7-server, tickets that travel — adds four concerns: the address you pin into tickets, TLS, the relay8-your mesh rides, and the binary you actually run.9-10-## Pin the public URL11-12-```sh13-lofi-node init --public-url https://sync.example.net14-```15-16-`publicUrl` is the base embedded in every issued ticket. It must be the address enrolled devices17-will reach — not `localhost`, not an internal IP that stops resolving off-network. It can be changed18-per-issuance (`ticket issue --url …`), but pinning it at init keeps tickets consistent.19-20-## Front the gate with TLS21-22-The gate speaks plain HTTP; beyond a trusted LAN, put TLS in front of it (a reverse proxy such as23-Caddy or nginx terminating `https://` and forwarding to the gate's port) and issue tickets with the24-`https://` public URL. Two reasons, one hard and one practical: the ticket URL is a bearer25-credential and should not transit networks unencrypted; and installed PWAs generally require a26-secure origin, so an app enrolled over plain http may work in a tab and fail as an installed app.27-28-Note what TLS does **not** change: the ticket still gates access, revocation still works the same,29-and the WebSocket upgrade still rides the same URL — the browser client derives its endpoints from30-the http(s) base.31-32-## Bring your own relay33-34-Paired nodes hole-punch direct connections wherever possible; an35-[iroh relay](https://docs.iroh.computer/concepts/relays) assists that hole-punching and carries the36-traffic that cannot go direct. Out of the box a node uses n0-computer's **public relays**: shared by37-every iroh developer, rate-limited, no uptime guarantees. That default exists so the tutorial works38-in thirty seconds; n0 blesses it for development and testing, and a production mesh should not lean39-on it.40-41-```sh42-lofi-node init --relay https://relay.example.net43-```44-45-The relay is part of what you self-host, and it fits the machine you already have: `iroh-relay` is a46-single open-source binary with built-in Let's Encrypt support, cheap to run because it only carries47-the non-direct residue of your own mesh's traffic. The publicly reachable machine hosting your root48-node is its natural home. Each node elects its **own** relay (`relay` in49-[config.json](configuration.md)), and the election travels inside that node's pairing tickets, so50-nothing has to be coordinated across the mesh — no lofi-run infrastructure, no shared choke point.51-Your mesh's availability depends only on machines you operate.52-53-`--relay` accepts several URLs (comma-separated or repeated) for failover. `--no-relay` opts out54-entirely; defensible for nodes that are all mutually reachable (static IPs, a VPN), but it also55-forgoes relay-assisted address discovery, so expect hole-punching to suffer across NATs.56-57-## One binary58-59-```sh60-deno task compile # → dist/lofi-node61-```62-63-The compiled binary embeds the prebuilt native transport layer and extracts it to a version-keyed OS64-cache on first run. Artifact digests are pinned in the source, so an extraction that doesn't match65-its pin fails loudly. Release binaries cover macOS arm64 and Linux x86_64.66-67-**Windows** is a documented gap: the native layer's build path needs a `libnode.dll` import library68-the upstream toolchain doesn't ship for this configuration. On Windows the node runs LAN-only — the69-Jazz server works, `status().mesh` reports `{ state: "unavailable", reason: … }`, and70-`ticket()`/`pair()` throw typed errors rather than degrade silently.71-72-**Linux aarch64** is a second documented gap: the Jazz engine (jazz-napi) publishes no73-linux-arm64-gnu build, so neither a compiled binary nor a native container image can boot on arm6474-Linux. The iroh transport layer is built for the platform already; the target returns when the75-upstream package appears.76-77-## Run it in Docker78-79-The container image wraps the same compiled binary in a minimal glibc base:80-`ghcr.io/felinestatemachine/lofi-node`, published per release, linux/amd64 (per the platform note81-above; Docker Desktop and qemu run it on arm64 hosts under emulation).82-83-```sh84-docker run --rm -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node \85- init --dir /data --port 4802 --public-url https://sync.example.net86-docker run -d --name lofi-node --network host \87- -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node88-```89-90-Everything durable lives in the `/data` volume: `config.json`, the node identity key,91-`tickets.json`, and the SQLite store. CLI verbs run through `docker exec` against the same directory92-and take effect without a restart, exactly as outside a container:93-94-```sh95-docker exec lofi-node lofi-node ticket issue --dir /data --label phone96-```97-98-Two container-specific choices matter:99-100-- **Networking.** Host networking keeps Docker's bridge NAT out of iroh's hole-punching path. A101- bridge network with a published port (`-p 4802:4802`) serves apps through the gate without issue,102- but paired nodes behind it are more likely to fall back to relayed traffic103- ([the relay's role](pair-two-homes.md)). Host networking is a Linux feature; Docker Desktop104- supports it from version 4.34.105-- **The health check** probes `http://127.0.0.1:4802/health`. A node initialized with a different106- `--port` needs the check overridden in compose.107-108-The image runs as uid 1000. Named volumes inherit that ownership; a bind mount must be writable by109-uid 1000 (`chown 1000:1000 <dir>`) or the storage probe fails at boot, naming the path. The110-repository's [compose.yaml](https://github.com/FelineStateMachine/lofi-node/blob/main/compose.yaml)111-is the reference deployment.112-113-## The version invariant114-115-A node pins the **exact** Jazz alpha of the apps it serves; version bumps are coordinated changes,116-never drive-by. Before pointing the apps you rely on at your node, confirm the pins match — a117-mismatched pair fails in protocol-level ways no amount of network configuration fixes. See118-[troubleshooting](troubleshooting.md) for what the mismatches look like.@@ -1,66 +0,0 @@
1-# CLI2-3-<!-- Source: FelineStateMachine/lofi-node cli.ts (USAGE block and argument parsing). -->4-5-```6-lofi-node — self-hostable sync node for lofi apps7-8-Usage:9- lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]10- [--public-url <base>] [--open] [--storage-path <path>] [--memory]11- [--relay <url[,url…]>] [--no-relay]12- lofi-node start [--dir <dataDir>]13- lofi-node pair <node-ticket> [--dir <dataDir>]14- lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]15- lofi-node ticket list [--dir <dataDir>]16- lofi-node ticket revoke <id> [--dir <dataDir>]17- lofi-node status [--dir <dataDir>]18-```19-20-The data directory defaults to `./lofi-node-data` everywhere.21-22-## init23-24-Creates the data directory and writes [`config.json`](configuration.md): a generated Jazz app id (or25-`--app-id` to pin one), fresh backend and admin secrets, and your choices.26-27-| Flag | Meaning |28-| ----------------------- | --------------------------------------------------------------------------------------------------------- |29-| `--port <n>` | Fixed public port for the gate; auto-allocated when omitted. |30-| `--public-url <base>` | Base URL embedded in issued tickets (use the reachable address). |31-| `--open` | Opt out of ticket gating — anyone who can reach the port can sync. CLI inits default to **ticket-gated**. |32-| `--storage-path <path>` | SQLite store on any mounted location; probed writable at boot. |33-| `--memory` | Ephemeral in-memory store. |34-| `--relay <url[,url…]>` | Use your own iroh relay(s) instead of the public n0 servers ([why](beyond-the-lan.md)). Repeatable. |35-| `--no-relay` | No relays at all: direct connections only. |36-37-## start38-39-Starts the daemon from an initialized directory: boots the loopback-only Jazz server, opens the40-public gate, loads the native transport when available, and prints the gate URL plus the41-node-pairing ticket when the mesh is up.42-43-## pair44-45-`lofi-node pair <node-ticket>` elects the peer as this node's upstream — the node becomes a leaf46-relaying sync and catalogue traffic over iroh. The argument is a node-pairing `endpoint…` string47-from the peer's `start` output, not an app ticket (the CLI rejects app tickets with a pointed48-message). Re-electable at runtime; the public port does not change.49-50-## ticket issue / list / revoke51-52-`issue` mints an app-connect ticket and prints it **exactly once** — the node stores only a digest.53-`--label` names it for your bookkeeping, `--url` overrides the embedded base for this ticket, and54-`--provision` mints a provision-scoped ticket that can also administer the store55-([scopes](tickets-explained.md)).56-57-`list` shows every issued ticket: id, scope, label, issued time, and revocation state. `revoke58-<id>`59-invalidates one; a running daemon picks the change up within seconds and closes the ticket's live60-connections with close code 4001 — no restart, no IPC.61-62-## status63-64-Prints configuration and live state without changing anything: access mode, storage, relay election,65-issued tickets, upstream election, and mesh state (up with connection stats, off, or unavailable66-with the reason).@@ -1,72 +0,0 @@
1-# Configuration2-3-<!-- Source: FelineStateMachine/lofi-node src/config.ts (NodeConfig, migration) and4- src/appticket.ts (tickets.json). -->5-6-A node's durable state is two JSON files in its data directory, plus a small set of environment7-variables. Both files are owned by the node; edit `config.json` only with the daemon stopped.8-9-## config.json (v2)10-11-```jsonc12-{13- "v": 2,14- "appId": "cfe52e44-…", // Jazz app id, generated at init (UUID — required by the engine)15- "backendSecret": "lofi_backend_…", // Jazz credential; server-side only16- "adminSecret": "lofi_admin_…", // authorizes schema/permission administration17- "listenPort": 4802, // optional fixed public port; auto-allocated when absent18- "access": "ticket", // "ticket" (CLI-init default) | "open" (dev; library default)19- "storage": { "type": "sqlite", "path": "/mnt/nas/lofi" }, // or { "type": "memory" }20- "publicUrl": "http://192.168.1.10:4802", // base embedded in issued tickets21- "upstream": "none", // "none" | { "url": "wss://…" } | { "peer": "endpoint…" }22- "relay": "n0", // "n0" (default when absent) | { "urls": ["https://…"] } | "disabled"23- "allowLocalFirstAuth": true24-}25-```26-27-- **`access`** — `ticket` puts the access gate in front of everything; `open` proxies28- unauthenticated (the gate still owns the public port and the Jazz server still binds29- loopback-only).30-- **`storage`** — the honest engine surface: a SQLite directory (optional path, probed writable at31- boot) or memory. See [storage choices](storage-choices.md).32-- **`upstream`** — `"none"` for a root node; `{ "peer": … }` for an iroh-paired leaf33- ([pairing](pair-two-homes.md)); `{ "url": … }` for a plain HTTP(S) upstream Jazz server.34-- **`relay`** — which relay servers the node's iroh endpoint uses for hole-punching assistance and35- as a fallback path when a direct connection cannot be established. `"n0"` (the default when36- absent) uses the public n0-computer relays: rate-limited, no SLA, meant for development and37- testing. `{ "urls": […] }` points at relays you run ([bring your own relay](beyond-the-lan.md));38- `"disabled"` forgoes relays entirely, including relay-assisted address discovery. The elected39- relay travels inside this node's pairing tickets, so peers need no matching configuration.40-- **Secrets** stay in this file and never transit issued tickets; a provision-scoped ticket causes41- the gate to inject `adminSecret` server-side rather than revealing it.42-43-**v1 migration**: v1 files load transparently with legacy defaults applied (`access: "open"`, SQLite44-storage) and are rewritten as v2 on the next save.45-46-## tickets.json47-48-```jsonc49-{50- "v": 1,51- "tickets": [52- {53- "id": "a1b2c3d4e5f6", // first 12 hex chars of the secret's SHA-25654- "scope": "provision", // absent means "sync"55- "label": "laptop-admin",56- "secretHash": "…", // full SHA-256 hex — the secret itself is never stored57- "createdAt": "2026-07-18T…",58- "revokedAt": "2026-07-19T…" // present only once revoked59- }60- ]61-}62-```63-64-The CLI writes this file; a running daemon only reads it, picking up changes by modification time —65-which is why `ticket issue` and `ticket revoke` need no IPC and no restart.66-67-## Environment variables68-69-| Name | Effect |70-| ----------------- | --------------------------------------------------------------------- |71-| `LOFI_NODE_DEBUG` | `1` enables `[gate]`/`[tunnel]` debug logging to stderr. |72-| `LOFI_NODE_IROH` | Explicit path to the native transport library, overriding resolution. |@@ -1,59 +0,0 @@
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.@@ -1,63 +0,0 @@
1-# Pair two homes2-3-<!-- Source: FelineStateMachine/lofi-node README.md, src/tunnel.ts, src/node.ts (pair/status). -->4-5-Two nodes in two places — a home server and a studio machine, say — can hold the same store and6-converge over [iroh](https://iroh.computer): dialed by public key, hole-punched through NATs, no7-static IPs, no port forwarding, no cloud dependency. Devices keep syncing against whichever node is8-closest; the nodes reconcile with each other.9-10-## Pairing11-12-Every node with the mesh up prints a **node-pairing ticket** on start — an iroh `endpoint…` string,13-distinct from the `lofisync1.` app tickets users enroll. On the second node:14-15-```sh16-lofi-node pair endpoint… --dir ./data17-```18-19-or at runtime, without a restart and without the public port changing:20-21-```ts22-await node.pair(otherNodeTicket);23-```24-25-Pairing is an **upstream election**: the paired node becomes a leaf that forwards to the root. Sync26-WebSockets and catalogue HTTP (schema reads, deploys) tunnel over iroh, so administering either node27-reaches the root — [provisioning through a leaf](provision-a-store.md) lands on the root's store,28-verified end to end in lofi-node's test suite.29-30-## What the tunnel carries31-32-One iroh connection carries exactly one WebSocket or one HTTP request. Each connection opens with a33-small HELLO frame naming what it carries (`ws` with path and subprotocol, or `http` with method,34-path, and headers); frames then bridge both ways until either side closes. This is why the35-browser-facing protocol never changes: the leaf re-originates exactly what Jazz expects.36-37-## Observing the mesh38-39-```ts40-node.status().mesh;41-// { state: "up", nodeId, ticket, connections: [{ direction, rtt, paths }] }42-```43-44-`connections` reports each live tunnel with its round-trip time and path counts — direct versus45-relay — so you can see whether hole-punching succeeded or traffic is riding an iroh relay. The other46-two states are honest degradations: `{ state: "off" }` when the mesh is disabled, and47-`{ state: "unavailable", reason }` when the native layer could not load — the Jazz server still runs48-LAN-only, and `ticket()`/`pair()` throw `MeshUnavailableError` rather than pretending.49-50-## The relay's role51-52-Relays assist that hole-punching (address discovery) and carry traffic only for the connections that53-cannot go direct; in practice the large majority of iroh connections are direct. The relay is a54-**per-node election** (`relay` in [config.json](configuration.md)): whichever relay a node elects55-travels inside its pairing ticket, so the dialing side needs no matching configuration. The default56-is n0-computer's public relays, which are rate-limited and meant for development. For a production57-mesh, [bring your own](beyond-the-lan.md).58-59-## Choosing a root60-61-The root holds the authoritative store; leaves relay to it. Put the root on the machine with the62-most reliable storage ([storage choices](storage-choices.md)) and pair outward from there. A leaf's63-election can be redone at runtime (`pair` again) if the topology changes.@@ -1,89 +0,0 @@
1-# Provision a store2-3-<!-- Source: lofi docs/store-provisioning.md + FelineStateMachine/lofi-node4- tests/provisioning_test.ts (the executable version of this tutorial). -->5-6-A fresh node has no schema deployed, and an app must never sync against a store in that state — the7-engine's writes hang rather than fail. Provisioning is the explicit step you opt into that creates8-or updates the store's schema, and this tutorial walks it end to end: first app in, then a **second9-app joining the store that already holds the first app's data**. Every step here is also executable10-— lofi-node's `tests/provisioning_test.ts` runs this exact flow through the gate in CI.11-12-## 0. The opt-in: a provision ticket13-14-```sh15-lofi-node ticket issue --dir ./data --provision --label laptop-admin16-```17-18-A provision-scoped ticket is a strict superset of a sync ticket: transport plus store19-administration. For provisioning requests the gate injects the node's own admin secret and strips20-any inbound one — the secret never transits the client, and possession of the ticket **is** the21-opt-in. Issue one per provisioning context, not per device.22-23-## 1. Preflight24-25-Any valid ticket — sync scope included — may ask the node where the store stands:26-27-```28-GET <ticket-url>/store-status29-→ { "v": 1, "appId": "…", "schema": { "deployed": false } }30-```31-32-On the app side this is `readTicketStoreStatus(ticketUrl)`, 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.@@ -1,93 +0,0 @@
1-# Self-host your first sync node2-3-<!-- Source: FelineStateMachine/lofi-node docs/hosting-lofi-apps.md and cli.ts; validated flow. -->4-5-By the end of this tutorial you will have a sync node running on your own machine, an app-connect6-ticket in hand, and a lofi app you use syncing against it. You are the app's user here, not its7-developer: the app stays exactly as it was deployed, and pointing it at your node is entirely your8-call. Nothing below requires a cloud account, a static IP, or TLS on a trusted LAN — one binary and9-one paste.10-11-## 1. Install lofi-node12-13-Install the tool from [JSR](https://jsr.io/@nzip/lofi-node), which puts `lofi-node` on your PATH:14-15-```sh16-deno install -g -A -n lofi-node jsr:@nzip/lofi-node/cli17-```18-19-For a single self-contained binary instead, compile from a checkout of20-[lofi-node](https://github.com/FelineStateMachine/lofi-node):21-22-```sh23-deno task compile # → dist/lofi-node24-```25-26-Either way, full support covers macOS arm64 and Linux x86_64 (including the27-[container image](beyond-the-lan.md)); on Windows and arm64 Linux the node is limited28-([why](troubleshooting.md)).29-30-## 2. Initialize and start31-32-```sh33-lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:480234-lofi-node start --dir ./data35-```36-37-`init` writes `data/config.json`: a generated Jazz app id, the node's secrets, your access mode, and38-storage choice. New inits are **ticket-gated** — only requests carrying an issued app-ticket secret39-reach the store; `--open` opts out for dev setups. The embedded Jazz server binds loopback-only; the40-node's access gate owns the public port, so the gate is both enforcement and reachability.41-42-`--public-url` is the base address that will be embedded in the tickets you issue — use the address43-other devices on your network can reach. `--storage-path /mnt/nas/lofi` puts the store on any44-mounted location; `--memory` keeps everything ephemeral ([storage choices](storage-choices.md)).45-46-`start` prints the gate URL and, when the mesh is up, the node-pairing ticket for47-[pairing a second node](pair-two-homes.md).48-49-## 3. Issue an app ticket50-51-```sh52-lofi-node ticket issue --dir ./data --label phone53-# → lofisync1.eyJ2IjoxLCJhcHBJZCI6… (shown once; the secret is never stored)54-```55-56-The ticket is one string carrying the store's app id and the gate URL with an access secret embedded57-in its path. It is displayed exactly once — the node keeps only a digest. Issue one per device or58-context (`--label` is for your own bookkeeping), so revocation stays scoped:59-60-```sh61-lofi-node ticket list --dir ./data62-lofi-node ticket revoke <id> --dir ./data # live connections close within seconds63-```64-65-Scopes matter later: a plain ticket is transport-only; `ticket issue --provision` mints one that can66-also [set up the store's schema](provision-a-store.md). What the string actually contains and why67-the secret lives in the URL path: [Tickets explained](tickets-explained.md).68-69-## 4. Enroll the ticket in a lofi app70-71-Paste the ticket into the app — a lofi app with sync exposes enrollment in its account UI. That72-declares your node as the device's sync location and elects sync in one step; the data you already73-made in the app pushes up under the same account identity. Nothing about the app changes or74-redeploys — pointing it at your node is your call, not the app developer's.75-76-(If you are the app developer: the framework side is one call, `enrollSyncTicket`, documented in77-[Sync and recovery](/docs/sync-and-recovery).)78-79-## 5. Verify80-81-- `GET <gate-url>/health` answers without a ticket — safe for liveness probes.82-- `GET <ticket-url>/store-status` answers for any valid ticket with metadata about the store's83- schema. A fresh node reports `{ "schema": { "deployed": false } }` — that is expected, and it is84- exactly the state the [store provisioning tutorial](provision-a-store.md) resolves. An app should85- not sync against a store with no schema; its writes would hang rather than fail.86-- `lofi-node status --dir ./data` shows configuration, tickets, upstream, and mesh state without87- starting anything.88-89-## Where you are90-91-You have a node, a ticket, and an enrolled app — but the store may not have a schema yet. Continue92-with [Provision a store](provision-a-store.md), or connect a second node with93-[Pair two homes](pair-two-homes.md).@@ -1,73 +0,0 @@
1-# Sliceable apps and shared stores2-3-<!-- Source: lofi #109 (design + conformance), lofi docs/store-provisioning.md and4- docs/examples/nested-namespaces.md, lofi-node #2; jazz-tools typed-app surface. -->5-6-Your store is shared infrastructure. You bring the store; the apps you use are tenants in it. This7-guide is the mental model that makes that safe: what a slice is, how one store's schema grows app by8-app, and why an app cannot damage a neighbor even though the store trusts its administrator.9-10-## A slice is a naming discipline, not a partition11-12-Jazz's pinned DSL compiles **one schema per store** — one hash, one migration lineage, one query13-planner. `defineSliceableApp` derives typed sub-app views over that single schema, and lofi's14-`s.defineNestedApp` builds the naming layer on top: an app declares namespaces, its tables flatten15-to global names with a reserved separator (`taskapp` + `tasks` → `taskapp__tasks`), and app code16-uses unprefixed typed handles as if the namespace were its whole world.17-18-Nothing at the storage layer partitions those tables — every table is a sibling of every other in19-the one store. The **slice** is the set of tables under an app's declared namespaces, and the20-namespace prefix is what makes ownership checkable.21-22-## The store's schema grows slice by slice23-24-The store enforces exactly one schema at a time — the head of a migration lineage. When a second app25-joins an occupied store, its provisioning _merges_: the stored schema is fetched verbatim, the app's26-missing tables are appended, and the union deploys as an ordinary migration advancing the head.27-Devices running the previous schema keep working, because their schema stays connected to the head28-through the migration chain.29-30-Four states describe any store relative to any app — the same taxonomy the tooling reports:31-32-| State | Meaning |33-| -------------------- | ------------------------------------------------------------------- |34-| `ok` | The enforced schema carries this app's slice exactly. |35-| `no_schema` | Nothing is deployed. Writes would hang; never sync in this state. |36-| `schema_out_of_date` | The store lacks some of this app's tables. Remedy: opt-in update. |37-| `schema_drift` | The store's copy of the app's own namespaces differs unexplainably. |38-39-`no_schema` deserves its emphasis: against an empty store the engine's writes hang rather than fail.40-That is why the node exposes a metadata-only `store-status` preflight any valid ticket can call, and41-why lofi classifies before attaching sync instead of writing and hoping. `schema_drift` is the42-opposite discipline — it is surfaced with the differing tables and **never** auto-repaired; a store43-whose copy of your namespace changed under you needs a human, not a merge.44-45-## The honesty invariant46-47-An app's provisioning may create tables **only under its own declared namespaces**. Everything else48-in the store — sibling apps' tables _and their access policies_ — carries through a merge49-byte-for-byte. Any generated change naming a table outside the app's namespaces is a hard error50-before a single request is made. A flat (non-nested) app declares no namespace and therefore gets no51-tenant rights: it may only provision a store it wholly owns.52-53-Two properties fall out, both verified against a real server in lofi's conformance suite:54-55-- **Continuity.** The first app's devices write and read across a second app's merge untouched.56-- **Policy preservation.** A user who couldn't read a gated row of the first app before the merge57- still can't after it — the joining app re-publishes the union permissions with the sibling's58- policies recovered from the store, unchanged.59-60-Stated honestly: this is a _framework_ boundary, not cryptography. Store administration (the admin61-secret, or a provision-scoped ticket) can technically do anything; the invariant keeps apps built on62-lofi honest through the surface they actually use. The node-side complement is that a provision63-ticket never exposes the admin secret itself — the gate injects it — so holding provisioning power64-never means holding a credential you could leak.65-66-## Where each piece lives67-68-- Declaring namespaces and per-namespace permissions:69- [nested app namespaces](/docs/examples/nested-namespaces) in the framework docs.70-- The classifier and merge flow an app runs: [store provisioning](/docs/store-provisioning).71-- Doing it against a node with a provision ticket: [Provision a store](provision-a-store.md).72-- The ticket semantics underneath: [Tickets explained](tickets-explained.md) and the73- [app-ticket contract](/node/docs/app-ticket).@@ -1,45 +0,0 @@
1-# Storage choices2-3-<!-- Source: FelineStateMachine/lofi-node docs/hosting-lofi-apps.md ("Storage choice") and4- src/config.ts (StorageConfig). -->5-6-The node's store is where your synced data durably lives, and the choice is deliberately narrow7-because the underlying engine's honest surface is narrow: jazz-napi supports exactly a SQLite8-directory or memory today. The `storage.type` discriminator in `config.json` is the seam future9-providers slot into.10-11-## SQLite on a path you choose12-13-```sh14-lofi-node init --storage-path /mnt/nas/lofi15-```16-17-```jsonc18-// config.json19-"storage": { "type": "sqlite", "path": "/mnt/nas/lofi" }20-```21-22-The path can be any mounted location — a NAS volume, a synced directory, an external disk. It is23-probed writable at boot; an unwritable location fails fast by name instead of silently degrading.24-Omitting the path keeps the store inside the data directory (`<dataDir>/jazz`).25-26-## Memory27-28-```sh29-lofi-node init --memory30-```31-32-Everything is ephemeral — useful for tests, demos, and throwaway topologies. A restart is a fresh,33-schema-less store: enrolled apps will classify it `no_schema` again and prompt for34-[provisioning](provision-a-store.md).35-36-## Off-site durability37-38-For cloud-grade durability without a cloud sync service, replicate the SQLite file: a39-Litestream-style continuous replica to S3-compatible storage, or snapshots of the volume the path40-lives on. [Pairing a second node](pair-two-homes.md) is the other half of the story — replication at41-the Jazz layer rather than the file layer — and the two compose.42-43-One honest caveat: back up the **data directory** alongside the store. `config.json` holds the44-node's secrets and app id, and `tickets.json` holds the digests that make issued tickets valid; a45-restored store without them is reachable by nobody.@@ -1,68 +0,0 @@
1-# Tickets explained2-3-<!-- Source: FelineStateMachine/lofi-node docs/app-ticket.md (the normative contract) and4- src/ticket.ts; lofi package/runtime/data-sink.ts for the app side. -->5-6-lofi-node uses two kinds of ticket, and confusing them is the most common early stumble. This guide7-is the conceptual tour; the [app-ticket contract](/node/docs/app-ticket) is the normative spec the8-lofi side implements against.9-10-## Two ticket kinds11-12-- A **node-pairing ticket** (`endpoint…`) is an iroh endpoint address. It names a _node_ so another13- node can dial it. It is used by `lofi-node pair` and never by browsers or apps.14-- An **app-connect ticket** (`lofisync1.…`) is the credential _you_ hand to a lofi app so it syncs15- against your node. It carries the store's app id and the gate URL with a 256-bit secret embedded16- in its path.17-18-## Why the secret lives in the URL path19-20-Jazz clients preserve a base path in their server URL and reject query parameters. By making the21-ticket's URL `http(s)://host:port/t/<secret>`, every request the client ever makes — sync WebSocket22-connects, catalogue reads, admin calls — carries the secret with **zero client changes**. The gate23-validates the secret (timing-safe, digest against digest), strips the prefix, and proxies to the24-loopback-only Jazz server. The lofi app uses the ticket URL verbatim as its sync server; that is the25-whole integration.26-27-## Scopes28-29-- **`sync`** (the default, and what an absent scope means — every pre-scope ticket keeps meaning30- transport-only): sync, catalogue reads that transport needs, and the metadata-only `store-status`31- preflight. Admin routes answer the same `401 {"error":"invalid_ticket"}` as an unknown secret, so32- a probe learns nothing.33-- **`provision`**: a strict superset — everything above plus store administration. The gate injects34- the node's own `X-Jazz-Admin-Secret` for provisioning requests and strips any inbound one, so the35- admin secret never transits the client. Possession of a provision ticket **is** the36- store-administration opt-in; issue one per provisioning context, not per device.37-38-A ticket with an unrecognized scope is rejected outright by the lofi parser — never silently granted39-less than it claims.40-41-## Revocation42-43-- The node stores only SHA-256 digests; the ticket string is displayable once, at issuance.44-- Unknown and revoked secrets are indistinguishable to probers: both get45- `401 {"error":"invalid_ticket"}`, on HTTP and on the WebSocket upgrade.46-- Revoking a ticket mid-session closes its live WebSockets with close code **4001** within a couple47- of seconds. The app treats the stored sink as dead and surfaces re-enrollment — it never silently48- retries forever.49-50-## The security posture, honestly51-52-An app ticket is a **bearer credential** with 256-bit entropy. Anyone holding it can sync as an53-authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on54-top, enforced by the deployed policy, not by the ticket. Plain http is acceptable on a trusted LAN;55-beyond one, front the gate with TLS — installed PWAs generally require a secure origin anyway. Issue56-tickets per device or context so revocation is scoped.57-58-## What the app does with it59-60-You paste; the app does the rest. The ticket is validated (against the same rules this node61-enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync62-location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local63-record (encrypted at rest under a device-bound key) and never surfaces it through its session64-snapshot — only the host and your label show. A provision ticket gets split first: the app asks the65-node for a derived sync ticket to store, and the provision original is either sealed behind your66-passkey or kept only in memory with your password manager holding the durable copy. For app67-developers, the framework call behind that paste is `enrollSyncTicket`, documented in68-[Sync and recovery](/docs/sync-and-recovery).@@ -1,64 +0,0 @@
1-# Troubleshooting and compatibility2-3-<!-- Source: FelineStateMachine/lofi-node README.md, docs/hosting-lofi-apps.md4- ("Validation status"), src/errors.ts, src/native/loader.ts. -->5-6-The node's failure modes are designed to be nameable. Work from the signal you see.7-8-## Mesh: `unavailable`9-10-`status().mesh` reports `{ state: "unavailable", reason: … }` when the native transport could not11-load; the Jazz server still runs LAN-only and `ticket()`/`pair()` throw `MeshUnavailableError`12-rather than degrade silently. Causes, in resolution order:13-14-1. An explicit `LOFI_NODE_IROH` path that doesn't exist.15-2. Running from source without building the native layer (`cargo build --release` in16- `native/iroh-js`).17-3. An unsupported platform — most notably **Windows**, a documented gap (the `*-gnu` build path18- needs a `libnode.dll` import library the upstream toolchain ships only for msvc). Windows nodes19- run LAN-only until that resolves.20-21-On **arm64 Linux** the limitation sits one layer deeper: the Jazz engine (jazz-napi) publishes no22-linux-arm64-gnu build, so the node cannot start at all there. Release binaries and the container23-image cover macOS arm64 and Linux x86_64 ([platform notes](beyond-the-lan.md)).24-25-The compiled binary embeds digest-pinned prebuilts; a cache extraction that doesn't match its pin26-fails loudly rather than loading.27-28-## 401 `invalid_ticket`29-30-Unknown ticket, revoked ticket, or a sync-scoped ticket on an admin route — deliberately one answer31-for all three. If a previously working app starts seeing it, the ticket was revoked: re-enroll with32-a fresh one. If provisioning sees it, the ticket lacks `provision` scope.33-34-## WebSocket closes with 400135-36-The ticket was revoked mid-session. This is the live-revocation path working as designed; the app37-should treat its stored sync location as dead and surface re-enrollment, not retry.38-39-## 502 `store_unavailable`40-41-The gate is up but its store is not — the loopback Jazz process, or on a leaf, the tunnel to the42-root. Check `lofi-node status` and, for a leaf, `status().mesh.connections`.43-44-## Writes hang; nothing errors45-46-Almost always a store with **no deployed schema** — the engine hangs rather than fails in this47-state. `GET <ticket-url>/store-status` reporting `{ "deployed": false }` confirms it; the remedy is48-[provisioning](provision-a-store.md). lofi apps classify before attaching sync for exactly this49-reason.50-51-## Version mismatch52-53-A node must run the **same Jazz alpha** as the apps it serves. Mismatches surface as protocol-level54-failures (schema hash and catalogue errors) that no network configuration fixes. Confirm both pins55-before debugging anything else.56-57-## Compatibility status, honestly58-59-The whole flow is validated end to end against lofi's reference app with real browser clients:60-build, boot, offline edits, reconvergence, and the full provisioning lifecycle through the gate and61-through an iroh-paired leaf. One known upstream finding: lofi's opt-in browser convergence gate62-fails at its final stage with a catalogue error **identically against lofi-node and against lofi's63-own first-party dev server** — a controlled experiment placing the issue in the pinned Jazz alpha,64-not the node. lofi-node's contract docs carry the current validation table.@@ -1,13 +1,15 @@
11 /**2- * Assembles the `/node/docs` section into `website/node-docs-gen/` from two3- * sources, per `website/node-docs-manifest.json`:2+ * Assembles the `/node/docs` section into `website/node-docs-gen/` from a3+ * lofi-node checkout, per `website/node-docs-manifest.json` (every `source`4+ * is `lofi-node:`-prefixed). The docs live with the node so its CI gates5+ * them; the site pins the checkout for reproducible builds.46 *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.7+ * - Site-voice pages (`docs/site/*.md` there) render as-is: links between8+ * manifest pages stay site-internal, links to anything else in the9+ * checkout are rewritten to the pinned ref on GitHub.10+ * - Contract pages (`"contract": true`, e.g. the app-ticket format) render11+ * verbatim with a provenance header and every relative link rewritten to12+ * GitHub — the file itself is the normative artifact.1113 *1214 * The checkout location comes from `LOFI_NODE_DIR` (default `../lofi-node`);1315 * the ref used in provenance links comes from `LOFI_NODE_REF` (default: the@@ -16,7 +18,7 @@
1618 1719 const OUT_DIR = "website/node-docs-gen";1820 19-type ManifestItem = { id: string; label: string; source: string };21+type ManifestItem = { id: string; label: string; source: string; contract?: boolean };2022 type ManifestSection = { label: string | null; items: ManifestItem[] };2123 type Manifest = { checkoutRepo: string; sections: ManifestSection[] };2224 @@ -45,12 +47,38 @@ async function checkoutRef(): Promise<string> {
4547 4648 const ref = await checkoutRef();4749 48-/** Rewrites a contract page's relative links to the pinned checkout on GitHub. */49-function rewriteContractLinks(markdown: string, sourceDir: string): string {50+// Resolves a relative link target against its page's checkout directory,51+// dropping any fragment; used to decide whether the target is a sibling52+// manifest page (stays site-internal) or checkout material (goes to GitHub).53+function resolveCheckoutPath(sourceDir: string, target: string): string {54+ const withoutFragment = target.split("#")[0];55+ const segments = `${sourceDir}/${withoutFragment}`.split("/");56+ const resolved: string[] = [];57+ for (const segment of segments) {58+ if (segment === "" || segment === ".") continue;59+ if (segment === "..") resolved.pop();60+ else resolved.push(segment);61+ }62+ return resolved.join("/");63+}64+65+/**66+ * Rewrites a page's relative links to the pinned checkout on GitHub. For67+ * site-voice pages, links whose target is another manifest page are left68+ * alone — Docusaurus resolves them inside the generated section, keeping69+ * navigation on-site.70+ */71+function rewriteCheckoutLinks(72+ markdown: string,73+ sourceDir: string,74+ siteInternalTargets: ReadonlySet<string>,75+): string {5076 return markdown.replace(5177 /\]\((?!https?:|\/|#)([^)]+)\)/g,52- (_match, target: string) =>53- `](https://github.com/${manifest.checkoutRepo}/blob/${ref}/${sourceDir}/${target})`,78+ (match: string, target: string) =>79+ siteInternalTargets.has(resolveCheckoutPath(sourceDir, target))80+ ? match81+ : `](https://github.com/${manifest.checkoutRepo}/blob/${ref}/${sourceDir}/${target})`,5482 );5583 }5684 @@ -68,42 +96,48 @@ const manifestItems = manifest.sections.flatMap((section) => section.items);
6896 await Deno.remove(OUT_DIR, { recursive: true }).catch(() => undefined);6997 await Deno.mkdir(OUT_DIR, { recursive: true });7098 99+// Non-contract pages whose sibling links must survive as site navigation.100+const siteInternalTargets = new Set(101+ manifestItems102+ .filter((item) => !item.contract)103+ .map((item) => item.source.slice("lofi-node:".length)),104+);105+71106 let assembled = 0;72107 for (const item of manifestItems) {108+ if (!item.source.startsWith("lofi-node:")) {109+ console.error(`Manifest source ${item.source} (id "${item.id}") is not lofi-node:-prefixed.`);110+ Deno.exit(1);111+ }112+ const relative = item.source.slice("lofi-node:".length);113+ const path = `${checkoutDir}/${relative}`;73114 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- }115+ try {116+ markdown = await Deno.readTextFile(path);117+ } catch {118+ console.error(119+ `Missing lofi-node checkout file ${path}. Set LOFI_NODE_DIR to a checkout of ` +120+ `${manifest.checkoutRepo} (default ../lofi-node).`,121+ );122+ Deno.exit(1);95123 }124+ const sourceDir = relative.split("/").slice(0, -1).join("/");125+ markdown = item.contract126+ ? provenanceHeader(relative) + rewriteCheckoutLinks(markdown, sourceDir, new Set())127+ : rewriteCheckoutLinks(markdown, sourceDir, siteInternalTargets);96128 await Deno.writeTextFile(`${OUT_DIR}/${item.id}.md`, markdown);97129 assembled++;98130 }99131 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")) {132+// Site-voice pages in the checkout but not in the manifest would silently133+// miss the sidebar and the llms corpus — treat them as drift.134+for await (const entry of Deno.readDir(`${checkoutDir}/docs/site`)) {103135 if (!entry.isFile || !entry.name.endsWith(".md")) continue;104- const source = `docs/node/${entry.name}`;136+ const source = `lofi-node:docs/site/${entry.name}`;105137 if (!manifestItems.some((item) => item.source === source)) {106- console.error(`docs/node/${entry.name} is not listed in website/node-docs-manifest.json.`);138+ console.error(139+ `checkout docs/site/${entry.name} is not listed in website/node-docs-manifest.json.`,140+ );107141 Deno.exit(1);108142 }109143 }@@ -153,7 +153,7 @@ const config: Config = {
153153 [154154 "@docusaurus/plugin-content-docs",155155 {156- // Assembled by tools/node_docs.ts from docs/node/ plus contract pages156+ // Assembled by tools/node_docs.ts from the pinned lofi-node checkout157157 // rendered out of the pinned lofi-node checkout; no edit URL because158158 // pages have two possible homes — each page names its source.159159 id: "node",@@ -5,7 +5,7 @@
55 {66 "label": null,77 "items": [8- { "id": "README", "label": "Overview", "source": "docs/node/README.md" }8+ { "id": "README", "label": "Overview", "source": "lofi-node:docs/site/README.md" }99 ]1010 },1111 {@@ -15,17 +15,17 @@
1515 {1616 "id": "self-host-a-sync-node",1717 "label": "Self-host your first sync node",18- "source": "docs/node/self-host-a-sync-node.md"18+ "source": "lofi-node:docs/site/self-host-a-sync-node.md"1919 },2020 {2121 "id": "pair-two-homes",2222 "label": "Pair two homes",23- "source": "docs/node/pair-two-homes.md"23+ "source": "lofi-node:docs/site/pair-two-homes.md"2424 },2525 {2626 "id": "provision-a-store",2727 "label": "Provision a store",28- "source": "docs/node/provision-a-store.md"28+ "source": "lofi-node:docs/site/provision-a-store.md"2929 }3030 ]3131 },@@ -36,53 +36,53 @@
3636 {3737 "id": "sliceable-apps-and-shared-stores",3838 "label": "Sliceable apps and shared stores",39- "source": "docs/node/sliceable-apps-and-shared-stores.md"39+ "source": "lofi-node:docs/site/sliceable-apps-and-shared-stores.md"4040 },4141 {4242 "id": "tickets-explained",4343 "label": "Tickets explained",44- "source": "docs/node/tickets-explained.md"44+ "source": "lofi-node:docs/site/tickets-explained.md"4545 },4646 {4747 "id": "storage-choices",4848 "label": "Storage choices",49- "source": "docs/node/storage-choices.md"49+ "source": "lofi-node:docs/site/storage-choices.md"5050 },5151 {5252 "id": "beyond-the-lan",5353 "label": "Deploying beyond the LAN",54- "source": "docs/node/beyond-the-lan.md"54+ "source": "lofi-node:docs/site/beyond-the-lan.md"5555 }5656 ]5757 },5858 {5959 "label": "Reference",6060 "items": [61- { "id": "cli", "label": "CLI", "source": "docs/node/cli.md" },61+ { "id": "cli", "label": "CLI", "source": "lofi-node:docs/site/cli.md" },6262 {6363 "id": "configuration",6464 "label": "Configuration",65- "source": "docs/node/configuration.md"65+ "source": "lofi-node:docs/site/configuration.md"6666 },6767 {6868 "id": "app-ticket",6969 "label": "App-ticket contract",70- "source": "lofi-node:docs/app-ticket.md"70+ "source": "lofi-node:docs/app-ticket.md", "contract": true7171 },7272 {7373 "id": "http-surface",7474 "label": "HTTP surface",75- "source": "docs/node/http-surface.md"75+ "source": "lofi-node:docs/site/http-surface.md"7676 },7777 {7878 "id": "architecture",7979 "label": "Architecture and provenance",80- "source": "docs/node/architecture.md"80+ "source": "lofi-node:docs/site/architecture.md"8181 },8282 {8383 "id": "troubleshooting",8484 "label": "Troubleshooting and compatibility",85- "source": "docs/node/troubleshooting.md"85+ "source": "lofi-node:docs/site/troubleshooting.md"8686 }8787 ]8888 }