clone

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

feat/connection-observability
lofi-node / docs / site / http-surface.md
http-surface.md 6.8K rendered
1# HTTP surface23<!-- Source: FelineStateMachine/lofi-node src/gate.ts; normative ticket semantics in the4 app-ticket contract. -->56The gate is the node's public face: it owns the public port, validates tickets, and proxies to the7loopback-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.910## Routes1112| Route | Ticket | Behavior |13| ------------------------------------- | ----------- | ---------------------------------------------------------------------- |14| `GET /health` | none | Liveness (below). `200`, or `502` when the store is unreachable. |15| `GET /t/<secret>/health` | any scope | Same liveness under a ticket base; `401` once the ticket is revoked. |16| `GET /t/<secret>/store-status` | any scope | Metadata-only schema preflight (below). |17| `/t/<secret>/apps/<appId>/ws` | any scope | WebSocket sync; re-originated toward Jazz with subprotocol forwarded. |18| `/t/<secret>/apps/<appId>/…` | any scope | Catalogue reads and other app routes, prefix-stripped and proxied. |19| `/t/<secret>/apps/<appId>/admin/…` | `provision` | Store administration; the node's admin secret is injected server-side. |20| `POST /t/<secret>/derive-sync-ticket` | `provision` | Mints a parent-linked, sync-scoped ticket (below). |21| `POST /t/<secret>/pop/challenge` | bound only | Mints a proof-of-possession challenge (below). |22| `POST /t/<secret>/pop/answer` | bound only | Verifies a device signature; answers with a connect token. |23| `/t/<secret>/c/<token>/…` | bound only | Every gated route of a bound ticket, behind its connect token. |2425The `<secret>` is a 43-character base64url path segment; the gate compares digests in constant time,26strips the prefix, preserves the query string, and forwards.2728On a **possession-bound** ticket (one derived with a `devicePublicKey`), the bare secret opens only29`/health` and the `/pop/*` exchange; every other route answers `401` until the request carries a30live connect token under `/c/<token>`. Unbound tickets are unaffected, and a bearer ticket calling31`/pop/*` gets `400 {"error":"pop_not_bound"}`. The full exchange contract — signed-message bytes,32single-use challenges, token lifetime — is normative in the app-ticket contract33(`docs/app-ticket.md`).3435## health3637```jsonc38// 200 — the store answered39{ "status": "healthy" }40```4142One liveness contract, every connection mode:4344- **Open mode** — Jazz serves `/health` directly on the public URL.45- **Ticket mode** — the gate proxies it, unauthenticated at the top level (reachable before46 enrollment) and under any valid ticket base, so `serverUrl`-relative polling works unchanged. A47 revoked ticket gets `401` on its own base while the top level stays `200`: "node up, access gone"48 stays distinguishable from "node unreachable".49- **Peer mode** — the request rides the iroh tunnel to the peer's Jazz like any other HTTP.5051Responses are cheap in all modes (one loopback hop at most) and never hang on a dead store: the gate52answers `502` when Jazz is unreachable. The client contract is composition, not a new primitive — an53app derives its single connection observable from WebSocket lifecycle events (connect, close, auth54failure) plus periodic `/health` polls, and uses it to label pending writes `offline` rather than55leaving them silently waiting. Verdict semantics for those pending writes are on the56[write verdicts](write-verdicts.md) page.5758## store-status5960```jsonc61// 20062{63 "v": 1,64 "appId": "…",65 "schema": {66 "deployed": true, // false → the object carries only { "deployed": false }67 "headHash": "ff85ac…", // newest stored schema hash (ordered by publishedAt)68 "permissionsHead": "0195…" // current permissions head object id, or null69 }70}7172// 502 — the node is up but its store is not73{ "error": "store_unavailable" }74```7576Metadata only — never schema contents, policies, or secrets. This endpoint exists so a sync-scoped77client can classify a store (most importantly `no_schema`, where writes would hang) without any78admin capability.7980## derive-sync-ticket8182```jsonc83// POST /t/<provision-secret>/derive-sync-ticket84// optional body: { "label": "phone (sync)" }8586// 20087{ "v": 1, "id": "<ticket id>", "ticket": "lofisync1.…" }88```8990The scope-down exchange: a provision-scoped secret mints a complete sync-scoped ticket linked to its91parent, so an app that enrolls one pasted provision ticket can persist transport-only credential92material and custody the provision original separately. The label defaults to93`<parent label> (sync)`. Sync-scoped and unknown secrets get the standard `401` (nothing to94enumerate); non-POST methods get `405`. Revoking the parent revokes every ticket derived from it —95live derived sockets close with `4001` like any revocation.9697## Errors and close codes9899| Signal | Meaning |100| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |101| `401 {"error":"invalid_ticket"}` | Unknown ticket, revoked ticket (directly or through its revoked parent), or a sync-scoped ticket on an admin route — deliberately indistinguishable. On WebSocket, the 401 **is** the upgrade response. |102| 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. |103| WS close `1011` | Upstream dial or pump failure inside the gate. |104| `502 {"error":"store_unavailable"}` | Gate up, Jazz (or the tunnel to the root) unreachable. |105106## Header handling107108Inbound `X-Jazz-Admin-Secret` headers are **stripped unconditionally** in ticket mode; for109provision-scoped requests on admin routes the gate injects its own. Hop-by-hop headers110(`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `host`, `content-length`) are managed111by the gate in both directions; everything else passes through.