clone

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

feat/browser-cors-sync-tickets
lofi-node / cli.ts
cli.ts 12.8K
1/**2 * The lofi-node CLI: `init`, `start`, `pair`, `ticket issue|list|revoke`,3 * `status`.4 *5 * Run it straight from JSR:6 *7 * ```sh8 * dx -A jsr:@nzip/lofi-node/cli init --port 48029 * dx -A jsr:@nzip/lofi-node/cli start10 * ```11 *12 * Ticket verbs write `<dataDir>/tickets.json` directly; a RUNNING daemon13 * picks changes up via the ticket store's mtime hot-reload, so issue/revoke14 * take effect without a restart and without IPC.15 *16 * @module17 */1819import {20 initConfig,21 loadConfig,22 type RelayConfig,23 saveConfig,24 type StorageConfig,25 validateRelay,26} from "./src/config.ts";27import { createSyncNode } from "./src/node.ts";28import { looksLikeTicket } from "./src/ticket.ts";29import {30 AppTicketStore,31 encodeAppTicket,32 isRevokedByLineage,33 looksLikeAppTicket,34} from "./src/appticket.ts";35import { readTicketActivity } from "./src/ticket-activity.ts";3637const USAGE = `lofi-node — self-hostable sync node for lofi apps3839Usage:40 lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]41 [--public-url <base>] [--open] [--storage-path <path>] [--memory]42 [--relay <url[,url…]>] [--no-relay]43 lofi-node start [--dir <dataDir>]44 lofi-node pair <node-ticket> [--dir <dataDir>]45 lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]46 lofi-node ticket list [--dir <dataDir>]47 lofi-node ticket revoke <id> [--dir <dataDir>]48 lofi-node status [--dir <dataDir>]4950The data directory defaults to ./lofi-node-data. New inits are ticket-gated51(--open opts out); app tickets are issued with \`ticket issue\` and pasted52into the lofi app.5354Relays default to n0's public servers — rate-limited, fine for development.55For production point --relay at your own iroh-relay (comma-separated for56more than one), or pass --no-relay for direct connections only.`;5758interface Args {59 command: string;60 positional: string[];61 dir: string;62 appId?: string;63 port?: number;64 publicUrl?: string;65 label?: string;66 url?: string;67 open: boolean;68 memory: boolean;69 provision: boolean;70 storagePath?: string;71 relayUrls: string[];72 noRelay: boolean;73}7475function parseArgs(argv: string[]): Args {76 const args: Args = {77 command: argv[0] ?? "",78 positional: [],79 dir: "./lofi-node-data",80 open: false,81 memory: false,82 provision: false,83 relayUrls: [],84 noRelay: false,85 };86 for (let i = 1; i < argv.length; i++) {87 const arg = argv[i];88 if (arg === "--dir") args.dir = argv[++i];89 else if (arg === "--app-id") args.appId = argv[++i];90 else if (arg === "--port") args.port = Number(argv[++i]);91 else if (arg === "--public-url") args.publicUrl = argv[++i];92 else if (arg === "--label") args.label = argv[++i];93 else if (arg === "--url") args.url = argv[++i];94 else if (arg === "--open") args.open = true;95 else if (arg === "--memory") args.memory = true;96 else if (arg === "--provision") args.provision = true;97 else if (arg === "--storage-path") args.storagePath = argv[++i];98 else if (arg === "--relay") {99 args.relayUrls.push(...(argv[++i] ?? "").split(",").map((u) => u.trim()).filter(Boolean));100 } else if (arg === "--no-relay") args.noRelay = true;101 else args.positional.push(arg);102 }103 return args;104}105106function fail(message: string): never {107 console.error(`error: ${message}`);108 Deno.exit(1);109}110111async function requireConfig(dir: string) {112 const config = await loadConfig(dir);113 if (!config) fail(`no config at ${dir}/config.json — run: lofi-node init --dir ${dir}`);114 return config;115}116117function describeStorage(storage: StorageConfig, dir: string): string {118 if (storage.type === "memory") return "memory (no persistence)";119 return `sqlite (${storage.path ?? `${dir}/jazz`})`;120}121122function describeRelay(relay: RelayConfig | undefined): string {123 if (relay === undefined || relay === "n0") return "n0 public relays (dev default)";124 if (relay === "disabled") return "disabled (direct connections only)";125 return `custom (${relay.urls.join(", ")})`;126}127128function lanAddress(): string | null {129 try {130 for (const iface of Deno.networkInterfaces()) {131 if (iface.family === "IPv4" && !iface.address.startsWith("127.")) return iface.address;132 }133 } catch {134 // permission or platform issue — fall through135 }136 return null;137}138139async function cmdInit(args: Args) {140 const existing = await loadConfig(args.dir);141 if (existing) fail(`already initialized: ${args.dir}/config.json`);142 const storage: StorageConfig = args.memory143 ? { type: "memory" }144 : { type: "sqlite", path: args.storagePath };145 if (args.noRelay && args.relayUrls.length > 0) {146 fail("--no-relay and --relay are mutually exclusive");147 }148 const relay: RelayConfig | undefined = args.noRelay149 ? "disabled"150 : args.relayUrls.length > 0151 ? { urls: args.relayUrls }152 : undefined;153 if (relay) {154 try {155 validateRelay(relay);156 } catch (e) {157 fail((e as Error).message);158 }159 }160 const config = await initConfig(args.dir, {161 appId: args.appId,162 listenPort: args.port,163 access: args.open ? "open" : "ticket",164 storage,165 publicUrl: args.publicUrl,166 relay,167 });168 console.log(`initialized ${args.dir}`);169 console.log(` app id: ${config.appId}`);170 console.log(` access: ${config.access}`);171 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);172 console.log(` relay: ${describeRelay(config.relay)}`);173 console.log(` listen: ${config.listenPort ?? "(auto)"}`);174 if (config.access === "ticket") {175 console.log(`\nNext: lofi-node start --dir ${args.dir}`);176 console.log(`Then: lofi-node ticket issue --dir ${args.dir} # connect an app`);177 } else {178 console.log(`\nNext: lofi-node start --dir ${args.dir}`);179 }180}181182async function cmdStart(args: Args) {183 const config = await requireConfig(args.dir);184 const node = await createSyncNode({185 appId: config.appId,186 backendSecret: config.backendSecret,187 adminSecret: config.adminSecret,188 dataDir: args.dir,189 listen: config.listenPort ? { port: config.listenPort } : undefined,190 access: config.access,191 storage: config.storage,192 publicUrl: config.publicUrl,193 upstream: config.upstream,194 relay: config.relay,195 allowLocalFirstAuth: config.allowLocalFirstAuth,196 });197 const status = node.status();198 console.log(`lofi-node up`);199 console.log(` app id: ${node.appId}`);200 console.log(` access: ${status.access}`);201 if (status.access === "ticket") {202 console.log(` gate: ${node.url} (apps connect with an issued ticket URL)`);203 console.log(204 ` tickets: ${205 status.tickets.filter((t) => !t.revoked).length206 } active — issue with: lofi-node ticket issue --dir ${args.dir}`,207 );208 } else {209 console.log(` jazz url: ${node.url} <- point JAZZ_SERVER_URL here`);210 }211 console.log(` storage: ${describeStorage(status.jazz.storage, args.dir)}`);212 console.log(213 ` upstream: ${214 status.upstream === "none"215 ? "none"216 : "url" in status.upstream217 ? status.upstream.url218 : "peer (over iroh)"219 }`,220 );221 if (status.mesh.state === "up") {222 console.log(` mesh: up (node ${status.mesh.nodeId.slice(0, 16)}…)`);223 console.log(` relay: ${describeRelay(config.relay)}`);224 console.log(` ticket: ${status.mesh.ticket}`);225 } else if (status.mesh.state === "unavailable") {226 console.log(` mesh: UNAVAILABLE — ${status.mesh.reason}`);227 console.log(` (LAN-only Jazz server; pairing disabled)`);228 }229 console.log(`\nCtrl-C to stop.`);230 Deno.addSignalListener("SIGINT", async () => {231 console.log("\nstopping…");232 try {233 await node.stop();234 } catch (e) {235 console.error(`shutdown error: ${(e as Error).message}`);236 }237 Deno.exit(0);238 });239 await new Promise(() => {});240}241242async function cmdPair(args: Args) {243 const ticket = args.positional[0];244 if (!ticket) fail("usage: lofi-node pair <node-ticket>");245 if (looksLikeAppTicket(ticket)) {246 fail(247 "that is an app-connect ticket (lofisync1.…); pairing takes a NODE ticket " +248 "(the endpoint… string printed by lofi-node start on the other node)",249 );250 }251 if (!looksLikeTicket(ticket)) fail("that does not look like a node-pairing ticket");252 const config = await requireConfig(args.dir);253 config.upstream = { peer: ticket };254 await saveConfig(args.dir, config);255 console.log("pairing saved. Restart the node to apply: lofi-node start");256}257258async function cmdTicket(args: Args) {259 const sub = args.positional[0];260 const config = await requireConfig(args.dir);261 if (config.access !== "ticket") {262 fail(`node access is "${config.access}" — app tickets require access: "ticket"`);263 }264 const store = await AppTicketStore.load(args.dir);265266 if (sub === "issue") {267 if (!config.listenPort && !args.url && !config.publicUrl) {268 fail(269 "ticket URLs need a stable address: set a fixed port (init --port) plus " +270 "--public-url/--url, or pass --url explicitly",271 );272 }273 let base = args.url ?? config.publicUrl;274 if (!base) {275 const lan = lanAddress();276 base = `http://${lan ?? "127.0.0.1"}:${config.listenPort}`;277 if (lan) {278 console.error(279 `note: auto-detected LAN address ${lan} — pin with --public-url if this is wrong`,280 );281 }282 }283 const scope = args.provision ? "provision" as const : "sync" as const;284 const { record, secret } = await store.issue(args.label, scope);285 const ticket = encodeAppTicket({286 v: 1,287 appId: config.appId,288 url: `${base.replace(/\/+$/, "")}/t/${secret}`,289 scope: scope === "provision" ? "provision" : undefined,290 label: args.label,291 });292 console.log(293 `issued ${scope} ticket ${record.id}${args.label ? ` (${args.label})` : ""}`,294 );295 console.log(`\n${ticket}\n`);296 console.log("Paste this into the lofi app. The secret is NOT stored and cannot");297 console.log("be shown again; a running node accepts it immediately.");298 if (scope === "provision") {299 console.log("PROVISION scope: this ticket also unlocks store administration");300 console.log("(schema deploys) through the gate. Issue per provisioning context.");301 }302 return;303 }304305 if (sub === "list") {306 const tickets = await store.list();307 if (tickets.length === 0) {308 console.log("no tickets issued");309 return;310 }311 // Last-seen comes from the daemon-owned sidecar; absent (never seen, or312 // the node has not flushed yet) prints as "-".313 const activity = args.dir ? await readTicketActivity(args.dir) : new Map<string, string>();314 for (const t of tickets) {315 // Lineage-aware: a derived ticket shows REVOKED once its parent is.316 const dead = isRevokedByLineage(t, tickets);317 const lastSeen = activity.get(t.id) ?? "-";318 console.log(319 `${t.id} ${dead ? "REVOKED" : "active "} ${320 (t.scope ?? "sync").padEnd(9)321 } ${t.createdAt} seen ${lastSeen} ${t.label ?? ""}${322 t.parentId ? ` [from ${t.parentId}]` : ""323 }`,324 );325 }326 return;327 }328329 if (sub === "revoke") {330 const id = args.positional[1];331 if (!id) fail("usage: lofi-node ticket revoke <id>");332 const record = await store.revoke(id);333 if (!record) fail(`no ticket with id ${id} (see: lofi-node ticket list)`);334 console.log(`revoked ${id} — takes effect immediately if the node is running`);335 return;336 }337338 fail("usage: lofi-node ticket <issue|list|revoke>");339}340341async function cmdStatus(args: Args) {342 const config = await requireConfig(args.dir);343 const store = await AppTicketStore.load(args.dir);344 const tickets = await store.list();345 console.log(` app id: ${config.appId}`);346 console.log(` access: ${config.access}`);347 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);348 console.log(` relay: ${describeRelay(config.relay)}`);349 console.log(` listen: ${config.listenPort ?? "(auto)"}`);350 console.log(351 ` tickets: ${tickets.filter((t) => !t.revokedAt).length} active / ${tickets.length} issued`,352 );353 console.log(354 ` upstream: ${355 config.upstream === "none"356 ? "none"357 : "url" in config.upstream358 ? config.upstream.url359 : "peer (over iroh)"360 }`,361 );362 if (config.listenPort) {363 try {364 const res = await fetch(`http://127.0.0.1:${config.listenPort}/health`, {365 signal: AbortSignal.timeout(1500),366 });367 console.log(` daemon: ${res.status === 200 ? "running" : `unhealthy (${res.status})`}`);368 } catch {369 console.log(` daemon: not running`);370 }371 }372}373374const args = parseArgs(Deno.args);375switch (args.command) {376 case "init":377 await cmdInit(args);378 break;379 case "start":380 await cmdStart(args);381 break;382 case "pair":383 await cmdPair(args);384 break;385 case "ticket":386 await cmdTicket(args);387 break;388 case "status":389 await cmdStatus(args);390 break;391 default:392 console.log(USAGE);393 Deno.exit(args.command ? 1 : 0);394}