docs: finish the contract-accuracy pass and the operator hardening docs
- commit
- e04a403e165de0c3b9884a2a7db66ede755e8d62
- parent
- 92023eabbc5267ba7f2a6f6547a2800a4c10b80a
- parent
- 02e056f4180039c7d0907ea669968b33c0c85eca
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 5 | ++-- |
| M | docs/ | 25 | +++++++++++-------- |
| M | docs/ | 21 | ++++++++++------ |
| M | docs/ | 40 | ++++++++++++++++++++++++++++++ |
| M | docs/ | 11 | ++++++-- |
| M | mod.ts | 2 | +- |
@@ -5,8 +5,9 @@
55 **Self-host the sync backend for [lofi](https://github.com/FelineStateMachine/lofi) apps: one daemon66 embedding a Jazz sync server, iroh peer-to-peer transport, and ticket-gated access.**77 8-Issue a ticket carrying location + secret; the app stores it passkey-encrypted and syncs against9-your node. Pair homes by ticket — no static IPs, no cloud — and choose where your data lives.8+Issue a ticket carrying location + secret; the app seals it at rest — admin capability behind the9+user's passkey — and syncs against your node. Pair homes by ticket — no static IPs, no cloud — and10+choose where your data lives.1011 1112 [Documentation](https://lofi.host/node) · [API reference](https://lofi.host/node/api) ·1213 [JSR package](https://jsr.io/@nzip/lofi-node) · [MIT License](LICENSE)@@ -47,8 +47,8 @@ The node's access gate validates the secret (timing-safe, digest vs digest), str
4747 ## Scopes: sync vs provision4848 4949 - **`sync`** (default): transport only. Admin/catalogue-mutating routes (`…/apps/<id>/admin/…`)50- answer the SAME `401 {"error":"invalid_ticket"}` as an unknown secret — nothing to enumerate. This51- is what lofi#109 calls "enrolling a ticket attaches transport only and never mutates the store."50+ answer the SAME `401 {"error":"invalid_ticket"}` as an unknown secret — nothing to enumerate.51+ Enrolling a ticket attaches transport only and never mutates the store.5252 - **`provision`**: a strict superset of sync — everything above PLUS store administration. For5353 provision-scoped HTTP requests the gate **injects the node's `X-Jazz-Admin-Secret` itself** (on5454 `/admin/*` and on catalogue reads like `/schemas` and `/schema/<hash>`, which the merge-deploy@@ -90,9 +90,9 @@ their lineage is.
9090 9191 ## Store-status preflight9292 93-Against a store with **no deployed schema, client writes hang indefinitely** (lofi#109's pinned94-failure surface) — so a sync-only client needs a preflight it can reach without the admin secret.95-Any valid ticket (sync scope included) may call:93+Against a store with **no deployed schema, client writes hang indefinitely** — so a sync-only client94+needs a preflight it can reach without the admin secret. Any valid ticket (sync scope included) may95+call:9696 9797 ```9898 GET <ticket.url>/store-status@@ -120,12 +120,15 @@ setups hold the admin secret and can query Jazz directly.
120120 121121 1. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`122122 mirrors the validation: prefix, `v: 1`, http(s) URL with a `/t/<43-char-secret>` path).123-2. App encrypts the ticket with the passkey-derived at-rest key (lofi `package/runtime/auth.ts`:124- `derivePrfSecret` → `deriveAtRestKey` → `encryptAtRest`) and stores the blob in localStorage125- (suggested key: `lofi:sync-location:<appId>`).126-3. On boot, after the user unlocks with their passkey: decrypt, use `ticket.url` as the runtime127- `serverUrl` override. The decrypted local data therefore reveals _location + access_ for the128- user's lofi data.123+2. A `provision`-scoped ticket is split before anything persists: the app calls the scope-down124+ exchange (above) and declares the derived sync ticket as its sink; the provision original is held125+ in memory and, on PRF-capable devices, sealed behind the user's passkey — otherwise the user's126+ password manager keeps the durable copy. Against a node without the exchange, the ticket enrolls127+ as pasted.128+3. The declared sink persists only as a sealed envelope under a device-bound key (localStorage key129+ `lofi:data-sink:<appId>`; nothing bearer-shaped is stored in cleartext). Boot opens it silently —130+ no ceremony — and uses `ticket.url` as the runtime `serverUrl`. Unlocking sealed provision131+ capability for an admin operation is a user-verifying passkey ceremony.129132 4. `ticket.appId` should match the app's own id; refuse enrollment otherwise.130133 131134 ## Revocation semantics@@ -48,7 +48,7 @@ app-side flow: [app-ticket.md](app-ticket.md).
4848 A store with no deployed schema is unusable — client writes hang (preflight with4949 `GET <ticket.url>/store-status`, see [app-ticket.md](app-ticket.md)). Two paths publish a schema:5050 51-**Primary — runtime provisioning via a provision ticket** (the lofi#109 opt-in flow): issue51+**Primary — runtime provisioning via a provision ticket**: issue5252 `lofi-node ticket issue --provision --label app-setup` and hand it to the app. On enrollment the app5353 splits it through the scope-down exchange (everyday transport rides a derived sync ticket; admin5454 capability unlocks through a passkey ceremony when provisioning actually runs). The app (or any@@ -85,7 +85,15 @@ deno task preview
8585 8686 `JAZZ_SERVER_URL` must be http(s) — lofi's preflight rejects ws URLs; the browser client derives the8787 WebSocket endpoint itself (`…/apps/<uuid>/ws`, preserving the ticket's `/t/<secret>` base path). On88-an open-mode node use the node URL directly. Beyond a trusted LAN, front the gate with TLS;88+an open-mode node use the node URL directly.89+90+Budget for the app shell's weight when you serve it: the Jazz WASM engine is ~9.8 MB uncompressed91+(~3 MB gzip), and lofi's build prints the measured shell total on every run. Serve the static app92+with compression — precompress at deploy time (`brotli -k`, `gzip -k9` over `dist/`) and let the93+file server prefer the precompressed variants (Caddy `file_server` with `precompressed br gzip`,94+nginx `gzip_static on;`/`brotli_static on;`), or at minimum enable on-the-fly compression for95+`.wasm` and `.js`. The service worker precaches the shell once per app version, so the cost is a96+cold install and each update, not every visit. Beyond a trusted LAN, front the gate with TLS;8997 installed PWAs require a secure origin. (Once lofi ships its runtime serverUrl override, the9098 build-time env becomes unnecessary — the app enrolls the ticket at runtime instead; see9199 [app-ticket.md](app-ticket.md).)@@ -98,12 +106,9 @@ build-time env becomes unnecessary — the app enrolls the ticket at runtime ins
98106 - lofi's opt-in browser convergence gate (`tests/convergence_e2e_test.ts`) **passes against99107 lofi-node**: two real Chromium clients with the backup-and-sync election performed, concurrent100108 offline edits, reconnection, full bidirectional convergence — identical results against lofi's own101- managed dev server. (An earlier version of this section reported the gate failing with102- `CatalogueWriteDenied`; that diagnosis was wrong. The gate was not electing sync, so both clients103- ran local-only by design, and the warning — which every browser client logs once at boot on every104- server, converging or not — was misattributed as the cause. lofi's decision record105- `docs/decisions/0002-convergence-verdict.md` carries the evidence.) Headless two-client106- convergence passes as before — see `tests/convergence_test.ts`.109+ managed dev server. Browser clients log one benign `CatalogueWriteDenied` warning at boot on any110+ server; it does not affect sync. Headless two-client convergence also passes — see111+ `tests/convergence_test.ts`.107112 108113 ## Pairing two homes109114 @@ -29,6 +29,46 @@ Note what TLS does **not** change: the ticket still gates access, revocation sti
2929 and the WebSocket upgrade still rides the same URL — the browser client derives its endpoints from3030 the http(s) base.3131 32+## Redact ticket secrets from proxy logs33+34+Every gated request carries `/t/<secret>` in its path, and a reverse proxy's default access log35+writes request paths — so a stock logging config quietly collects bearer credentials on disk. Redact36+the path before it is logged (or disable access logging for the gate's host).37+38+Caddy, with its log field filter:39+40+```caddyfile41+node.example.com {42+ reverse_proxy 127.0.0.1:480243+ log {44+ format filter {45+ wrap console46+ fields {47+ request>uri regexp /t/[A-Za-z0-9_-]{43} /t/<redacted>48+ }49+ }50+ }51+}52+```53+54+nginx, by logging a scrubbed copy of the URI:55+56+```nginx57+map $request_uri $redacted_uri {58+ "~^(?<pre>.*/t/)[A-Za-z0-9_-]{43}(?<post>.*)$" "${pre}<redacted>${post}";59+ default $request_uri;60+}61+log_format redacted '$remote_addr - [$time_local] '62+ '"$request_method $redacted_uri $server_protocol" '63+ '$status $body_bytes_sent';64+access_log /var/log/nginx/access.log redacted;65+```66+67+The same concern applies to anything else that records URLs on the request path — tracing68+middleware, error reporters, CDN logs. The node itself compares digests and logs ticket ids, never69+secrets; keep the perimeter to the same standard. If a secret does land in a log, revoke that ticket70+(`lofi-node ticket revoke <id>`) — reissuing is one command and live sockets close within seconds.71+3272 ## Bring your own relay3373 3474 Paired nodes hole-punch direct connections wherever possible; an@@ -17,9 +17,14 @@ lofi side implements against.
1717 1818 ## Why the secret lives in the URL path1919 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 gate20+A header was the obvious alternative and it does not survive contact with the platform: the21+browser's WebSocket API cannot set request headers, so a header-borne credential would need a22+different authentication path for exactly the connection that matters most. Jazz clients preserve a23+base path in their server URL and reject query parameters. By making the ticket's URL24+`http(s)://host:port/t/<secret>`, every request the client ever makes — sync WebSocket connects,25+catalogue reads, admin calls — carries the secret with **zero client changes**. The cost of the26+choice is that the secret rides in a URL, which is why proxy access logs need27+[redaction](beyond-the-lan.md) and why the gate compares digests rather than logging paths. The gate2328 validates the secret (timing-safe, digest against digest), strips the prefix, and proxies to the2429 loopback-only Jazz server. The lofi app uses the ticket URL verbatim as its sync server; that is the2530 whole integration.@@ -16,7 +16,7 @@
1616 * access: "ticket",1717 * });1818 * const { ticket } = await node.issueTicket({ label: "phone" });19- * // the app stores `ticket` passkey-encrypted and uses its URL as serverUrl19+ * // the app seals `ticket` at rest and uses its URL as serverUrl2020 * ```2121 *2222 * The CLI lives at the `./cli` export (`dx -A jsr:/lofi-node/cli`), and