clone

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

docs/convergence-validated
lofi-node / src / node.ts
node.ts 14.1K
1// createSyncNode — the one constructor. Consumer vocabulary is app, storage,2// upstream, pairing, access, status; iroh/dlopen/tunnel/jazz-napi/gate stay3// internal.45import { loadIrohAddon } from "./native/addon.ts";6import { IrohNode } from "./iroh/node.ts";7import {8 runTunnelAcceptor,9 startTunnelListener,10 type TunnelAcceptor,11 type TunnelConnStat,12 type TunnelListener,13} from "./tunnel.ts";14import { type JazzHandle, startJazz } from "./jazz.ts";15import {16 loadOrCreateIrohKey,17 type RelayConfig,18 type StorageConfig,19 type UpstreamConfig,20 validateRelay,21 validateStorage,22} from "./config.ts";23import { type Gate, startGate } from "./gate.ts";24import {25 type AppTicketRecord,26 AppTicketStore,27 encodeAppTicket,28 isRevokedByLineage,29} from "./appticket.ts";30import { MeshUnavailableError } from "./errors.ts";3132/** Options for {@link createSyncNode}. */33export interface SyncNodeOptions {34 /** Jazz app id (a UUID). */35 appId: string;36 /** Jazz backend secret. */37 backendSecret: string;38 /** Jazz admin secret; in ticket mode the gate injects it server-side and39 * it never transits clients. */40 adminSecret: string;41 /** Omit (or set inMemory) for an ephemeral node — tests, throwaway relays. */42 dataDir?: string;43 inMemory?: boolean;44 /** Public port (the gate's in ticket mode); auto-allocated when omitted. */45 listen?: { port?: number };46 /** "open" (library default — today's behavior): everything reaching the47 * port syncs. "ticket": only requests carrying an issued app-ticket secret48 * (/t/<secret>/… serverUrl base path) reach Jazz; CLI init defaults here. */49 access?: "open" | "ticket";50 /** Where node data lives; sqlite path may be any mounted location. */51 storage?: StorageConfig;52 /** Base URL embedded into issued app tickets (e.g. http://192.168.1.10:4802).53 * Defaults to the gate's local URL — fine for same-host, wrong for LAN. */54 publicUrl?: string;55 /** "none" (default) | direct URL (e.g. Jazz Cloud) | peer ticket over iroh. */56 upstream?: UpstreamConfig;57 /** "auto": bring up iroh if the dylib resolves; stay up without it unless a58 * peer upstream NEEDS it. "off": plain Jazz server, dylib never loaded. */59 mesh?: "auto" | "off";60 /** Relay election for the iroh endpoint: "n0" (default — the public61 * n0-computer relays, fine for dev/testing), { urls } for operator-run62 * relays, or "disabled" for direct connections only. Peers dial this node63 * via the relay named in its pairing ticket, so no matching config is64 * needed on the other side. */65 relay?: RelayConfig;66 allowLocalFirstAuth?: boolean;67 /** Explicit addon path (otherwise LOFI_NODE_IROH / in-repo build). */68 irohLibPath?: string;69}7071/** iroh mesh state: up (with pairing ticket + live tunnel stats), off, or72 * unavailable with the precise reason (no silent degradation). */73export type MeshStatus =74 | { state: "up"; nodeId: string; ticket: string; connections: TunnelConnStat[] }75 | { state: "off" }76 | { state: "unavailable"; reason: string };7778/** Public view of an issued app-connect ticket (never the secret). */79export interface AppTicketInfo {80 id: string;81 scope: "sync" | "provision";82 label?: string;83 /** Id of the ticket this one was derived from (scope-down exchange). */84 parentId?: string;85 createdAt: string;86 /** True when the ticket itself or any ancestor is revoked. */87 revoked: boolean;88}8990/** Snapshot returned by {@link SyncNode.status}. */91export interface SyncNodeStatus {92 appId: string;93 access: "open" | "ticket";94 jazz: { url: string; port: number; storage: StorageConfig };95 tickets: AppTicketInfo[];96 upstream: UpstreamConfig;97 mesh: MeshStatus;98}99100/** A running sync node — see {@link createSyncNode}. */101export interface SyncNode {102 /** Public URL — what JAZZ_SERVER_URL points at in open mode; in ticket mode103 * apps use a ticket's own /t/<secret> URL against this same host:port. */104 url: string;105 port: number;106 appId: string;107 /** Node-pairing ticket; throws MeshUnavailableError if mesh is down. */108 ticket(): string;109 /** Issue an app-connect ticket (ticket mode only). The returned string is110 * shown once — the secret is never stored, only its digest. scope111 * "provision" additionally unlocks store administration through the gate112 * (a strict superset of sync). */113 issueTicket(114 options?: { label?: string; publicBase?: string; scope?: "sync" | "provision" },115 ): Promise<{116 id: string;117 ticket: string;118 }>;119 /** Revoke by id: new connections 401 immediately; live gated sockets close120 * with 4001 within the sweep interval. */121 revokeTicket(id: string): Promise<boolean>;122 listTickets(): Promise<AppTicketInfo[]>;123 /** Re-elect the upstream to a peer AT RUNTIME. The public port never124 * changes. Throws MeshUnavailableError if the mesh is down. */125 pair(peerTicket: string): Promise<void>;126 status(): SyncNodeStatus;127 stop(): Promise<void>;128}129130function toTicketInfo(record: AppTicketRecord, all: AppTicketRecord[]): AppTicketInfo {131 return {132 id: record.id,133 scope: record.scope ?? "sync",134 label: record.label,135 parentId: record.parentId,136 createdAt: record.createdAt,137 revoked: isRevokedByLineage(record, all),138 };139}140141/** Fail fast with the offending path when a user-chosen storage location is142 * not writable (NAS/synced-volume friendliness; no silent degradation). */143async function probeWritable(path: string): Promise<void> {144 try {145 await Deno.mkdir(path, { recursive: true });146 const probe = `${path}/.lofi-write-probe`;147 await Deno.writeTextFile(probe, "probe");148 await Deno.remove(probe);149 } catch (e) {150 throw new Error(`storage path "${path}" is not writable: ${(e as Error).message}`);151 }152}153154/**155 * Start a sync node: an embedded Jazz server, optional iroh mesh, and (in156 * ticket mode) the public access gate. The returned {@link SyncNode} is the157 * whole operational surface: tickets, pairing, status, stop.158 */159export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode> {160 let upstream: UpstreamConfig = options.upstream ?? "none";161 const meshMode = options.mesh ?? "auto";162 const access = options.access ?? "open";163 const relay: RelayConfig = options.relay ?? "n0";164 validateRelay(relay);165166 // 0. Storage election. Back-compat: explicit storage wins, else today's167 // rules (inMemory flag / absent dataDir → memory).168 const storage: StorageConfig = options.storage ??169 (options.inMemory ?? options.dataDir === undefined ? { type: "memory" } : { type: "sqlite" });170 validateStorage(storage);171 let jazzDataDir: string | undefined;172 if (storage.type === "sqlite") {173 jazzDataDir = storage.path ??174 (options.dataDir ? `${options.dataDir}/jazz` : undefined);175 if (jazzDataDir === undefined) {176 throw new Error('storage {type:"sqlite"} needs a path (or a node dataDir)');177 }178 await probeWritable(jazzDataDir);179 }180 const inMemory = storage.type === "memory";181182 // 1. Mesh (iroh) — optional unless the upstream election requires it.183 // Connections are attached at status() time (they live in the tunnels).184 let mesh:185 | { state: "up"; nodeId: string; ticket: string }186 | { state: "off" }187 | { state: "unavailable"; reason: string } = { state: "off" };188 let irohNode: IrohNode | null = null;189 let ticketString: string | null = null;190 if (meshMode === "auto") {191 try {192 const addon = await loadIrohAddon(options.irohLibPath);193 const key = options.dataDir194 ? await loadOrCreateIrohKey(options.dataDir)195 : crypto.getRandomValues(new Uint8Array(32));196 irohNode = await IrohNode.open(addon, key, relay);197 ticketString = await irohNode.ticket();198 mesh = { state: "up", nodeId: irohNode.idString(), ticket: ticketString };199 } catch (e) {200 if (e instanceof MeshUnavailableError) {201 mesh = { state: "unavailable", reason: e.message };202 } else {203 throw e;204 }205 }206 }207208 // 2. Resolve the upstream election to a concrete URL for JazzServer.209 let tunnelListener: TunnelListener | null = null;210 function resolveUpstream(211 election: UpstreamConfig,212 ): { url: string | undefined; listener: TunnelListener | null } {213 if (election === "none") return { url: undefined, listener: null };214 if ("url" in (election as object)) {215 return { url: (election as { url: string }).url, listener: null };216 }217 const ticket = (election as { peer: string }).peer;218 if (!irohNode) {219 const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";220 throw new MeshUnavailableError(`peer upstream requires the mesh: ${reason}`);221 }222 const listener = startTunnelListener(irohNode, ticket);223 return { url: `ws://127.0.0.1:${listener.port}`, listener };224 }225226 function startJazzFor(upstreamUrl: string | undefined, port: number | undefined) {227 return startJazz({228 appId: options.appId,229 backendSecret: options.backendSecret,230 adminSecret: options.adminSecret,231 port,232 dataDir: jazzDataDir,233 inMemory,234 upstreamUrl,235 allowLocalFirstAuth: options.allowLocalFirstAuth,236 });237 }238239 // 3. Jazz server. In ticket mode Jazz takes an ephemeral loopback port and240 // the gate owns the public one; in open mode Jazz gets the listen port241 // directly (today's exact behavior — JazzServer binds loopback-only, so242 // "open" remains same-host/reverse-proxy territory).243 const gated = access === "ticket";244 let jazz: JazzHandle;245 const initial = resolveUpstream(upstream);246 tunnelListener = initial.listener;247 try {248 jazz = await startJazzFor(initial.url, gated ? undefined : options.listen?.port);249 } catch (e) {250 await initial.listener?.close();251 await irohNode?.close();252 throw e;253 }254 if (new URL(jazz.url.replace(/^ws/, "http")).hostname !== "127.0.0.1") {255 // The gate's enforcement assumes Jazz is loopback-only (true at256 // alpha.53); a future alpha binding wider would need a firewall note.257 console.warn(`lofi-node: JazzServer bound ${jazz.url}, expected loopback`);258 }259260 // 3b. App tickets + gate (ticket mode).261 const ticketStore = await AppTicketStore.load(options.dataDir);262 let gate: Gate | null = null;263 if (gated) {264 gate = startGate({265 port: options.listen?.port ?? 0,266 target: () => jazz.url.replace(/^ws/, "http"),267 mode: "ticket",268 store: ticketStore,269 appId: options.appId,270 adminSecret: options.adminSecret,271 publicBase: options.publicUrl,272 nodeTicket: ticketString ?? undefined,273 });274 }275276 // 4. Inbound bridge: peers' tunnels land directly on the INTERNAL Jazz URL277 // — paired peers are authenticated by possession of the node-pairing278 // ticket, so this is not a gate bypass.279 let acceptor: TunnelAcceptor | null = null;280 if (irohNode) {281 acceptor = runTunnelAcceptor(irohNode, jazz.url);282 }283284 const publicPort = gate ? gate.port : jazz.port;285 const publicUrl = gate ? `ws://127.0.0.1:${gate.port}` : jazz.url;286287 // status() is sync; keep a ticket snapshot refreshed by every ticket call288 // and opportunistically by status() itself (next call sees fresh state).289 const snapshotTickets = async () => {290 const records = await ticketStore.list();291 return records.map((record) => toTicketInfo(record, records));292 };293 let ticketSnapshot: AppTicketInfo[] = await snapshotTickets();294 const refreshTickets = async () => {295 ticketSnapshot = await snapshotTickets();296 return ticketSnapshot;297 };298299 let stopped: Promise<void> | null = null;300 let pairing: Promise<void> | null = null;301 return {302 url: publicUrl,303 port: publicPort,304 appId: options.appId,305 ticket: () => {306 if (!ticketString) {307 const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";308 throw new MeshUnavailableError(reason);309 }310 return ticketString;311 },312 issueTicket: async (ticketOptions = {}) => {313 if (!gate) throw new Error('issueTicket requires access: "ticket"');314 const scope = ticketOptions.scope ?? "sync";315 const { record, secret } = await ticketStore.issue(ticketOptions.label, scope);316 const base = (ticketOptions.publicBase ?? options.publicUrl ??317 `http://127.0.0.1:${gate.port}`).replace(/\/+$/, "");318 const ticket = encodeAppTicket({319 v: 1,320 appId: options.appId,321 url: `${base}/t/${secret}`,322 scope: scope === "provision" ? "provision" : undefined,323 label: ticketOptions.label,324 node: ticketString ?? undefined,325 });326 await refreshTickets();327 return { id: record.id, ticket };328 },329 revokeTicket: async (id: string) => {330 const revoked = (await ticketStore.revoke(id)) !== null;331 await refreshTickets();332 return revoked;333 },334 listTickets: () => refreshTickets(),335 pair: (peerTicket: string) => {336 if (stopped) return Promise.reject(new Error("node is stopped"));337 if (pairing) return Promise.reject(new Error("a pair() is already in progress"));338 pairing = (async () => {339 try {340 // In gated mode the public port belongs to the gate, so Jazz can341 // restart on a fresh ephemeral port; in open mode keep the port so342 // clients reconnect to an unchanged URL.343 const keptPort = gate ? undefined : jazz.port;344 // resolveUpstream throws (MeshUnavailableError) BEFORE teardown.345 const next = resolveUpstream({ peer: peerTicket });346 const oldListener = tunnelListener;347 tunnelListener = next.listener;348 await oldListener?.close();349 await jazz.stop();350 jazz = await startJazzFor(next.url, keptPort);351 upstream = { peer: peerTicket };352 } finally {353 pairing = null;354 }355 })();356 return pairing;357 },358 status: () => {359 refreshTickets().catch(() => {});360 return {361 appId: options.appId,362 access,363 jazz: { url: jazz.url, port: jazz.port, storage },364 tickets: ticketSnapshot,365 upstream,366 mesh: mesh.state === "up"367 ? {368 ...mesh,369 connections: [...(tunnelListener?.stats() ?? []), ...(acceptor?.stats() ?? [])],370 }371 : mesh,372 };373 },374 stop: () => (stopped ??= (async () => {375 acceptor?.close();376 await tunnelListener?.close();377 await gate?.close();378 await jazz.stop();379 await irohNode?.close();380 })()),381 };382}