clone

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

feat/write-verdict-foundations
lofi-node / docs / site / http-surface.md
http-surface.md 6.0K 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). |2122The `<secret>` is a 43-character base64url path segment; the gate compares digests in constant time,23strips the prefix, preserves the query string, and forwards.2425## health2627```jsonc28// 200 — the store answered29{ "status": "healthy" }30```3132One liveness contract, every connection mode:3334- **Open mode** — Jazz serves `/health` directly on the public URL.35- **Ticket mode** — the gate proxies it, unauthenticated at the top level (reachable before36 enrollment) and under any valid ticket base, so `serverUrl`-relative polling works unchanged. A37 revoked ticket gets `401` on its own base while the top level stays `200`: "node up, access gone"38 stays distinguishable from "node unreachable".39- **Peer mode** — the request rides the iroh tunnel to the peer's Jazz like any other HTTP.4041Responses are cheap in all modes (one loopback hop at most) and never hang on a dead store: the gate42answers `502` when Jazz is unreachable. The client contract is composition, not a new primitive — an43app derives its single connection observable from WebSocket lifecycle events (connect, close, auth44failure) plus periodic `/health` polls, and uses it to label pending writes `offline` rather than45leaving them silently waiting. Verdict semantics for those pending writes are on the46[write verdicts](write-verdicts.md) page.4748## store-status4950```jsonc51// 20052{53 "v": 1,54 "appId": "…",55 "schema": {56 "deployed": true, // false → the object carries only { "deployed": false }57 "headHash": "ff85ac…", // newest stored schema hash (ordered by publishedAt)58 "permissionsHead": "0195…" // current permissions head object id, or null59 }60}6162// 502 — the node is up but its store is not63{ "error": "store_unavailable" }64```6566Metadata only — never schema contents, policies, or secrets. This endpoint exists so a sync-scoped67client can classify a store (most importantly `no_schema`, where writes would hang) without any68admin capability.6970## derive-sync-ticket7172```jsonc73// POST /t/<provision-secret>/derive-sync-ticket74// optional body: { "label": "phone (sync)" }7576// 20077{ "v": 1, "id": "<ticket id>", "ticket": "lofisync1.…" }78```7980The scope-down exchange: a provision-scoped secret mints a complete sync-scoped ticket linked to its81parent, so an app that enrolls one pasted provision ticket can persist transport-only credential82material and custody the provision original separately. The label defaults to83`<parent label> (sync)`. Sync-scoped and unknown secrets get the standard `401` (nothing to84enumerate); non-POST methods get `405`. Revoking the parent revokes every ticket derived from it —85live derived sockets close with `4001` like any revocation.8687## Errors and close codes8889| Signal | Meaning |90| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |91| `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. |92| 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. |93| WS close `1011` | Upstream dial or pump failure inside the gate. |94| `502 {"error":"store_unavailable"}` | Gate up, Jazz (or the tunnel to the root) unreachable. |9596## Header handling9798Inbound `X-Jazz-Admin-Secret` headers are **stripped unconditionally** in ticket mode; for99provision-scoped requests on admin routes the gate injects its own. Hop-by-hop headers100(`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `host`, `content-length`) are managed101by the gate in both directions; everything else passes through.