clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

docs/adopt-site-pages
lofi-node / docs / site / provision-a-store.md
provision-a-store.md 4.3K rendered
1# Provision a store23<!-- Source: lofi docs/store-provisioning.md + FelineStateMachine/lofi-node4 tests/provisioning_test.ts (the executable version of this tutorial). -->56A fresh node has no schema deployed, and an app must never sync against a store in that state — the7engine's writes hang rather than fail. Provisioning is the explicit step you opt into that creates8or updates the store's schema, and this tutorial walks it end to end: first app in, then a **second9app 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.1112## 0. The opt-in: a provision ticket1314```sh15lofi-node ticket issue --dir ./data --provision --label laptop-admin16```1718A provision-scoped ticket is a strict superset of a sync ticket: transport plus store19administration. For provisioning requests the gate injects the node's own admin secret and strips20any inbound one — the secret never transits the client, and possession of the ticket **is** the21opt-in. Issue one per provisioning context, not per device.2223## 1. Preflight2425Any valid ticket — sync scope included — may ask the node where the store stands:2627```28GET <ticket-url>/store-status29→ { "v": 1, "appId": "…", "schema": { "deployed": false } }30```3132On the app side this is `readTicketStoreStatus(ticketUrl)` — a browser app obtains the provision URL33from its capability custody (held after enrollment, or unlocked through its passkey ceremony; see34[Sync and recovery](/docs/sync-and-recovery)) — and with administration in hand the richer35classifier is `readStoreStatus(root, target)`, which reports one of `ok`, `no_schema`,36`schema_out_of_date`, or `schema_drift`. `deployed: false` here means `no_schema`: create.3738## 2. First app: create3940With the app's nested schema and merged permissions bundle:4142```ts43import { provisionStore } from "@nzip/lofi/schema/store";44import { permissions, root } from "./schema.ts";4546await provisionStore({47 app: root,48 permissions,49 target: { serverUrl: provisionTicketUrl, appId: ticketAppId },50});51// → { status: "created", headHash: "…" }52```5354Note the absent `adminSecret` — the provision ticket's gate injection carries administration.55`store-status` now reports `deployed: true` with the head hash, and enrolled devices sync normally.5657## 3. Second app: merge into an occupied store5859Time passes; the store holds `taskapp` data. Now a notes app — its own namespaces, its own60deployment — enrolls against the same store. Its preflight reports `schema_out_of_date` with exactly61its missing tables, and its opt-in provisioning **merges its slice** rather than replacing anything:6263- The stored head schema is fetched verbatim and only extended — the first app's tables keep their64 exact serialization.65- The union deploys as an ordinary migration advancing the store's one permissions head, with the66 first app's policies preserved unchanged and the second app's swapped in for its own tables only.67- The joining app's own compiled schema is registered and connected to the head, so its clients are68 never disconnected.6970```ts71await provisionStore({ app: notesRoot, permissions: notesPermissions, target });72// → { status: "updated", headHash: "…" }73```7475The first app's devices keep working untouched across the merge — their schema stays connected76through the migration chain — and its access rules still hold: a user who couldn't read a gated77`taskapp` row before the merge still can't after it. Why apps can't step on each other here is the78subject of [Sliceable apps and shared stores](sliceable-apps-and-shared-stores.md).7980## 4. What provisioning refuses8182- **Drift is never auto-repaired.** If the store's copy of an app's own namespaces differs from what83 the app declares, provisioning throws with the differing tables. A human decides.84- **Out-of-namespace changes are hard errors** before any request is made.85- **Sync-scoped tickets cannot provision**: admin routes answer the same86 `401 {"error":"invalid_ticket"}` as an unknown ticket — nothing to enumerate.8788## Through a paired leaf8990Provisioning against a [paired](pair-two-homes.md) leaf lands on the root: catalogue writes ride the91iroh tunnel's HTTP path. Deploy wherever is convenient; the root's store is what advances.