// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0044). Only // this module's tools import it; nodered has nothing to poll, so there is no events entrypoint. // // Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow // configuration, GET /nodes for installed node modules. A default install has no auth; when // adminAuth is on, a bearer token (minted at /auth/token) is required. import { readFileSync } from "node:fs"; export interface NodeRedFlow { /** The tab (flow) node id. */ id: string; label: string; disabled: boolean; } export interface NodeRedNodeModule { name: string; version: string; types: string[]; } /** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } export class NodeRedClient { readonly baseUrl: string; constructor( url: string, private readonly token?: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** * Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the * "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing * rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on; * a default install needs none. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient { const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE); const url = cfg.url ?? env.MESH_NODERED_URL; if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL"); return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN); } private headers(extra: Record = {}): Record { return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra }; } private async req(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${this.baseUrl}${path}`, init); if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`); return res.json(); } /** The full flow configuration — the flat array of every node across every tab. */ async getConfig(): Promise { const body = await this.req("/flows", { headers: this.headers() }); // /flows answers a bare array by default, or { rev, flows } to a v2-aware client. return Array.isArray(body) ? body : (body.flows ?? []); } /** The tabs (flows), each a node of type "tab" in the configuration. */ async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> { const config = await this.getConfig(); const flows = config .filter((n) => n.type === "tab") .map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled })); return { flows, nodeCount: config.length }; } async listNodes(): Promise { const modules = (await this.req("/nodes", { headers: this.headers() })) as any[]; return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] })); } /** * Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to * Node-RED's deployment types — "full" (default), "nodes", or "flows". */ async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> { const body = await this.req("/flows", { method: "POST", headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }), body: JSON.stringify(config), }); return { rev: body?.rev, nodeCount: config.length }; } }