clone

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

feat/pop-tickets
lofi-node / src / config.ts
config.ts 8.5K
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/** Tighten a path's mode. POSIX only — Windows has no chmod; there the data110 * dir inherits the profile directory's ACLs instead. */111async function restrictMode(path: string, mode: number): Promise<void> {112 if (Deno.build.os === "windows") return;113 await Deno.chmod(path, mode);114}115116/** config.json and iroh.key hold the node's identity and Jazz credentials —117 * owner-only on disk. Installs created before modes were set are healed on118 * every load. */119async function restrictSecretModes(dataDir: string): Promise<void> {120 await restrictMode(dataDir, 0o700);121 await restrictMode(configPath(dataDir), 0o600);122}123124/** Read config.json (null when absent); v1 files are mapped to v2 in125 * memory — persisted as v2 on the next explicit {@link saveConfig}. */126export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {127 try {128 const raw = await Deno.readTextFile(configPath(dataDir));129 await restrictSecretModes(dataDir);130 const parsed = JSON.parse(raw) as { v: number } & Partial<Omit<NodeConfig, "v">>;131 if (parsed.v === 1) {132 // Lazy v1→v2 migration: existing dirs keep exact current behavior.133 // Persisted as v2 on the next explicit saveConfig.134 return {135 ...(parsed as unknown as NodeConfig),136 v: 2,137 access: parsed.access ?? "open",138 storage: parsed.storage ?? { type: "sqlite" },139 };140 }141 if (parsed.v !== 2) {142 throw new Error(143 `unsupported config version ${parsed.v} — this lofi-node reads v1 and v2 config.json`,144 );145 }146 const config = parsed as NodeConfig;147 validateStorage(config.storage);148 if (config.relay !== undefined) validateRelay(config.relay);149 return config;150 } catch (e) {151 if (e instanceof Deno.errors.NotFound) return null;152 throw e;153 }154}155156/** Write config.json, owner-only (creates the data directory when needed). */157export async function saveConfig(dataDir: string, config: NodeConfig): Promise<void> {158 await Deno.mkdir(dataDir, { recursive: true, mode: 0o700 });159 await Deno.writeTextFile(configPath(dataDir), JSON.stringify(config, null, 2) + "\n", {160 mode: 0o600,161 });162 // mkdir/write modes apply only on creation (and are umask-masked) — chmod163 // pins them for pre-existing paths too.164 await restrictSecretModes(dataDir);165}166167/** Create (or return the existing) daemon config; new inits default to168 * ticket-gated access and SQLite storage, with generated secrets. */169export async function initConfig(170 dataDir: string,171 overrides: Partial<172 Pick<173 NodeConfig,174 "appId" | "listenPort" | "upstream" | "access" | "storage" | "publicUrl" | "relay"175 >176 > = {},177): Promise<NodeConfig> {178 const existing = await loadConfig(dataDir);179 if (existing) return existing;180 const config: NodeConfig = {181 v: 2,182 appId: overrides.appId ?? crypto.randomUUID(),183 backendSecret: randomToken("lofi_backend"),184 adminSecret: randomToken("lofi_admin"),185 listenPort: overrides.listenPort,186 // Secure by default for real installs; the library default stays "open".187 access: overrides.access ?? "ticket",188 storage: overrides.storage ?? { type: "sqlite" },189 publicUrl: overrides.publicUrl,190 upstream: overrides.upstream ?? "none",191 relay: overrides.relay,192 allowLocalFirstAuth: true,193 };194 validateStorage(config.storage);195 if (config.relay !== undefined) validateRelay(config.relay);196 await saveConfig(dataDir, config);197 return config;198}199200/** Load-or-create the node's 32-byte iroh secret key. */201export async function loadOrCreateIrohKey(dataDir: string): Promise<Uint8Array> {202 const path = `${dataDir}/iroh.key`;203 try {204 const hex = (await Deno.readTextFile(path)).trim();205 if (hex.length !== 64) throw new Error(`corrupt iroh.key at ${path}`);206 await restrictMode(path, 0o600);207 return fromHex(hex);208 } catch (e) {209 if (!(e instanceof Deno.errors.NotFound)) throw e;210 }211 const key = crypto.getRandomValues(new Uint8Array(32));212 await Deno.mkdir(dataDir, { recursive: true, mode: 0o700 });213 await Deno.writeTextFile(path, toHex(key) + "\n", { mode: 0o600 });214 await restrictMode(path, 0o600);215 return key;216}