Files
mesh-catalog/modules/nodered/client.ts
T
jschoubben 0c91e08bad nodered: its settings are files the mesh writes, and its editor is locked
The catalogue ran the image's defaults: no adminAuth, so a routed Node-RED
editor (which runs arbitrary code) was open to anyone who reached it, and
the module's own tools had no token to present to an install that was locked.

- settings.js (fixed, 0600, uid 1000) carries adminAuth: user admin checked
  against the admin secret -- a minted password, or the bcrypt hash an
  existing install held (accepted), so current logins keep working -- and a
  static bearer token (api-token) the sidecar presents. It loads settings.json
  beside it, the one mergeable file; endpoints is dropped there, and an
  optional timeZone sets process.env.TZ (assignments cannot set env).
- The sidecar's runtime config is no longer merged; it carries the token.
- Directories are placed (state, data), the route binds into state.
- Image pinned to 5.0.7 (a649dd71), what ace runs; the old pin was 5.0.6.
- deployFlows asks for API v2: v1 answers 204 with no body, which the client
  tried to parse as JSON.

Verified: catalogue tests pass against this tree. A throwaway 5.0.7 container
started with the generated files: anonymous /flows 401, bearer api-token 200,
bad token 401, password grant 200/403 with a minted password and with a
bcrypt-hash-accepted one; endpoints and timeZone do not reach /settings;
timeZone Europe/Brussels overrides TZ=Etc/UTC; v1 deploy 204, v2 deploy
answers {rev}.
2026-09-29 23:41:18 +02:00

103 lines
4.0 KiB
TypeScript

// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0039). 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 is required — the module's settings accept the mesh-minted
// api-token, which the runtime config file carries as `token`.
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 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<string, string> = {}): Record<string, string> {
return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };
}
private async req(path: string, init: RequestInit = {}): Promise<any> {
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<any[]> {
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<NodeRedNodeModule[]> {
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",
// v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse.
headers: this.headers({
"Content-Type": "application/json",
"Node-RED-API-Version": "v2",
"Node-RED-Deployment-Type": type,
}),
body: JSON.stringify({ flows: config }),
});
return { rev: body?.rev, nodeCount: config.length };
}
}