clone

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

feat: app-connect tickets + access gate + user-chosen storage (BYOS MVP)

Dami committed b2b809c
A user hosting lofi-node can now issue revocable app-connect tickets
(lofisync1. + base64url JSON {v, appId, url, label?, node?}) whose URL
embeds a 256-bit secret as a path prefix — jazz clients preserve base
paths in serverUrl, so the ticket URL works as the app's sync server
with ZERO client changes (proven: the convergence fixture runs deploy +
two devices through /t/<secret>).

Access gate (src/gate.ts): lofi-node's public listener; JazzServer stays
loopback-only behind it on an ephemeral port. Verifies secrets timing-
safe against sha256 digests in tickets.json (secrets never stored, shown
once), strips the prefix, re-originates WS (in-band Jazz auth makes this
safe) and streams HTTP with search preserved. Unknown and revoked are
both 401 (indistinguishable to probers); mid-session revocation closes
sockets with 4001. CLI ticket issue/list/revoke write the file; a
running daemon hot-reloads by mtime — no IPC.

Storage election: {type:'sqlite', path}|{type:'memory'} in config v2
(v1 migrates lazily; new inits default access:'ticket'), path probed
writable at boot, --storage-path/--memory in the CLI. jazz-napi is the
honest boundary (SQLite/memory only); provider recipes in docs.

pair() in ticket mode no longer needs the same-port restart — the gate
owns the public port. New docs/app-ticket.md is the contract the lofi
side (passkey-encrypted localStorage enrollment) implements against.

