clone

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

Merge pull request #8 from FelineStateMachine/docs/contract-and-operator-pass

FelineStateMachine committed e04a403
docs: finish the contract-accuracy pass and the operator hardening docs
commit
e04a403e165de0c3b9884a2a7db66ede755e8d62
parent
92023eabbc5267ba7f2a6f6547a2800a4c10b80a
parent
02e056f4180039c7d0907ea669968b33c0c85eca
author
FelineStateMachine <dami.etoile@gmail.com>
6 files changed, +79 -25
MREADME.md5++--
Mdocs/app-ticket.md25+++++++++++--------
Mdocs/hosting-lofi-apps.md21++++++++++------
Mdocs/site/beyond-the-lan.md40++++++++++++++++++++++++++++++
Mdocs/site/tickets-explained.md11++++++--
Mmod.ts2+-
M README.md +3 -2
M docs/app-ticket.md +14 -11
@@ -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
M docs/hosting-lofi-apps.md +13 -8
@@ -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
M docs/site/beyond-the-lan.md +40 -0
@@ -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
M docs/site/tickets-explained.md +8 -3
M mod.ts +1 -1