1// dataDir layout: config.json (app id, secrets, upstream election) and2// iroh.key (32-byte node secret, hex, 0600). Secrets are generated on init3// and persisted — the node's identity and its Jazz credentials both survive4// restarts, so tickets and client sessions stay stable.56/** Where this node replicates to: nowhere, a direct Jazz server URL (e.g.7 * Jazz Cloud), or a peer lofi-node named by its iroh pairing ticket. */8export type UpstreamConfig = "none" | { url: string } | { peer: string };910/** Where node data lives. jazz-napi supports exactly these two today; the11 * `type` discriminator is the seam future providers slot into. `path` may be12 * any mounted location (NAS, synced volume) — validated writable at boot. */13export type StorageConfig = { type: "sqlite"; path?: string } | { type: "memory" };1415/** Which relay infrastructure the node's iroh endpoint uses. Relays assist16 * holepunching (address discovery) and carry traffic only when a direct17 * connection cannot be established. "n0": the public n0-computer relays —18 * rate-limited, no SLA, blessed for development and testing. { urls }:19 * operator-run or dedicated relay servers (iroh-relay); the chosen relay20 * travels inside this node's pairing tickets, so peers need no matching21 * config. "disabled": no relays at all — direct connections only, which22 * also forgoes relay-assisted address discovery. */23export type RelayConfig = "n0" | "disabled" | { urls: string[] };2425/** Persisted daemon configuration (`<dataDir>/config.json`, version 2 —26 * v1 files migrate lazily on load). */27export interface NodeConfig {28 /** Config schema version. */29 v: 2;30 /** Jazz app id (a UUID — Jazz's catalogue requires UUID ids). */31 appId: string;32 /** Jazz backend secret (server-to-server calls). */33 backendSecret: string;34 /** Jazz admin secret; in ticket mode it never leaves the node. */35 adminSecret: string;36 /** Fixed public port; auto-allocated per start when omitted. */37 listenPort?: number;38 /** "ticket": only issued app tickets reach Jazz (CLI init default).39 * "open": today's behavior (library/test default). */40 access: "open" | "ticket";41 storage: StorageConfig;42 /** Base URL embedded into issued tickets, e.g. "http://192.168.1.10:4802". */43 publicUrl?: string;44 upstream: UpstreamConfig;45 /** Relay election; absent means "n0" (the public relays). */46 relay?: RelayConfig;47 allowLocalFirstAuth: boolean;48}4950const SUPPORTED_STORAGE = ["sqlite", "memory"];5152/** Throws with an actionable message for storage types jazz-napi cannot53 * back today (`sqlite` and `memory` are the supported set). */54export function validateStorage(storage: StorageConfig): void {55 if (!SUPPORTED_STORAGE.includes(storage.type)) {56 throw new Error(57 `unsupported storage type "${(storage as { type: string }).type}" — jazz-napi supports ` +58 `${59 SUPPORTED_STORAGE.join(" | ")60 } today; see docs/hosting-lofi-apps.md for provider recipes`,61 );62 }63}6465/** Throws with the offending value when a relay election is malformed:66 * custom mode needs at least one parseable http(s) relay URL. */67export function validateRelay(relay: RelayConfig): void {68 if (relay === "n0" || relay === "disabled") return;69 if (!Array.isArray(relay.urls) || relay.urls.length === 0) {70 throw new Error('relay { urls } needs at least one relay URL (or use "n0" | "disabled")');71 }72 for (const url of relay.urls) {73 let parsed: URL;74 try {75 parsed = new URL(url);76 } catch {77 throw new Error(78 `relay URL "${url}" does not parse — expected e.g. https://relay.example.com`,79 );80 }81 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {82 throw new Error(`relay URL "${url}" must be http(s), got ${parsed.protocol}`);83 }84 }85}8687function randomToken(prefix: string): string {88 const bytes = crypto.getRandomValues(new Uint8Array(24));89 const b64 = btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_")90 .replaceAll("=", "");91 return `${prefix}_${b64}`;92}9394function toHex(bytes: Uint8Array): string {95 return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");96}9798function fromHex(text: string): Uint8Array {99 const out = new Uint8Array(text.length / 2);100 for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);101 return out;102}103104/** Path of the daemon config file inside a data directory. */105export function configPath(dataDir: string): string {106 return `${dataDir}/config.json`;107}108109/** Read config.json (null when absent); v1 files are mapped to v2 in110 * memory — persisted as v2 on the next explicit {@link saveConfig}. */111export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {112 try {113 const raw = await Deno.readTextFile(configPath(dataDir));114 const parsed = JSON.parse(raw) as { v: number } & Partial<Omit<NodeConfig, "v">>;115 if (parsed.v === 1) {116 // Lazy v1→v2 migration: existing dirs keep exact current behavior.117 // Persisted as v2 on the next explicit saveConfig.118 return {119 ...(parsed as unknown as NodeConfig),120 v: 2,121 access: parsed.access ?? "open",122 storage: parsed.storage ?? { type: "sqlite" },123 };124 }125 if (parsed.v !== 2) {126 throw new Error(127 `unsupported config version ${parsed.v} — this lofi-node reads v1 and v2 config.json`,128 );129 }130 const config = parsed as NodeConfig;131 validateStorage(config.storage);132 if (config.relay !== undefined) validateRelay(config.relay);133 return config;134 } catch (e) {135 if (e instanceof Deno.errors.NotFound) return null;136 throw e;137 }138}139140/** Write config.json (creates the data directory when needed). */141export async function saveConfig(dataDir: string, config: NodeConfig): Promise<void> {142 await Deno.mkdir(dataDir, { recursive: true });143 await Deno.writeTextFile(configPath(dataDir), JSON.stringify(config, null, 2) + "\n");144}145146/** Create (or return the existing) daemon config; new inits default to147 * ticket-gated access and SQLite storage, with generated secrets. */148export async function initConfig(149 dataDir: string,150 overrides: Partial<151 Pick<152 NodeConfig,153 "appId" | "listenPort" | "upstream" | "access" | "storage" | "publicUrl" | "relay"154 >155 > = {},156): Promise<NodeConfig> {157 const existing = await loadConfig(dataDir);158 if (existing) return existing;159 const config: NodeConfig = {160 v: 2,161 appId: overrides.appId ?? crypto.randomUUID(),162 backendSecret: randomToken("lofi_backend"),163 adminSecret: randomToken("lofi_admin"),164 listenPort: overrides.listenPort,165 // Secure by default for real installs; the library default stays "open".166 access: overrides.access ?? "ticket",167 storage: overrides.storage ?? { type: "sqlite" },168 publicUrl: overrides.publicUrl,169 upstream: overrides.upstream ?? "none",170 relay: overrides.relay,171 allowLocalFirstAuth: true,172 };173 validateStorage(config.storage);174 if (config.relay !== undefined) validateRelay(config.relay);175 await saveConfig(dataDir, config);176 return config;177}178179/** Load-or-create the node's 32-byte iroh secret key. */180export async function loadOrCreateIrohKey(dataDir: string): Promise<Uint8Array> {181 const path = `${dataDir}/iroh.key`;182 try {183 const hex = (await Deno.readTextFile(path)).trim();184 if (hex.length === 64) return fromHex(hex);185 throw new Error(`corrupt iroh.key at ${path}`);186 } catch (e) {187 if (!(e instanceof Deno.errors.NotFound)) throw e;188 }189 const key = crypto.getRandomValues(new Uint8Array(32));190 await Deno.mkdir(dataDir, { recursive: true });191 await Deno.writeTextFile(path, toHex(key) + "\n", { mode: 0o600 });192 return key;193}