clone

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

release/0.3.0
lofi-node / docs / site / http-surface.md
http-surface.md 4.7K source

HTTP surface

The gate is the node's public face: it owns the public port, validates tickets, and proxies to the loopback-only Jazz server. This page is the route-level reference for a ticket-gated node; in open mode the same routes exist without the /t/<secret> prefix or scope checks.

Routes

Route Ticket Behavior
GET /health none Liveness; proxied to Jazz. 200, or 502 when the store is unreachable.
GET /t/<secret>/store-status any scope Metadata-only schema preflight (below).
/t/<secret>/apps/<appId>/ws any scope WebSocket sync; re-originated toward Jazz with subprotocol forwarded.
/t/<secret>/apps/<appId>/… any scope Catalogue reads and other app routes, prefix-stripped and proxied.
/t/<secret>/apps/<appId>/admin/… provision Store administration; the node's admin secret is injected server-side.
POST /t/<secret>/derive-sync-ticket provision Mints a parent-linked, sync-scoped ticket (below).

The <secret> is a 43-character base64url path segment; the gate compares digests in constant time, strips the prefix, preserves the query string, and forwards.

store-status

// 200
{
  "v": 1,
  "appId": "…",
  "schema": {
    "deployed": true, // false → the object carries only { "deployed": false }
    "headHash": "ff85ac…", // newest stored schema hash (ordered by publishedAt)
    "permissionsHead": "0195…" // current permissions head object id, or null
  }
}

// 502 — the node is up but its store is not
{ "error": "store_unavailable" }

Metadata only — never schema contents, policies, or secrets. This endpoint exists so a sync-scoped client can classify a store (most importantly no_schema, where writes would hang) without any admin capability.

derive-sync-ticket

// POST /t/<provision-secret>/derive-sync-ticket
// optional body: { "label": "phone (sync)" }

// 200
{ "v": 1, "id": "<ticket id>", "ticket": "lofisync1.…" }

The scope-down exchange: a provision-scoped secret mints a complete sync-scoped ticket linked to its parent, so an app that enrolls one pasted provision ticket can persist transport-only credential material and custody the provision original separately. The label defaults to <parent label> (sync). Sync-scoped and unknown secrets get the standard 401 (nothing to enumerate); non-POST methods get 405. Revoking the parent revokes every ticket derived from it — live derived sockets close with 4001 like any revocation.

Errors and close codes

Signal Meaning
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.
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.
WS close 1011 Upstream dial or pump failure inside the gate.
502 {"error":"store_unavailable"} Gate up, Jazz (or the tunnel to the root) unreachable.

Header handling

Inbound X-Jazz-Admin-Secret headers are stripped unconditionally in ticket mode; for provision-scoped requests on admin routes the gate injects its own. Hop-by-hop headers (connection, keep-alive, transfer-encoding, upgrade, host, content-length) are managed by the gate in both directions; everything else passes through.