28 tests green: appticket codec/store/hot-reload, gate routing/
revocation, ticket-gated convergence + revoked-401, gate-stable pair,
storage paths, config migration.
commit
b2b809cc07af866b092bf7c8d700790617d30e52
parent
49e51feffee3cac21abd7792d2e8cfbd86a1e0f3
author
Dami <dami.etoile@gmail.com>
15 files changed, +1425 -62
MREADME.md11+-
Mcli.ts181++++++++++++++++++++++--
Adocs/app-ticket.md73++++++++++
Mdocs/hosting-lofi-apps.md38++++-
Mmod.ts15++-
Asrc/appticket.ts207+++++++++++++++++++++++++++
Msrc/config.ts59+++++++-
Asrc/gate.ts230++++++++++++++++++++++++++++++
Msrc/node.ts194++++++++++++++++++++++----
Msrc/tunnel.ts4+-
Atests/appticket_test.ts118+++++++++++++++
Mtests/convergence_test.ts58+++++++-
Atests/gate_test.ts177+++++++++++++++++++++++
Mtests/pair_test.ts31++++
Atests/storage_config_test.ts91++++++++++++
M README.md +9 -2
M cli.ts +167 -14
@@ -1,19 +1,29 @@
1-// lofi-node CLI — init / start / pair / status. Prove-out scope: `pair`2-// persists the election and asks for a restart (no daemon IPC yet).1+// lofi-node CLI — init / start / pair / ticket / status.2+//3+// Ticket verbs write <dataDir>/tickets.json directly; a RUNNING daemon picks4+// changes up via the ticket store's mtime hot-reload, so issue/revoke take5+// effect without a restart and without IPC.36 4-import { initConfig, loadConfig, saveConfig } from "./src/config.ts";7+import { initConfig, loadConfig, saveConfig, type StorageConfig } from "./src/config.ts";58 import { createSyncNode } from "./src/node.ts";69 import { looksLikeTicket } from "./src/ticket.ts";10+import { AppTicketStore, encodeAppTicket, looksLikeAppTicket } from "./src/appticket.ts";711 8-const USAGE = `lofi-node — self-hostable Jazz sync node with iroh transport12+const USAGE = `lofi-node — self-hostable sync node for lofi apps913 1014 Usage:1115 lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]16+ [--public-url <base>] [--open] [--storage-path <path>] [--memory]1217 lofi-node start [--dir <dataDir>]13- lofi-node pair <ticket> [--dir <dataDir>]18+ lofi-node pair <node-ticket> [--dir <dataDir>]19+ lofi-node ticket issue [--label <s>] [--url <base>] [--dir <dataDir>]20+ lofi-node ticket list [--dir <dataDir>]21+ lofi-node ticket revoke <id> [--dir <dataDir>]1422 lofi-node status [--dir <dataDir>]1523 16-The data directory defaults to ./lofi-node-data.`;24+The data directory defaults to ./lofi-node-data. New inits are ticket-gated25+(--open opts out); app tickets are issued with \`ticket issue\` and pasted26+into the lofi app.`;1727 1828 interface Args {1929 command: string;
@@ -21,15 +31,33 @@ interface Args {
2131 dir: string;2232 appId?: string;2333 port?: number;34+ publicUrl?: string;35+ label?: string;36+ url?: string;37+ open: boolean;38+ memory: boolean;39+ storagePath?: string;2440 }2541 2642 function parseArgs(argv: string[]): Args {27- const args: Args = { command: argv[0] ?? "", positional: [], dir: "./lofi-node-data" };43+ const args: Args = {44+ command: argv[0] ?? "",45+ positional: [],46+ dir: "./lofi-node-data",47+ open: false,48+ memory: false,49+ };2850 for (let i = 1; i < argv.length; i++) {2951 const arg = argv[i];3052 if (arg === "--dir") args.dir = argv[++i];3153 else if (arg === "--app-id") args.appId = argv[++i];3254 else if (arg === "--port") args.port = Number(argv[++i]);55+ else if (arg === "--public-url") args.publicUrl = argv[++i];56+ else if (arg === "--label") args.label = argv[++i];57+ else if (arg === "--url") args.url = argv[++i];58+ else if (arg === "--open") args.open = true;59+ else if (arg === "--memory") args.memory = true;60+ else if (arg === "--storage-path") args.storagePath = argv[++i];3361 else args.positional.push(arg);3462 }3563 return args;
@@ -46,14 +74,46 @@ async function requireConfig(dir: string) {
4674 return config;4775 }4876 77+function describeStorage(storage: StorageConfig, dir: string): string {78+ if (storage.type === "memory") return "memory (no persistence)";79+ return `sqlite (${storage.path ?? `${dir}/jazz`})`;80+}81+82+function lanAddress(): string | null {83+ try {84+ for (const iface of Deno.networkInterfaces()) {85+ if (iface.family === "IPv4" && !iface.address.startsWith("127.")) return iface.address;86+ }87+ } catch {88+ // permission or platform issue — fall through89+ }90+ return null;91+}92+4993 async function cmdInit(args: Args) {5094 const existing = await loadConfig(args.dir);5195 if (existing) fail(`already initialized: ${args.dir}/config.json`);52- const config = await initConfig(args.dir, { appId: args.appId, listenPort: args.port });96+ const storage: StorageConfig = args.memory97+ ? { type: "memory" }98+ : { type: "sqlite", path: args.storagePath };99+ const config = await initConfig(args.dir, {100+ appId: args.appId,101+ listenPort: args.port,102+ access: args.open ? "open" : "ticket",103+ storage,104+ publicUrl: args.publicUrl,105+ });53106 console.log(`initialized ${args.dir}`);54107 console.log(` app id: ${config.appId}`);108+ console.log(` access: ${config.access}`);109+ console.log(` storage: ${describeStorage(config.storage, args.dir)}`);55110 console.log(` listen: ${config.listenPort ?? "(auto)"}`);56- console.log(`\nNext: lofi-node start --dir ${args.dir}`);111+ if (config.access === "ticket") {112+ console.log(`\nNext: lofi-node start --dir ${args.dir}`);113+ console.log(`Then: lofi-node ticket issue --dir ${args.dir} # connect an app`);114+ } else {115+ console.log(`\nNext: lofi-node start --dir ${args.dir}`);116+ }57117 }58118 59119 async function cmdStart(args: Args) {
@@ -64,14 +124,27 @@ async function cmdStart(args: Args) {
64124 adminSecret: config.adminSecret,65125 dataDir: args.dir,66126 listen: config.listenPort ? { port: config.listenPort } : undefined,127+ access: config.access,128+ storage: config.storage,129+ publicUrl: config.publicUrl,67130 upstream: config.upstream,68131 allowLocalFirstAuth: config.allowLocalFirstAuth,69132 });70133 const status = node.status();71134 console.log(`lofi-node up`);72135 console.log(` app id: ${node.appId}`);73- console.log(` jazz url: ${node.url} <- point JAZZ_SERVER_URL here`);74- console.log(` storage: ${status.jazz.storage} (${args.dir})`);136+ console.log(` access: ${status.access}`);137+ if (status.access === "ticket") {138+ console.log(` gate: ${node.url} (apps connect with an issued ticket URL)`);139+ console.log(140+ ` tickets: ${141+ status.tickets.filter((t) => !t.revoked).length142+ } active — issue with: lofi-node ticket issue --dir ${args.dir}`,143+ );144+ } else {145+ console.log(` jazz url: ${node.url} <- point JAZZ_SERVER_URL here`);146+ }147+ console.log(` storage: ${describeStorage(status.jazz.storage, args.dir)}`);75148 console.log(76149 ` upstream: ${77150 status.upstream === "none"
@@ -96,7 +169,6 @@ async function cmdStart(args: Args) {
96169 } catch (e) {97170 console.error(`shutdown error: ${(e as Error).message}`);98171 }99- // Parked db_accept threads cannot be woken (upstream gap); exit hard.100172 Deno.exit(0);101173 });102174 await new Promise(() => {});
@@ -104,18 +176,96 @@ async function cmdStart(args: Args) {
104176 105177 async function cmdPair(args: Args) {106178 const ticket = args.positional[0];107- if (!ticket) fail("usage: lofi-node pair <ticket>");108- if (!looksLikeTicket(ticket)) fail("that does not look like a pairing ticket");179+ if (!ticket) fail("usage: lofi-node pair <node-ticket>");180+ if (looksLikeAppTicket(ticket)) {181+ fail(182+ "that is an app-connect ticket (lofisync1.…); pairing takes a NODE ticket " +183+ "(the endpoint… string printed by lofi-node start on the other node)",184+ );185+ }186+ if (!looksLikeTicket(ticket)) fail("that does not look like a node-pairing ticket");109187 const config = await requireConfig(args.dir);110188 config.upstream = { peer: ticket };111189 await saveConfig(args.dir, config);112190 console.log("pairing saved. Restart the node to apply: lofi-node start");113191 }114192 193+async function cmdTicket(args: Args) {194+ const sub = args.positional[0];195+ const config = await requireConfig(args.dir);196+ if (config.access !== "ticket") {197+ fail(`node access is "${config.access}" — app tickets require access: "ticket"`);198+ }199+ const store = await AppTicketStore.load(args.dir);200+201+ if (sub === "issue") {202+ if (!config.listenPort && !args.url && !config.publicUrl) {203+ fail(204+ "ticket URLs need a stable address: set a fixed port (init --port) plus " +205+ "--public-url/--url, or pass --url explicitly",206+ );207+ }208+ let base = args.url ?? config.publicUrl;209+ if (!base) {210+ const lan = lanAddress();211+ base = `http://${lan ?? "127.0.0.1"}:${config.listenPort}`;212+ if (lan) {213+ console.error(214+ `note: auto-detected LAN address ${lan} — pin with --public-url if this is wrong`,215+ );216+ }217+ }218+ const { record, secret } = await store.issue(args.label);219+ const ticket = encodeAppTicket({220+ v: 1,221+ appId: config.appId,222+ url: `${base.replace(/\/+$/, "")}/t/${secret}`,223+ label: args.label,224+ });225+ console.log(`issued ticket ${record.id}${args.label ? ` (${args.label})` : ""}`);226+ console.log(`\n${ticket}\n`);227+ console.log("Paste this into the lofi app. The secret is NOT stored and cannot");228+ console.log("be shown again; a running node accepts it immediately.");229+ return;230+ }231+232+ if (sub === "list") {233+ const tickets = await store.list();234+ if (tickets.length === 0) {235+ console.log("no tickets issued");236+ return;237+ }238+ for (const t of tickets) {239+ console.log(240+ `${t.id} ${t.revokedAt ? "REVOKED" : "active "} ${t.createdAt} ${t.label ?? ""}`,241+ );242+ }243+ return;244+ }245+246+ if (sub === "revoke") {247+ const id = args.positional[1];248+ if (!id) fail("usage: lofi-node ticket revoke <id>");249+ const record = await store.revoke(id);250+ if (!record) fail(`no ticket with id ${id} (see: lofi-node ticket list)`);251+ console.log(`revoked ${id} — takes effect immediately if the node is running`);252+ return;253+ }254+255+ fail("usage: lofi-node ticket <issue|list|revoke>");256+}257+115258 async function cmdStatus(args: Args) {116259 const config = await requireConfig(args.dir);260+ const store = await AppTicketStore.load(args.dir);261+ const tickets = await store.list();117262 console.log(` app id: ${config.appId}`);263+ console.log(` access: ${config.access}`);264+ console.log(` storage: ${describeStorage(config.storage, args.dir)}`);118265 console.log(` listen: ${config.listenPort ?? "(auto)"}`);266+ console.log(267+ ` tickets: ${tickets.filter((t) => !t.revokedAt).length} active / ${tickets.length} issued`,268+ );119269 console.log(120270 ` upstream: ${121271 config.upstream === "none"
@@ -148,6 +298,9 @@ switch (args.command) {
148298 case "pair":149299 await cmdPair(args);150300 break;301+ case "ticket":302+ await cmdTicket(args);303+ break;151304 case "status":152305 await cmdStatus(args);153306 break;
A docs/app-ticket.md +73 -0
@@ -0,0 +1,73 @@
1+# App-connect tickets — the contract2+3+This is the format and semantics the lofi app side implements against. An app ticket is the4+credential a user hands to a lofi app so it syncs against their self-hosted node. It is distinct5+from the **node-pairing ticket** (the `endpoint…` string used by `lofi-node pair`).6+7+## Ticket string8+9+```10+lofisync1.<base64url(JSON, no padding)>11+```12+13+```jsonc14+{15+ "v": 1,16+ "appId": "cfe52e44-7a59-4232-8dbb-bf53f27aeed6",17+ "url": "http://192.168.1.10:4802/t/<secret>", // ← the app's serverUrl, verbatim18+ "label": "phone", // optional, user-facing19+ "node": "endpoint…" // optional: the node's iroh20+ // EndpointTicket (forward21+ // compat; unused by browsers)22+}23+```24+25+- `<secret>` is 32 random bytes base64url (43 chars, alphabet `[A-Za-z0-9_-]`) — safe as a URL path26+ segment with no encoding.27+- The node stores only the SHA-256 digest of the secret; the ticket string is displayable **once**,28+ at issuance.29+30+## Why the secret lives in the URL path31+32+jazz clients preserve a base path in `serverUrl` (`appScopedUrl`) and reject query params. With33+`serverUrl = <ticket.url>`, every request the client makes carries the secret with **zero client34+changes**:35+36+- sync WebSocket → `…/t/<secret>/apps/<appId>/ws`37+- catalogue reads → `…/t/<secret>/apps/<appId>/schemas`, `schema/<hash>`38+- admin (deploys) → `…/t/<secret>/apps/<appId>/admin/…` (Jazz's `X-Jazz-Admin-Secret` is still39+ required on top — the ticket gates transport, the admin secret gates administration)40+41+The node's access gate validates the secret (timing-safe, digest vs digest), strips the42+`/t/<secret>` prefix, and proxies to the internal Jazz server (which binds loopback-only).43+44+## Enrollment flow (app side)45+46+1. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`47+ mirrors the validation: prefix, `v: 1`, http(s) URL with a `/t/<43-char-secret>` path).48+2. App encrypts the ticket with the passkey-derived at-rest key (lofi `package/runtime/auth.ts`:49+ `derivePrfSecret` → `deriveAtRestKey` → `encryptAtRest`) and stores the blob in localStorage50+ (suggested key: `lofi:sync-location:<appId>`).51+3. On boot, after the user unlocks with their passkey: decrypt, use `ticket.url` as the runtime52+ `serverUrl` override. The decrypted local data therefore reveals _location + access_ for the53+ user's lofi data.54+4. `ticket.appId` should match the app's own id; refuse enrollment otherwise.55+56+## Revocation semantics57+58+- Unknown **and** revoked secrets both get `401 {"error":"invalid_ticket"}` — indistinguishable to59+ probers, on HTTP and on the WS upgrade (the 401 _is_ the upgrade response).60+- A ticket revoked mid-session closes its live WebSockets with close code **4001**61+ (`ticket revoked`) within a couple of seconds.62+- On 401/4001 the app should treat the stored ticket as dead and surface re-enrollment (do not63+ silently retry forever).64+- `GET /health` (no secret) is open — safe for liveness probes.65+66+## Security notes67+68+- The ticket is a **bearer credential** (256-bit entropy). Anyone holding it can sync as an69+ authorized transport peer; identity/permissions remain Jazz's local-first layer on top.70+- Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS71+ (the URL scheme in the ticket may be `https`). Installed PWAs generally require a secure origin72+ anyway.73+- Issue one ticket per device/context (`--label phone`) so revocation is scoped.
M docs/hosting-lofi-apps.md +32 -6
@@ -8,11 +8,34 @@ end against lofi's reference app (`apps/reference`) with real Chromium clients.
88 ## 1. Host a node99 1010 ```sh11-lofi-node init --dir ./data --port 4802 # prints the app id (a UUID)12-lofi-node start --dir ./data # Jazz URL + pairing ticket11+lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:480212+lofi-node start --dir ./data1313 ```1414 15-`data/config.json` holds the app id and the admin secret you'll need next.15+New inits are **ticket-gated**: only requests carrying an issued app-ticket secret reach Jazz (the16+embedded Jazz server binds loopback-only; lofi-node's access gate owns the public port). `--open`17+opts out for dev setups. `data/config.json` holds the app id and the admin secret you'll need below.18+19+### Storage choice20+21+`--storage-path /mnt/nas/lofi` puts the SQLite store on any mounted location (NAS, synced volume);22+`--memory` keeps everything ephemeral. The path is probed writable at boot — an unwritable location23+fails fast by name. jazz-napi supports exactly SQLite-directory and memory today; for cloud off-site24+durability, replicate the SQLite file (Litestream-style to S3, or a snapshotting volume). The25+`storage.type` discriminator in config is the seam future providers slot into.26+27+### Issue an app ticket28+29+```sh30+lofi-node ticket issue --dir ./data --label phone31+# → lofisync1.eyJ2IjoxLCJhcHBJZCI6… (shown once; secret is never stored)32+lofi-node ticket list --dir ./data33+lofi-node ticket revoke <id> --dir ./data # live sockets close with 400134+```35+36+The user pastes the ticket into their lofi app; the app stores it passkey-encrypted in localStorage37+and uses the ticket's URL as its sync server. Format and app-side flow:38+[app-ticket.md](app-ticket.md).1639 1740 ## 2. Deploy the app's schema to the node1841
@@ -38,13 +61,16 @@ await deploy({
3861 ## 3. Build the app against the node3962 4063 ```sh41-JAZZ_APP_ID=<app id> JAZZ_SERVER_URL=http://127.0.0.1:4802 deno task build64+JAZZ_APP_ID=<app id> JAZZ_SERVER_URL=<ticket url> deno task build4265 deno task preview4366 ```4467 4568 `JAZZ_SERVER_URL` must be http(s) — lofi's preflight rejects ws URLs; the browser client derives the46-WebSocket endpoint itself (`/apps/<uuid>/ws`). For LAN/production use an https reverse proxy in47-front of the node's port; installed PWAs require a secure origin.69+WebSocket endpoint itself (`…/apps/<uuid>/ws`, preserving the ticket's `/t/<secret>` base path). On70+an open-mode node use the node URL directly. Beyond a trusted LAN, front the gate with TLS;71+installed PWAs require a secure origin. (Once lofi ships its runtime serverUrl override, the72+build-time env becomes unnecessary — the app enrolls the ticket at runtime instead; see73+[app-ticket.md](app-ticket.md).)4874 4975 ## Validation status (2026-07-18, jazz-tools 2.0.0-alpha.53)5076
M mod.ts +12 -3
A src/appticket.ts +207 -0
@@ -0,0 +1,207 @@
1+// App-connect tickets: the credential a lofi app uses to sync against this2+// node. Distinct from node-pairing tickets (src/ticket.ts — iroh3+// EndpointTicket strings): an app ticket names a LOCATION (gate URL with the4+// secret embedded as a path prefix) plus the app id, and optionally the5+// node's iroh EndpointTicket for forward compat.6+//7+// The node never stores secrets — only SHA-256 digests in8+// <dataDir>/tickets.json. A ticket string is printable exactly once, at9+// issuance. The lofi app stores it passkey-encrypted in localStorage (see10+// docs/app-ticket.md for the contract the app side implements against).11+12+const TICKET_PREFIX = "lofisync1.";13+14+/** 32 random bytes, base64url without padding: 43 chars, alphabet15+ * [A-Za-z0-9_-] — safe as a URL path segment with no encoding. */16+export const SECRET_LENGTH = 43;17+18+export interface AppTicket {19+ v: 1;20+ appId: string;21+ /** Gate base URL with the secret embedded: http(s)://host:port/t/<secret>.22+ * Used VERBATIM as the lofi app's serverUrl — jazz clients preserve base23+ * paths, so the secret rides every WS connect and catalogue fetch. */24+ url: string;25+ label?: string;26+ /** The node's iroh EndpointTicket (forward compat; unused by browsers). */27+ node?: string;28+}29+30+export interface AppTicketRecord {31+ id: string;32+ label?: string;33+ secretHash: string;34+ createdAt: string;35+ revokedAt?: string;36+}37+38+function base64urlNoPad(bytes: Uint8Array): string {39+ let bin = "";40+ for (const b of bytes) bin += String.fromCharCode(b);41+ return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");42+}43+44+export function generateSecret(): string {45+ return base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));46+}47+48+export async function hashSecret(secret: string): Promise<string> {49+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));50+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");51+}52+53+export function encodeAppTicket(ticket: AppTicket): string {54+ return TICKET_PREFIX + base64urlNoPad(new TextEncoder().encode(JSON.stringify(ticket)));55+}56+57+/** Parse a ticket string; null on any malformed input (paste path — never58+ * throws). */59+export function decodeAppTicket(text: string): AppTicket | null {60+ const trimmed = text.trim();61+ if (!trimmed.startsWith(TICKET_PREFIX)) return null;62+ try {63+ const b64 = trimmed.slice(TICKET_PREFIX.length).replaceAll("-", "+").replaceAll("_", "/");64+ const json = atob(b64);65+ const parsed = JSON.parse(json) as AppTicket;66+ if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {67+ return null;68+ }69+ const url = new URL(parsed.url);70+ if (!/^https?:$/.test(url.protocol) || !/^\/t\/[A-Za-z0-9_-]{43}$/.test(url.pathname)) {71+ return null;72+ }73+ return parsed;74+ } catch {75+ return null;76+ }77+}78+79+/** Cheap prefix check — lets the CLI `pair` command reject app tickets with a80+ * pointed message (an app ticket is not a node-pairing ticket). */81+export function looksLikeAppTicket(text: string): boolean {82+ return text.trim().startsWith(TICKET_PREFIX);83+}84+85+interface TicketsFile {86+ v: 1;87+ tickets: AppTicketRecord[];88+}89+90+function timingSafeEqualHex(a: string, b: string): boolean {91+ // Constant-time over same-length hex digests; length mismatch exits early,92+ // which leaks only that the input wasn't a SHA-256 hex string.93+ if (a.length !== b.length) return false;94+ let diff = 0;95+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);96+ return diff === 0;97+}98+99+export type VerifyResult =100+ | { status: "valid"; record: AppTicketRecord }101+ | { status: "revoked"; record: AppTicketRecord }102+ | { status: "unknown" };103+104+/** Issued-ticket persistence. File-backed when a dataDir is given (the CLI105+ * writes it; a running daemon only READS, picking up changes via mtime — so106+ * `ticket issue`/`revoke` need no IPC), in-memory otherwise (tests, ephemeral107+ * nodes). */108+export class AppTicketStore {109+ #path: string | null;110+ #tickets: AppTicketRecord[] = [];111+ #mtime = 0;112+ #lastStat = 0;113+114+ private constructor(path: string | null) {115+ this.#path = path;116+ }117+118+ static async load(dataDir?: string): Promise<AppTicketStore> {119+ const store = new AppTicketStore(dataDir ? `${dataDir}/tickets.json` : null);120+ await store.#reload();121+ return store;122+ }123+124+ async #reload(): Promise<void> {125+ if (!this.#path) return;126+ try {127+ const stat = await Deno.stat(this.#path);128+ const mtime = stat.mtime?.getTime() ?? 0;129+ if (mtime !== 0 && mtime === this.#mtime) return;130+ const parsed = JSON.parse(await Deno.readTextFile(this.#path)) as TicketsFile;131+ if (parsed.v !== 1) throw new Error(`unsupported tickets.json version ${parsed.v}`);132+ this.#tickets = parsed.tickets;133+ this.#mtime = mtime;134+ } catch (e) {135+ if (e instanceof Deno.errors.NotFound) {136+ this.#tickets = [];137+ this.#mtime = 0;138+ return;139+ }140+ throw e;141+ }142+ }143+144+ /** Throttled (~1s) mtime check so per-request verify stays cheap. */145+ async #maybeReload(): Promise<void> {146+ const now = Date.now();147+ if (now - this.#lastStat < 1000) return;148+ this.#lastStat = now;149+ await this.#reload();150+ }151+152+ async #persist(): Promise<void> {153+ if (!this.#path) return;154+ const file: TicketsFile = { v: 1, tickets: this.#tickets };155+ const tmp = `${this.#path}.${Deno.pid}.tmp`;156+ await Deno.writeTextFile(tmp, JSON.stringify(file, null, 2) + "\n");157+ await Deno.rename(tmp, this.#path);158+ this.#mtime = (await Deno.stat(this.#path)).mtime?.getTime() ?? 0;159+ }160+161+ async issue(label?: string): Promise<{ record: AppTicketRecord; secret: string }> {162+ await this.#reload();163+ const secret = generateSecret();164+ const secretHash = await hashSecret(secret);165+ const record: AppTicketRecord = {166+ id: secretHash.slice(0, 12),167+ label,168+ secretHash,169+ createdAt: new Date().toISOString(),170+ };171+ this.#tickets.push(record);172+ await this.#persist();173+ return { record, secret };174+ }175+176+ async revoke(id: string): Promise<AppTicketRecord | null> {177+ await this.#reload();178+ const record = this.#tickets.find((t) => t.id === id);179+ if (!record) return null;180+ if (!record.revokedAt) {181+ record.revokedAt = new Date().toISOString();182+ await this.#persist();183+ }184+ return record;185+ }186+187+ async list(): Promise<AppTicketRecord[]> {188+ await this.#reload();189+ return [...this.#tickets];190+ }191+192+ /** Timing-safe lookup by presented secret. Compares against EVERY record193+ * (valid and revoked) so probers cannot distinguish unknown from revoked by194+ * timing; the gate returns 401 for both. */195+ async verify(secret: string): Promise<VerifyResult> {196+ await this.#maybeReload();197+ const presented = await hashSecret(secret);198+ let match: AppTicketRecord | null = null;199+ for (const record of this.#tickets) {200+ if (timingSafeEqualHex(presented, record.secretHash)) match = record;201+ }202+ if (!match) return { status: "unknown" };203+ return match.revokedAt204+ ? { status: "revoked", record: match }205+ : { status: "valid", record: match };206+ }207+}
M src/config.ts +53 -6
@@ -5,16 +5,40 @@
55 66 export type UpstreamConfig = "none" | { url: string } | { peer: string };77 8+/** Where node data lives. jazz-napi supports exactly these two today; the9+ * `type` discriminator is the seam future providers slot into. `path` may be10+ * any mounted location (NAS, synced volume) — validated writable at boot. */11+export type StorageConfig = { type: "sqlite"; path?: string } | { type: "memory" };12+813 export interface NodeConfig {9- v: 1;14+ v: 2;1015 appId: string;1116 backendSecret: string;1217 adminSecret: string;1318 listenPort?: number;19+ /** "ticket": only issued app tickets reach Jazz (CLI init default).20+ * "open": today's behavior (library/test default). */21+ access: "open" | "ticket";22+ storage: StorageConfig;23+ /** Base URL embedded into issued tickets, e.g. "http://192.168.1.10:4802". */24+ publicUrl?: string;1425 upstream: UpstreamConfig;1526 allowLocalFirstAuth: boolean;1627 }1728 29+const SUPPORTED_STORAGE = ["sqlite", "memory"];30+31+export function validateStorage(storage: StorageConfig): void {32+ if (!SUPPORTED_STORAGE.includes(storage.type)) {33+ throw new Error(34+ `unsupported storage type "${(storage as { type: string }).type}" — jazz-napi supports ` +35+ `${36+ SUPPORTED_STORAGE.join(" | ")37+ } today; see docs/hosting-lofi-apps.md for provider recipes`,38+ );39+ }40+}41+1842 function randomToken(prefix: string): string {1943 const bytes = crypto.getRandomValues(new Uint8Array(24));2044 const b64 = btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_")
@@ -39,9 +63,25 @@ export function configPath(dataDir: string): string {
3963 export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {4064 try {4165 const raw = await Deno.readTextFile(configPath(dataDir));42- const parsed = JSON.parse(raw) as NodeConfig;43- if (parsed.v !== 1) throw new Error(`unsupported config version ${parsed.v}`);44- return parsed;66+ const parsed = JSON.parse(raw) as { v: number } & Partial<Omit<NodeConfig, "v">>;67+ if (parsed.v === 1) {68+ // Lazy v1→v2 migration: existing dirs keep exact current behavior.69+ // Persisted as v2 on the next explicit saveConfig.70+ return {71+ ...(parsed as unknown as NodeConfig),72+ v: 2,73+ access: parsed.access ?? "open",74+ storage: parsed.storage ?? { type: "sqlite" },75+ };76+ }77+ if (parsed.v !== 2) {78+ throw new Error(79+ `unsupported config version ${parsed.v} — this lofi-node reads v1 and v2 config.json`,80+ );81+ }82+ const config = parsed as NodeConfig;83+ validateStorage(config.storage);84+ return config;4585 } catch (e) {4686 if (e instanceof Deno.errors.NotFound) return null;4787 throw e;
@@ -55,19 +95,26 @@ export async function saveConfig(dataDir: string, config: NodeConfig): Promise<v
5595 5696 export async function initConfig(5797 dataDir: string,58- overrides: Partial<Pick<NodeConfig, "appId" | "listenPort" | "upstream">> = {},98+ overrides: Partial<99+ Pick<NodeConfig, "appId" | "listenPort" | "upstream" | "access" | "storage" | "publicUrl">100+ > = {},59101 ): Promise<NodeConfig> {60102 const existing = await loadConfig(dataDir);61103 if (existing) return existing;62104 const config: NodeConfig = {63- v: 1,105+ v: 2,64106 appId: overrides.appId ?? crypto.randomUUID(),65107 backendSecret: randomToken("lofi_backend"),66108 adminSecret: randomToken("lofi_admin"),67109 listenPort: overrides.listenPort,110+ // Secure by default for real installs; the library default stays "open".111+ access: overrides.access ?? "ticket",112+ storage: overrides.storage ?? { type: "sqlite" },113+ publicUrl: overrides.publicUrl,68114 upstream: overrides.upstream ?? "none",69115 allowLocalFirstAuth: true,70116 };117+ validateStorage(config.storage);71118 await saveConfig(dataDir, config);72119 return config;73120 }
A src/gate.ts +230 -0
@@ -0,0 +1,230 @@
1+// The access gate: lofi-node's public front listener. JazzServer binds2+// loopback-only, so the gate is BOTH the enforcement point for app tickets3+// and what makes the node reachable beyond localhost at all.4+//5+// Ticket mode routes requests shaped /t/<secret>/… — the secret arrives as a6+// serverUrl base-path prefix (jazz clients preserve base paths through every7+// WS connect and catalogue fetch), gets verified against the ticket store8+// (timing-safe, digest vs digest), and is stripped before proxying to the9+// internal Jazz port. WS is terminated and re-originated (safe: Jazz auth is10+// an in-band frame, proven by the iroh tunnel doing the same); HTTP streams.11+12+import { AppTicketStore, SECRET_LENGTH } from "./appticket.ts";13+import { forwardableHeaders, sanitizeCloseCode } from "./tunnel.ts";14+15+const DEBUG = Deno.env.get("LOFI_NODE_DEBUG") === "1";16+function debug(...args: unknown[]): void {17+ if (DEBUG) console.error("[gate]", ...args);18+}19+20+/** WS close code sent when a ticket is revoked mid-session; the app should21+ * surface re-enrollment (see docs/app-ticket.md). */22+export const CLOSE_TICKET_REVOKED = 4001;23+24+const TICKET_PATH = new RegExp(`^/t/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);25+26+export interface GateOptions {27+ port: number;28+ hostname?: string;29+ /** Internal Jazz http URL, read per-request so Jazz restarts (pair()) never30+ * invalidate the gate. */31+ target: () => string;32+ mode: "open" | "ticket";33+ store: AppTicketStore;34+}35+36+export interface Gate {37+ port: number;38+ url: string;39+ /** Live gated WS connections per ticket id. */40+ stats(): { ticketId: string; connections: number }[];41+ close(): Promise<void>;42+}43+44+function unauthorized(): Response {45+ return new Response(JSON.stringify({ error: "invalid_ticket" }), {46+ status: 401,47+ headers: { "content-type": "application/json" },48+ });49+}50+51+/** Pump two OPEN (or opening) WebSockets both ways until either closes.52+ * `backlog` holds client data that arrived before the upstream dial finished;53+ * flushed before the listener attaches (same task — no interleave). */54+export function pumpWebSockets(55+ client: WebSocket,56+ upstream: WebSocket,57+ backlog: (string | ArrayBuffer)[] = [],58+): void {59+ client.binaryType = "arraybuffer";60+ upstream.binaryType = "arraybuffer";61+ let torndown = false;62+ const teardown = (code: number, reason: string) => {63+ if (torndown) return;64+ torndown = true;65+ for (const ws of [client, upstream]) {66+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {67+ try {68+ ws.close(sanitizeCloseCode(code), reason.slice(0, 120));69+ } catch {70+ // already closing71+ }72+ }73+ }74+ };75+76+ const forward = (to: WebSocket) => (ev: MessageEvent) => {77+ try {78+ to.send(ev.data);79+ } catch {80+ teardown(1011, "gate forward failed");81+ }82+ };83+ for (const data of backlog) upstream.send(data);84+ backlog.length = 0;85+ client.addEventListener("message", forward(upstream));86+ upstream.addEventListener("message", forward(client));87+ client.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));88+ upstream.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));89+ client.addEventListener("error", () => teardown(1011, "client ws error"));90+ upstream.addEventListener("error", () => teardown(1011, "upstream ws error"));91+}92+93+async function proxyHttp(req: Request, targetBase: string, path: string): Promise<Response> {94+ try {95+ const res = await fetch(new URL(path, targetBase), {96+ method: req.method,97+ headers: forwardableHeaders(req.headers),98+ body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,99+ });100+ return new Response(res.body, {101+ status: res.status,102+ headers: forwardableHeaders(res.headers),103+ });104+ } catch (e) {105+ debug("http proxy error", (e as Error).message);106+ return new Response("upstream unavailable", { status: 502 });107+ }108+}109+110+function proxyWebSocket(req: Request, targetBase: string, path: string): {111+ response: Response;112+ socket: WebSocket;113+} {114+ const requestedProtocols = req.headers.get("sec-websocket-protocol");115+ const { socket, response } = Deno.upgradeWebSocket(116+ req,117+ requestedProtocols ? { protocol: requestedProtocols.split(",")[0].trim() } : {},118+ );119+ // Buffer client data while the upstream dial is in flight; pumpWebSockets120+ // flushes before attaching its own listener (same task, no gap).121+ const backlog: (string | ArrayBuffer)[] = [];122+ const buffer = (ev: MessageEvent) => backlog.push(ev.data);123+ socket.addEventListener("message", buffer);124+ socket.addEventListener("open", () => {125+ const wsUrl = new URL(path, targetBase);126+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";127+ const upstream = requestedProtocols128+ ? new WebSocket(wsUrl, requestedProtocols.split(",").map((s) => s.trim()))129+ : new WebSocket(wsUrl);130+ upstream.addEventListener("open", () => {131+ socket.removeEventListener("message", buffer);132+ pumpWebSockets(socket, upstream, backlog);133+ });134+ upstream.addEventListener("error", () => {135+ try {136+ socket.close(1011, "upstream dial failed");137+ } catch {138+ // already closed139+ }140+ });141+ });142+ return { response, socket };143+}144+145+export function startGate(options: GateOptions): Gate {146+ const hostname = options.hostname ?? "0.0.0.0";147+ const liveByTicket = new Map<string, Set<WebSocket>>();148+149+ const track = (ticketId: string, socket: WebSocket) => {150+ let set = liveByTicket.get(ticketId);151+ if (!set) liveByTicket.set(ticketId, set = new Set());152+ set.add(socket);153+ socket.addEventListener("close", () => {154+ set.delete(socket);155+ if (set.size === 0) liveByTicket.delete(ticketId);156+ });157+ };158+159+ // Revocation sweep: piggyback on the store's hot-reload — poll live tickets160+ // and close sockets whose ticket flipped to revoked. Cheap (only runs while161+ // gated sockets exist).162+ const sweep = setInterval(async () => {163+ if (liveByTicket.size === 0) return;164+ const records = await options.store.list();165+ for (const [ticketId, sockets] of liveByTicket) {166+ const record = records.find((r) => r.id === ticketId);167+ if (record && !record.revokedAt) continue;168+ for (const socket of [...sockets]) {169+ try {170+ socket.close(CLOSE_TICKET_REVOKED, "ticket revoked");171+ } catch {172+ // already closing173+ }174+ }175+ liveByTicket.delete(ticketId);176+ }177+ }, 2000);178+179+ const server = Deno.serve(180+ { hostname, port: options.port, onListen: () => {} },181+ async (req) => {182+ const url = new URL(req.url);183+ const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";184+ const targetBase = options.target();185+186+ if (options.mode === "open") {187+ const path = url.pathname + url.search;188+ return isUpgrade189+ ? proxyWebSocket(req, targetBase, path).response190+ : proxyHttp(req, targetBase, path);191+ }192+193+ // Ticket mode.194+ if (url.pathname === "/health" && !isUpgrade) {195+ return proxyHttp(req, targetBase, "/health");196+ }197+ const match = url.pathname.match(TICKET_PATH);198+ if (!match) return new Response("not found", { status: 404 });199+ const [, secret, rest] = match;200+ const verdict = await options.store.verify(secret);201+ if (verdict.status !== "valid") {202+ debug("rejected", verdict.status, url.pathname.slice(0, 24));203+ return unauthorized();204+ }205+ const path = (rest || "/") + url.search;206+ debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id);207+ if (isUpgrade) {208+ const { response, socket } = proxyWebSocket(req, targetBase, path);209+ track(verdict.record.id, socket);210+ return response;211+ }212+ return proxyHttp(req, targetBase, path);213+ },214+ );215+216+ const port = (server.addr as Deno.NetAddr).port;217+ return {218+ port,219+ url: `http://127.0.0.1:${port}`,220+ stats: () =>221+ [...liveByTicket.entries()].map(([ticketId, sockets]) => ({222+ ticketId,223+ connections: sockets.size,224+ })),225+ close: async () => {226+ clearInterval(sweep);227+ await server.shutdown();228+ },229+ };230+}
M src/node.ts +166 -28
@@ -1,5 +1,6 @@
11 // createSyncNode — the one constructor. Consumer vocabulary is app, storage,2-// upstream, pairing, status; iroh/dlopen/tunnel/jazz-napi stay internal.2+// upstream, pairing, access, status; iroh/dlopen/tunnel/jazz-napi/gate stay3+// internal.34 45 import { loadIrohAddon } from "./native/addon.ts";56 import { IrohNode } from "./iroh/node.ts";
@@ -11,7 +12,14 @@ import {
1112 type TunnelListener,1213 } from "./tunnel.ts";1314 import { type JazzHandle, startJazz } from "./jazz.ts";14-import { loadOrCreateIrohKey, type UpstreamConfig } from "./config.ts";15+import {16+ loadOrCreateIrohKey,17+ type StorageConfig,18+ type UpstreamConfig,19+ validateStorage,20+} from "./config.ts";21+import { type Gate, startGate } from "./gate.ts";22+import { type AppTicketRecord, AppTicketStore, encodeAppTicket } from "./appticket.ts";1523 import { MeshUnavailableError } from "./errors.ts";1624 1725 export interface SyncNodeOptions {
@@ -21,8 +29,17 @@ export interface SyncNodeOptions {
2129 /** Omit (or set inMemory) for an ephemeral node — tests, throwaway relays. */2230 dataDir?: string;2331 inMemory?: boolean;24- /** Browser-facing Jazz WebSocket port; auto-allocated when omitted. */32+ /** Public port (the gate's in ticket mode); auto-allocated when omitted. */2533 listen?: { port?: number };34+ /** "open" (library default — today's behavior): everything reaching the35+ * port syncs. "ticket": only requests carrying an issued app-ticket secret36+ * (/t/<secret>/… serverUrl base path) reach Jazz; CLI init defaults here. */37+ access?: "open" | "ticket";38+ /** Where node data lives; sqlite path may be any mounted location. */39+ storage?: StorageConfig;40+ /** Base URL embedded into issued app tickets (e.g. http://192.168.1.10:4802).41+ * Defaults to the gate's local URL — fine for same-host, wrong for LAN. */42+ publicUrl?: string;2643 /** "none" (default) | direct URL (e.g. Jazz Cloud) | peer ticket over iroh. */2744 upstream?: UpstreamConfig;2845 /** "auto": bring up iroh if the dylib resolves; stay up without it unless a
@@ -38,32 +55,89 @@ export type MeshStatus =
3855 | { state: "off" }3956 | { state: "unavailable"; reason: string };4057 58+export interface AppTicketInfo {59+ id: string;60+ label?: string;61+ createdAt: string;62+ revoked: boolean;63+}64+4165 export interface SyncNodeStatus {4266 appId: string;43- jazz: { url: string; port: number; storage: "persistent" | "memory" };67+ access: "open" | "ticket";68+ jazz: { url: string; port: number; storage: StorageConfig };69+ tickets: AppTicketInfo[];4470 upstream: UpstreamConfig;4571 mesh: MeshStatus;4672 }4773 4874 export interface SyncNode {49- /** Browser-facing Jazz WebSocket URL — what JAZZ_SERVER_URL points at. */75+ /** Public URL — what JAZZ_SERVER_URL points at in open mode; in ticket mode76+ * apps use a ticket's own /t/<secret> URL against this same host:port. */5077 url: string;5178 port: number;5279 appId: string;53- /** Pairing ticket for this node; throws MeshUnavailableError if mesh is down. */80+ /** Node-pairing ticket; throws MeshUnavailableError if mesh is down. */5481 ticket(): string;55- /** Re-elect the upstream to a peer AT RUNTIME: swaps the tunnel and56- * restarts the embedded Jazz server on the SAME port, so browser clients57- * simply reconnect. Throws MeshUnavailableError if the mesh is down. */82+ /** Issue an app-connect ticket (ticket mode only). The returned string is83+ * shown once — the secret is never stored, only its digest. */84+ issueTicket(options?: { label?: string; publicBase?: string }): Promise<{85+ id: string;86+ ticket: string;87+ }>;88+ /** Revoke by id: new connections 401 immediately; live gated sockets close89+ * with 4001 within the sweep interval. */90+ revokeTicket(id: string): Promise<boolean>;91+ listTickets(): Promise<AppTicketInfo[]>;92+ /** Re-elect the upstream to a peer AT RUNTIME. The public port never93+ * changes. Throws MeshUnavailableError if the mesh is down. */5894 pair(peerTicket: string): Promise<void>;5995 status(): SyncNodeStatus;6096 stop(): Promise<void>;6197 }6298 99+function toTicketInfo(record: AppTicketRecord): AppTicketInfo {100+ return {101+ id: record.id,102+ label: record.label,103+ createdAt: record.createdAt,104+ revoked: Boolean(record.revokedAt),105+ };106+}107+108+/** Fail fast with the offending path when a user-chosen storage location is109+ * not writable (NAS/synced-volume friendliness; no silent degradation). */110+async function probeWritable(path: string): Promise<void> {111+ try {112+ await Deno.mkdir(path, { recursive: true });113+ const probe = `${path}/.lofi-write-probe`;114+ await Deno.writeTextFile(probe, "probe");115+ await Deno.remove(probe);116+ } catch (e) {117+ throw new Error(`storage path "${path}" is not writable: ${(e as Error).message}`);118+ }119+}120+63121 export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode> {64122 let upstream: UpstreamConfig = options.upstream ?? "none";65123 const meshMode = options.mesh ?? "auto";66- const inMemory = options.inMemory ?? options.dataDir === undefined;124+ const access = options.access ?? "open";125+126+ // 0. Storage election. Back-compat: explicit storage wins, else today's127+ // rules (inMemory flag / absent dataDir → memory).128+ const storage: StorageConfig = options.storage ??129+ (options.inMemory ?? options.dataDir === undefined ? { type: "memory" } : { type: "sqlite" });130+ validateStorage(storage);131+ let jazzDataDir: string | undefined;132+ if (storage.type === "sqlite") {133+ jazzDataDir = storage.path ??134+ (options.dataDir ? `${options.dataDir}/jazz` : undefined);135+ if (jazzDataDir === undefined) {136+ throw new Error('storage {type:"sqlite"} needs a path (or a node dataDir)');137+ }138+ await probeWritable(jazzDataDir);139+ }140+ const inMemory = storage.type === "memory";67141 68142 // 1. Mesh (iroh) — optional unless the upstream election requires it.69143 // Connections are attached at status() time (they live in the tunnels).
@@ -115,36 +189,70 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
115189 backendSecret: options.backendSecret,116190 adminSecret: options.adminSecret,117191 port,118- dataDir: options.dataDir ? `${options.dataDir}/jazz` : undefined,192+ dataDir: jazzDataDir,119193 inMemory,120194 upstreamUrl,121195 allowLocalFirstAuth: options.allowLocalFirstAuth,122196 });123197 }124198 125- // 3. Jazz server.199+ // 3. Jazz server. In ticket mode Jazz takes an ephemeral loopback port and200+ // the gate owns the public one; in open mode Jazz gets the listen port201+ // directly (today's exact behavior — JazzServer binds loopback-only, so202+ // "open" remains same-host/reverse-proxy territory).203+ const gated = access === "ticket";126204 let jazz: JazzHandle;127205 const initial = resolveUpstream(upstream);128206 tunnelListener = initial.listener;129207 try {130- jazz = await startJazzFor(initial.url, options.listen?.port);208+ jazz = await startJazzFor(initial.url, gated ? undefined : options.listen?.port);131209 } catch (e) {132210 await initial.listener?.close();133211 await irohNode?.close();134212 throw e;135213 }214+ if (new URL(jazz.url.replace(/^ws/, "http")).hostname !== "127.0.0.1") {215+ // The gate's enforcement assumes Jazz is loopback-only (true at216+ // alpha.53); a future alpha binding wider would need a firewall note.217+ console.warn(`lofi-node: JazzServer bound ${jazz.url}, expected loopback`);218+ }136219 137- // 4. Inbound bridge: peers' tunnels land on our Jazz server.220+ // 3b. App tickets + gate (ticket mode).221+ const ticketStore = await AppTicketStore.load(options.dataDir);222+ let gate: Gate | null = null;223+ if (gated) {224+ gate = startGate({225+ port: options.listen?.port ?? 0,226+ target: () => jazz.url.replace(/^ws/, "http"),227+ mode: "ticket",228+ store: ticketStore,229+ });230+ }231+232+ // 4. Inbound bridge: peers' tunnels land directly on the INTERNAL Jazz URL233+ // — paired peers are authenticated by possession of the node-pairing234+ // ticket, so this is not a gate bypass.138235 let acceptor: TunnelAcceptor | null = null;139236 if (irohNode) {140237 acceptor = runTunnelAcceptor(irohNode, jazz.url);141238 }142239 240+ const publicPort = gate ? gate.port : jazz.port;241+ const publicUrl = gate ? `ws://127.0.0.1:${gate.port}` : jazz.url;242+243+ // status() is sync; keep a ticket snapshot refreshed by every ticket call244+ // and opportunistically by status() itself (next call sees fresh state).245+ let ticketSnapshot: AppTicketInfo[] = (await ticketStore.list()).map(toTicketInfo);246+ const refreshTickets = async () => {247+ ticketSnapshot = (await ticketStore.list()).map(toTicketInfo);248+ return ticketSnapshot;249+ };250+143251 let stopped: Promise<void> | null = null;144252 let pairing: Promise<void> | null = null;145253 return {146- url: jazz.url,147- port: jazz.port,254+ url: publicUrl,255+ port: publicPort,148256 appId: options.appId,149257 ticket: () => {150258 if (!ticketString) {
@@ -153,12 +261,36 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
153261 }154262 return ticketString;155263 },264+ issueTicket: async (ticketOptions = {}) => {265+ if (!gate) throw new Error('issueTicket requires access: "ticket"');266+ const { record, secret } = await ticketStore.issue(ticketOptions.label);267+ const base = (ticketOptions.publicBase ?? options.publicUrl ??268+ `http://127.0.0.1:${gate.port}`).replace(/\/+$/, "");269+ const ticket = encodeAppTicket({270+ v: 1,271+ appId: options.appId,272+ url: `${base}/t/${secret}`,273+ label: ticketOptions.label,274+ node: ticketString ?? undefined,275+ });276+ await refreshTickets();277+ return { id: record.id, ticket };278+ },279+ revokeTicket: async (id: string) => {280+ const revoked = (await ticketStore.revoke(id)) !== null;281+ await refreshTickets();282+ return revoked;283+ },284+ listTickets: () => refreshTickets(),156285 pair: (peerTicket: string) => {157286 if (stopped) return Promise.reject(new Error("node is stopped"));158287 if (pairing) return Promise.reject(new Error("a pair() is already in progress"));159288 pairing = (async () => {160289 try {161- const keptPort = jazz.port;290+ // In gated mode the public port belongs to the gate, so Jazz can291+ // restart on a fresh ephemeral port; in open mode keep the port so292+ // clients reconnect to an unchanged URL.293+ const keptPort = gate ? undefined : jazz.port;162294 // resolveUpstream throws (MeshUnavailableError) BEFORE teardown.163295 const next = resolveUpstream({ peer: peerTicket });164296 const oldListener = tunnelListener;
@@ -173,20 +305,26 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
173305 })();174306 return pairing;175307 },176- status: () => ({177- appId: options.appId,178- jazz: { url: jazz.url, port: jazz.port, storage: inMemory ? "memory" : "persistent" },179- upstream,180- mesh: mesh.state === "up"181- ? {182- ...mesh,183- connections: [...(tunnelListener?.stats() ?? []), ...(acceptor?.stats() ?? [])],184- }185- : mesh,186- }),308+ status: () => {309+ refreshTickets().catch(() => {});310+ return {311+ appId: options.appId,312+ access,313+ jazz: { url: jazz.url, port: jazz.port, storage },314+ tickets: ticketSnapshot,315+ upstream,316+ mesh: mesh.state === "up"317+ ? {318+ ...mesh,319+ connections: [...(tunnelListener?.stats() ?? []), ...(acceptor?.stats() ?? [])],320+ }321+ : mesh,322+ };323+ },187324 stop: () => (stopped ??= (async () => {188325 acceptor?.close();189326 await tunnelListener?.close();327+ await gate?.close();190328 await jazz.stop();191329 await irohNode?.close();192330 })()),
M src/tunnel.ts +2 -2
A tests/appticket_test.ts +118 -0
@@ -0,0 +1,118 @@
1+import { assert, assertEquals, assertStrictEquals } from "@std/assert";2+import {3+ AppTicketStore,4+ decodeAppTicket,5+ encodeAppTicket,6+ generateSecret,7+ looksLikeAppTicket,8+ SECRET_LENGTH,9+} from "../src/appticket.ts";10+11+Deno.test("secret shape: 43 chars of url-path-safe base64url", () => {12+ for (let i = 0; i < 20; i++) {13+ const secret = generateSecret();14+ assertEquals(secret.length, SECRET_LENGTH);15+ assert(/^[A-Za-z0-9_-]+$/.test(secret), `charset: ${secret}`);16+ }17+});18+19+Deno.test("app ticket round-trips including optional fields", () => {20+ const secret = generateSecret();21+ const ticket = {22+ v: 1 as const,23+ appId: crypto.randomUUID(),24+ url: `http://192.168.1.10:4802/t/${secret}`,25+ label: "phone",26+ node: "endpointabc123",27+ };28+ const decoded = decodeAppTicket(encodeAppTicket(ticket));29+ assertEquals(decoded, ticket);30+ assertEquals(decodeAppTicket(` ${encodeAppTicket(ticket)}\n`), ticket, "tolerates whitespace");31+});32+33+Deno.test("decode rejects malformed tickets without throwing", () => {34+ assertStrictEquals(decodeAppTicket(""), null);35+ assertStrictEquals(decodeAppTicket("lofisync1."), null);36+ assertStrictEquals(37+ decodeAppTicket("endpointabcdef"),38+ null,39+ "pairing tickets are not app tickets",40+ );41+ const bad = { v: 1, appId: "x", url: "http://h/wrong-path" };42+ assertStrictEquals(43+ decodeAppTicket("lofisync1." + btoa(JSON.stringify(bad))),44+ null,45+ "url must embed /t/<secret>",46+ );47+ const wsUrl = { v: 1, appId: "x", url: `ws://h/t/${generateSecret()}` };48+ assertStrictEquals(49+ decodeAppTicket("lofisync1." + btoa(JSON.stringify(wsUrl))),50+ null,51+ "url must be http(s)",52+ );53+});54+55+Deno.test("looksLikeAppTicket distinguishes ticket kinds", () => {56+ assert(looksLikeAppTicket("lofisync1.abcd"));57+ assert(!looksLikeAppTicket("endpointabcd"));58+});59+60+Deno.test("store: issue → verify → revoke → verify, with persistence", async () => {61+ const dir = await Deno.makeTempDir();62+ try {63+ const store = await AppTicketStore.load(dir);64+ const { record, secret } = await store.issue("laptop");65+ assertEquals(record.label, "laptop");66+ assertEquals(record.id, record.secretHash.slice(0, 12));67+68+ const valid = await store.verify(secret);69+ assert(valid.status === "valid" && valid.record.id === record.id);70+ assertEquals((await store.verify(generateSecret())).status, "unknown");71+72+ await store.revoke(record.id);73+ assertEquals((await store.verify(secret)).status, "revoked");74+75+ // A fresh store over the same dir sees the persisted state.76+ const reloaded = await AppTicketStore.load(dir);77+ assertEquals((await reloaded.verify(secret)).status, "revoked");78+ assertEquals((await reloaded.list()).length, 1);79+ } finally {80+ await Deno.remove(dir, { recursive: true });81+ }82+});83+84+Deno.test("store: external file change is picked up (CLI → daemon hot-reload)", async () => {85+ const dir = await Deno.makeTempDir();86+ try {87+ const daemonStore = await AppTicketStore.load(dir);88+ assertEquals((await daemonStore.list()).length, 0);89+90+ // A second store instance models the CLI process writing the file.91+ const cliStore = await AppTicketStore.load(dir);92+ const { record, secret } = await cliStore.issue("issued-by-cli");93+94+ // Force the throttle window to expire, then verify through the daemon.95+ await new Promise((r) => setTimeout(r, 1100));96+ const seen = await daemonStore.verify(secret);97+ assert(98+ seen.status === "valid" && seen.record.id === record.id,99+ "daemon sees CLI-issued ticket",100+ );101+102+ await cliStore.revoke(record.id);103+ await new Promise((r) => setTimeout(r, 1100));104+ assertEquals(105+ (await daemonStore.verify(secret)).status,106+ "revoked",107+ "daemon sees CLI revocation",108+ );109+ } finally {110+ await Deno.remove(dir, { recursive: true });111+ }112+});113+114+Deno.test("store: in-memory fallback works without a dataDir", async () => {115+ const store = await AppTicketStore.load();116+ const { secret } = await store.issue();117+ assertEquals((await store.verify(secret)).status, "valid");118+});
M tests/convergence_test.ts +57 -1
@@ -9,11 +9,12 @@
99 // Test 2 is the full topology: each device on its own node, nodes paired over1010 // the iroh tunnel.1111 12-import { assertEquals } from "@std/assert";12+import { assert, assertEquals } from "@std/assert";1313 import { createDb, schema as s } from "jazz-tools";1414 import { deploy } from "jazz-tools/testing";1515 import { definePermissions } from "jazz-tools/permissions";1616 import { createSyncNode, type SyncNode } from "../src/node.ts";17+import { decodeAppTicket } from "../src/appticket.ts";1718 import { resolveIrohLib } from "../src/native/loader.ts";1819 1920 const app = s.defineApp({
@@ -223,3 +224,58 @@ Deno.test({
223224 }224225 },225226 });227+228+Deno.test({229+ name: "convergence fixture: ticket-gated node — jazz clients sync through /t/<secret> unchanged",230+ sanitizeOps: false,231+ sanitizeResources: false,232+ fn: async () => {233+ const node = await createSyncNode({234+ appId: crypto.randomUUID(),235+ backendSecret: "lofi_backend_gate",236+ adminSecret: "lofi_admin_gate",237+ inMemory: true,238+ mesh: "off",239+ access: "ticket",240+ });241+ try {242+ const issued = await node.issueTicket({ label: "fixture" });243+ const parsed = decodeAppTicket(issued.ticket);244+ assert(parsed !== null, "issued ticket decodes");245+ const gatedUrl = parsed.url; // http://127.0.0.1:<gatePort>/t/<secret>246+ assertEquals(parsed.appId, node.appId);247+248+ // ZERO client changes: the ticket URL is the serverUrl for BOTH the249+ // admin deploy (catalogue HTTP through the gate) and the devices.250+ const target = {251+ serverUrl: gatedUrl,252+ appId: node.appId,253+ adminSecret: "lofi_admin_gate",254+ };255+ await deploy({ ...target, schema: app, permissions });256+ const [deviceA, deviceB] = await connectDevices(target, target);257+ try {258+ await runConvergenceFixture(deviceA, deviceB);259+ } finally {260+ await Promise.allSettled([261+ within(deviceA.logout(), "device A cleanup", 3000),262+ within(deviceB.logout(), "device B cleanup", 3000),263+ ]);264+ }265+266+ // Negative: a revoked ticket is rejected at the gate (401 → jazz client267+ // fetch fails), and a bogus secret never reaches Jazz.268+ await node.revokeTicket(issued.id);269+ const res = await fetch(`${gatedUrl}/apps/${node.appId}/schemas`, {270+ headers: { "X-Jazz-Admin-Secret": "lofi_admin_gate" },271+ });272+ await res.body?.cancel();273+ assertEquals(res.status, 401, "revoked ticket → 401 at the gate");274+ const tickets = await node.listTickets();275+ assertEquals(tickets.length, 1);276+ assert(tickets[0].revoked, "status reflects revocation");277+ } finally {278+ await node.stop();279+ }280+ },281+});
A tests/gate_test.ts +177 -0
@@ -0,0 +1,177 @@
1+// Gate behavior against a stub upstream (no jazz, no iroh): routing,2+// secret verification, prefix stripping, WS re-origination, revocation.3+4+import { assert, assertEquals } from "@std/assert";5+import { AppTicketStore } from "../src/appticket.ts";6+import { CLOSE_TICKET_REVOKED, startGate } from "../src/gate.ts";7+8+/** Stub upstream: echoes path+search+selected headers over HTTP; echoes9+ * messages and reports path/protocol over WS. */10+function startStubUpstream() {11+ const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {12+ const url = new URL(req.url);13+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {14+ const protocol = req.headers.get("sec-websocket-protocol")?.split(",")[0].trim();15+ const { socket, response } = Deno.upgradeWebSocket(req, protocol ? { protocol } : {});16+ socket.addEventListener("open", () => {17+ socket.send(18+ JSON.stringify({ hello: url.pathname + url.search, protocol: protocol ?? null }),19+ );20+ });21+ socket.addEventListener("message", (ev) => socket.send(ev.data));22+ return response;23+ }24+ return new Response(25+ JSON.stringify({26+ path: url.pathname + url.search,27+ admin: req.headers.get("x-jazz-admin-secret"),28+ method: req.method,29+ }),30+ { headers: { "content-type": "application/json" } },31+ );32+ });33+ return {34+ url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,35+ close: () => server.shutdown(),36+ };37+}38+39+async function withGate(40+ mode: "open" | "ticket",41+ fn: (ctx: {42+ gateUrl: string;43+ store: AppTicketStore;44+ gate: ReturnType<typeof startGate>;45+ }) => Promise<void>,46+) {47+ const upstream = startStubUpstream();48+ const store = await AppTicketStore.load();49+ const gate = startGate({50+ port: 0,51+ hostname: "127.0.0.1",52+ target: () => upstream.url,53+ mode,54+ store,55+ });56+ try {57+ await fn({ gateUrl: `http://127.0.0.1:${gate.port}`, store, gate });58+ } finally {59+ await gate.close();60+ await upstream.close();61+ }62+}63+64+Deno.test({65+ name: "gate: valid secret strips prefix, preserves search, streams headers",66+ sanitizeOps: false,67+ sanitizeResources: false,68+ fn: () =>69+ withGate("ticket", async ({ gateUrl, store }) => {70+ const { secret } = await store.issue("t");71+ const res = await fetch(72+ `${gateUrl}/t/${secret}/apps/abc/admin/schema-connectivity?appId=xyz`,73+ { headers: { "X-Jazz-Admin-Secret": "adm" } },74+ );75+ assertEquals(res.status, 200);76+ const body = await res.json();77+ assertEquals(body.path, "/apps/abc/admin/schema-connectivity?appId=xyz");78+ assertEquals(body.admin, "adm", "admin header forwarded for Jazz's own enforcement");79+ }),80+});81+82+Deno.test({83+ name: "gate: /health is open; everything else 401/404",84+ sanitizeOps: false,85+ sanitizeResources: false,86+ fn: () =>87+ withGate("ticket", async ({ gateUrl, store }) => {88+ assertEquals((await (await fetch(`${gateUrl}/health`)).json()).path, "/health");89+ assertEquals((await fetch(`${gateUrl}/apps/abc/ws`)).status, 404, "no ticket prefix → 404");90+ const bogus = "A".repeat(43);91+ assertEquals((await fetch(`${gateUrl}/t/${bogus}/apps/abc`)).status, 401);92+ const { record, secret } = await store.issue();93+ await store.revoke(record.id);94+ assertEquals((await fetch(`${gateUrl}/t/${secret}/apps/abc`)).status, 401, "revoked → 401");95+ }),96+});97+98+Deno.test({99+ name: "gate: WS round-trip with subprotocol echo and path stripping",100+ sanitizeOps: false,101+ sanitizeResources: false,102+ fn: () =>103+ withGate("ticket", async ({ gateUrl, store }) => {104+ const { secret } = await store.issue();105+ const ws = new WebSocket(106+ `${gateUrl.replace("http", "ws")}/t/${secret}/apps/abc/ws?key=1`,107+ ["jazz-sync"],108+ );109+ const messages: string[] = [];110+ const done = new Promise<void>((resolve, reject) => {111+ const timer = setTimeout(() => reject(new Error("ws timeout")), 8000);112+ ws.addEventListener("message", (ev) => {113+ messages.push(ev.data as string);114+ if (messages.length === 2) {115+ clearTimeout(timer);116+ resolve();117+ }118+ });119+ ws.addEventListener("error", () => {120+ clearTimeout(timer);121+ reject(new Error("ws error"));122+ });123+ });124+ await new Promise<void>((resolve, reject) => {125+ ws.addEventListener("open", () => resolve());126+ ws.addEventListener("close", () => reject(new Error("closed before open")));127+ });128+ assertEquals(ws.protocol, "jazz-sync", "subprotocol negotiated through the gate");129+ ws.send("ping through gate");130+ await done;131+ const hello = JSON.parse(messages[0]);132+ assertEquals(hello.hello, "/apps/abc/ws?key=1", "upstream saw stripped path + search");133+ assertEquals(hello.protocol, "jazz-sync");134+ assertEquals(messages[1], "ping through gate", "echo round-trips");135+ ws.close(1000);136+ }),137+});138+139+Deno.test({140+ name: "gate: revocation closes live sockets with 4001",141+ sanitizeOps: false,142+ sanitizeResources: false,143+ fn: () =>144+ withGate("ticket", async ({ gateUrl, store, gate }) => {145+ const { record, secret } = await store.issue("victim");146+ const ws = new WebSocket(`${gateUrl.replace("http", "ws")}/t/${secret}/apps/abc/ws`);147+ await new Promise<void>((resolve, reject) => {148+ ws.addEventListener("open", () => resolve());149+ ws.addEventListener("close", () => reject(new Error("closed before open")));150+ });151+ assertEquals(gate.stats(), [{ ticketId: record.id, connections: 1 }]);152+153+ const closed = new Promise<CloseEvent>((resolve) =>154+ ws.addEventListener("close", (ev) => resolve(ev))155+ );156+ await store.revoke(record.id);157+ const ev = await Promise.race([158+ closed,159+ new Promise<never>((_r, reject) =>160+ setTimeout(() => reject(new Error("no revocation close within 6s")), 6000)161+ ),162+ ]);163+ assertEquals(ev.code, CLOSE_TICKET_REVOKED);164+ assertEquals(gate.stats(), []);165+ }),166+});167+168+Deno.test({169+ name: "gate: open mode proxies without ticket prefixes",170+ sanitizeOps: false,171+ sanitizeResources: false,172+ fn: () =>173+ withGate("open", async ({ gateUrl }) => {174+ const res = await fetch(`${gateUrl}/apps/abc/schemas?x=1`);175+ assertEquals((await res.json()).path, "/apps/abc/schemas?x=1");176+ }),177+});
M tests/pair_test.ts +31 -0
A tests/storage_config_test.ts +91 -0
@@ -0,0 +1,91 @@
1+// Storage election: user-chosen sqlite path honored on disk, unwritable paths2+// fail fast and named, v1 configs migrate to v2 defaults.3+4+import { assert, assertEquals, assertRejects } from "@std/assert";5+import { createSyncNode } from "../src/node.ts";6+import { initConfig, loadConfig, saveConfig } from "../src/config.ts";7+8+Deno.test({9+ name: "storage: user-chosen sqlite path is honored on disk",10+ sanitizeOps: false,11+ sanitizeResources: false,12+ fn: async () => {13+ const dir = await Deno.makeTempDir();14+ const node = await createSyncNode({15+ appId: crypto.randomUUID(),16+ backendSecret: "b",17+ adminSecret: "a",18+ mesh: "off",19+ storage: { type: "sqlite", path: `${dir}/my-chosen-location` },20+ });21+ try {22+ assertEquals(node.status().jazz.storage, {23+ type: "sqlite",24+ path: `${dir}/my-chosen-location`,25+ });26+ const files = [...Deno.readDirSync(`${dir}/my-chosen-location`)].map((e) => e.name);27+ assert(files.length > 0, `jazz wrote into the chosen path (found: ${files.join(", ")})`);28+ } finally {29+ await node.stop();30+ await Deno.remove(dir, { recursive: true });31+ }32+ },33+});34+35+Deno.test({36+ name: "storage: unwritable path fails fast with the path named",37+ sanitizeOps: false,38+ sanitizeResources: false,39+ fn: async () => {40+ await assertRejects(41+ () =>42+ createSyncNode({43+ appId: crypto.randomUUID(),44+ backendSecret: "b",45+ adminSecret: "a",46+ mesh: "off",47+ storage: { type: "sqlite", path: "/dev/null/not-a-dir" },48+ }),49+ Error,50+ "/dev/null/not-a-dir",51+ );52+ },53+});54+55+Deno.test("config: v1 file migrates to v2 with open access + sqlite storage", async () => {56+ const dir = await Deno.makeTempDir();57+ try {58+ await Deno.writeTextFile(59+ `${dir}/config.json`,60+ JSON.stringify({61+ v: 1,62+ appId: "11111111-1111-1111-1111-111111111111",63+ backendSecret: "b",64+ adminSecret: "a",65+ listenPort: 4900,66+ upstream: "none",67+ allowLocalFirstAuth: true,68+ }),69+ );70+ const config = await loadConfig(dir);71+ assert(config !== null);72+ assertEquals(config.v, 2);73+ assertEquals(config.access, "open", "v1 dirs keep today's behavior");74+ assertEquals(config.storage, { type: "sqlite" });75+ await saveConfig(dir, config);76+ assertEquals(JSON.parse(await Deno.readTextFile(`${dir}/config.json`)).v, 2);77+ } finally {78+ await Deno.remove(dir, { recursive: true });79+ }80+});81+82+Deno.test("config: new inits default to ticket access", async () => {83+ const dir = await Deno.makeTempDir();84+ try {85+ const config = await initConfig(dir);86+ assertEquals(config.access, "ticket");87+ assertEquals(config.storage, { type: "sqlite" });88+ } finally {89+ await Deno.remove(dir, { recursive: true });90+ }91+});