From 43625ec03fc13411cbdb93e6e2f787e8cf574cc2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 00:26:10 +0200 Subject: [PATCH 01/28] audit-logger: record the event's own x-event-id (ADR 0047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trail's id is now the event's x-event-id — the handle a reader dedups the at-least-once stream on — not the type@time placeholder the first cut used. Carries causation/schema through when present. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/audit-logger/audit.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/audit-logger/audit.ts b/modules/audit-logger/audit.ts index 9ed2f6d..aa7ee46 100644 --- a/modules/audit-logger/audit.ts +++ b/modules/audit-logger/audit.ts @@ -11,15 +11,18 @@ export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string { return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log"; } -/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. */ +/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. The id + * is the event's own x-event-id (ADR 0047) — the handle a reader dedups the at-least-once trail on. */ export async function record(event: Event, path: string): Promise { const line = JSON.stringify({ - id: event.type + "@" + event.at, // a stable-ish key until x-event-id headers land (ADR 0047) + id: event.id, type: event.type, source: event.source, node: event.node, at: event.at, + ...(event.causationId ? { causationId: event.causationId } : {}), + ...(event.schema ? { schema: event.schema } : {}), body: event.body, }) + "\n"; await mkdir(dirname(path), { recursive: true }).catch(() => {}); From 8f0994fcdcc0994ae5aa70656f1a3f6419fffad2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:56:36 +0200 Subject: [PATCH 02/28] audit-logger: the assigned-module manifest (ADR 0048) Now a real assigned module, not just a handler: consumes '#', declares its broker own-secret, and runs the runtime image as a container that mounts the sealed credential and its trail. own-secrets:{broker} is the file the mesh seals it (module issue); the container reads MESH_BROKER_FILE from the mount and takes its node/module identity from the credential. Parses against the catalogue schema. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/audit-logger/module.json | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 72bf5b9..4528f02 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -1,15 +1,46 @@ { "module": "audit-logger", "version": "1", - "consumes": [ - "#" - ], + "consumes": ["#"], + "own-secrets": { + "broker": "/var/lib/audit-logger/broker" + }, + "build": { + "artifacts": [ + { + "name": "runtime", + "kind": "upstream", + "from": "registry.invalid/mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ] + }, "resources": [ { - "id": "log", + "id": "state", "type": "directory", "path": "/var/lib/audit-logger", "mode": "0700" + }, + { + "id": "trail", + "type": "directory", + "path": "/var/lib/audit-logger/trail", + "mode": "0700" + }, + { + "id": "run", + "type": "container", + "name": "mesh-audit-logger", + "artifact": "runtime", + "network": "host", + "volumes": [ + "/var/lib/audit-logger/broker:/run/secrets/broker:ro", + "/var/lib/audit-logger/trail:/trail" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "AUDIT_LOG": "/trail/audit.log" + } } ] } From c2c26a29a9067591756c5cd1548538e45c426f0d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:13:01 +0200 Subject: [PATCH 03/28] audit-logger: the container names its image directly (a container has no artifact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container resource takes a digest-pinned image, not a build artifact — the host refuses 'artifact' on a container. Verified: the mesh assigns it and the host runs it in the lab. --- modules/audit-logger/module.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 4528f02..d46a715 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -5,15 +5,6 @@ "own-secrets": { "broker": "/var/lib/audit-logger/broker" }, - "build": { - "artifacts": [ - { - "name": "runtime", - "kind": "upstream", - "from": "registry.invalid/mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000" - } - ] - }, "resources": [ { "id": "state", @@ -31,7 +22,7 @@ "id": "run", "type": "container", "name": "mesh-audit-logger", - "artifact": "runtime", + "image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "host", "volumes": [ "/var/lib/audit-logger/broker:/run/secrets/broker:ro", From 252695571298792a26be6afe600f3601d7e3b53f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:23:23 +0200 Subject: [PATCH 04/28] =?UTF-8?q?plex:=20full=20nox=20module=20=E2=80=94?= =?UTF-8?q?=20client,=20tools=20and=20events=20(ADR=200044/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves plex's API client and tools out of the shared hal sdk into the module, so a Plex API change rebuilds only plex. Tools: status, search, sessions, recently-added, refresh. And a real event design: it emits playback started/stopped and item.added by watching the server, and consumes module.*.download.completed to rescan so a downloader's fetch becomes a visible item. Typechecks against the sdk; manifest parses with its emits/ consumes and broker own-secret. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/plex/client.ts | 131 ++++++++++++++++++++++++++++++++++++ modules/plex/index.ts | 68 +++++++++++++++++++ modules/plex/module.json | 11 +++ modules/plex/package.json | 14 ++++ modules/plex/tools/index.ts | 68 +++++++++++++++++++ modules/plex/tsconfig.json | 12 ++++ 6 files changed, 304 insertions(+) create mode 100644 modules/plex/client.ts create mode 100644 modules/plex/index.ts create mode 100644 modules/plex/package.json create mode 100644 modules/plex/tools/index.ts create mode 100644 modules/plex/tsconfig.json diff --git a/modules/plex/client.ts b/modules/plex/client.ts new file mode 100644 index 0000000..a987767 --- /dev/null +++ b/modules/plex/client.ts @@ -0,0 +1,131 @@ +// The Plex API client — plex's own code, living in the module (novox/hq ADR 0044). Moved out of the +// shared hal sdk, where a change to Plex's API rebuilt everything; here it rebuilds only plex. Both +// this module's tools and its events entrypoint import it, and nothing outside plex does. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface PlexLibrary { + key: string; + title: string; + type: string; + count?: number; +} + +export interface PlexSession { + key: string; + title: string; + user: string; + player: string; + state: string; + type: string; +} + +export interface PlexItem { + title: string; + type: string; + year?: number; + summary?: string; + addedAt?: string; +} + +export class PlexClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The token is read from MESH_PLEX_TOKEN, or + * discovered from the server's own Preferences.xml under the data directory — the same file Plex + * writes it to, so a running server needs nothing configured by hand. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexClient { + const url = env.MESH_PLEX_URL ?? `http://127.0.0.1:${env.PLEX_PORT ?? "32400"}`; + const dataDir = env.MESH_PLEX_DATA_DIR ?? "/var/lib/plex"; + const token = env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir); + if (!token) throw new Error("no Plex token — set MESH_PLEX_TOKEN or make the data dir readable"); + return new PlexClient(url, token); + } + + /** Discover the token from the server's Preferences.xml, falling back to null. */ + static detectToken(dataDir: string): string | null { + const prefs = join(dataDir, "config", "Library", "Application Support", "Plex Media Server", "Preferences.xml"); + if (existsSync(prefs)) { + const match = readFileSync(prefs, "utf8").match(/PlexOnlineToken="([^"]+)"/); + if (match) return match[1]; + } + return null; + } + + private async get(path: string): Promise { + const url = `${this.baseUrl}${path}`; + const sep = url.includes("?") ? "&" : "?"; + const res = await fetch(`${url}${sep}X-Plex-Token=${this.token}`, { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Plex API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getServerInfo(): Promise<{ name: string; version: string; platform: string }> { + const mc = (await this.get("/")).MediaContainer; + return { name: mc.friendlyName || mc.machineIdentifier, version: mc.version, platform: mc.platform }; + } + + async getLibraries(): Promise { + const dirs = (await this.get("/library/sections")).MediaContainer?.Directory ?? []; + return dirs.map((d: any) => ({ key: d.key, title: d.title, type: d.type, count: d.count })); + } + + async getSessions(): Promise { + const sessions = (await this.get("/status/sessions")).MediaContainer?.Metadata ?? []; + return sessions.map((s: any) => ({ + key: s.sessionKey ?? s.ratingKey, + title: s.title + (s.grandparentTitle ? ` (${s.grandparentTitle})` : ""), + user: s.User?.title ?? "unknown", + player: s.Player?.title ?? s.Player?.product ?? "unknown", + state: s.Player?.state ?? "unknown", + type: s.type, + })); + } + + async search(query: string): Promise { + const hubs = (await this.get(`/hubs/search?query=${encodeURIComponent(query)}&limit=20`)).MediaContainer?.Hub ?? []; + const results: PlexItem[] = []; + for (const hub of hubs) { + for (const m of hub.Metadata ?? []) { + results.push({ + title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), + type: m.type, + year: m.year, + summary: m.summary?.slice(0, 200), + }); + } + } + return results; + } + + async getRecentlyAdded(limit = 20): Promise { + const items = (await this.get(`/library/recentlyAdded?X-Plex-Container-Size=${limit}`)).MediaContainer?.Metadata ?? []; + return items.map((m: any) => ({ + title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), + type: m.type, + year: m.year, + summary: m.summary?.slice(0, 200), + addedAt: m.addedAt ? new Date(m.addedAt * 1000).toISOString() : undefined, + })); + } + + /** Ask Plex to rescan a library section — how a "new media arrived" event becomes a visible item. */ + async refreshLibrary(key: string): Promise { + await this.get(`/library/sections/${key}/refresh`); + } + + /** Rescan every library, for when what arrived is not known to belong to one. */ + async refreshAll(): Promise { + for (const library of await this.getLibraries()) await this.refreshLibrary(library.key); + } +} diff --git a/modules/plex/index.ts b/modules/plex/index.ts new file mode 100644 index 0000000..74830e2 --- /dev/null +++ b/modules/plex/index.ts @@ -0,0 +1,68 @@ +// plex's events. The tool runtime imports this once the broker is bound, and it does two things: +// it watches the server and emits what happened, and it reacts to the mesh's media events. +// +// Emits (novox/hq ADR 0046/0047): +// module.plex.playback.started / .stopped — someone began or ended watching +// module.plex.item.added — a new item appeared in a library +// Consumes: +// module.*.download.completed — a downloader finished; rescan so the file shows up +// +// The polling is deliberately unhurried: Plex is a neighbour on the same node, and an event a few +// seconds late is an event, whereas hammering the server for immediacy nobody asked for is not. + +import { emit, on } from "@novox/mesh-sdk/events"; +import { PlexClient, type PlexSession } from "./client.js"; + +const plex = PlexClient.fromEnv(); + +// Playback, by diffing the set of active sessions. Primed silently on the first look so a server +// that was already streaming when this started does not announce it as freshly begun. +const active = new Map(); +let playbackPrimed = false; +async function pollSessions(): Promise { + const sessions = await plex.getSessions(); + const now = new Map(sessions.map((s) => [s.key, s])); + if (playbackPrimed) { + for (const [key, s] of now) { + if (!active.has(key)) await emit("module.plex.playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type }); + } + for (const [key, s] of active) { + if (!now.has(key)) await emit("module.plex.playback.stopped", { title: s.title, user: s.user, player: s.player }); + } + } + active.clear(); + for (const [key, s] of now) active.set(key, s); + playbackPrimed = true; +} + +// New items, by diffing recently-added. Primed silently too, or a restart would re-announce the +// whole recent list as new. +const seen = new Set(); +let itemsPrimed = false; +async function pollRecent(): Promise { + const items = await plex.getRecentlyAdded(20); + for (const item of items) { + const id = `${item.title}@${item.addedAt ?? ""}`; + if (!seen.has(id)) { + if (itemsPrimed) await emit("module.plex.item.added", item); + seen.add(id); + } + } + itemsPrimed = true; +} + +// A downloader finished somewhere on the mesh: rescan, so what it fetched becomes a visible item +// rather than a file Plex has not noticed. Idempotent — a rescan too many costs a little disk I/O. +await on("module.*.download.completed", async () => { + await plex.refreshAll(); +}); + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[plex] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollSessions, 15_000); +tick(pollRecent, 60_000); + +console.log("[plex] watching sessions and recently-added, reacting to downloads"); diff --git a/modules/plex/module.json b/modules/plex/module.json index d1287f3..91445c0 100644 --- a/modules/plex/module.json +++ b/modules/plex/module.json @@ -4,6 +4,17 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.plex.playback.started", + "module.plex.playback.stopped", + "module.plex.item.added" + ], + "consumes": [ + "module.*.download.completed" + ], + "own-secrets": { + "broker": "/var/lib/plex/broker" + }, "listens": [ { "port": 32400, diff --git a/modules/plex/package.json b/modules/plex/package.json new file mode 100644 index 0000000..65393e4 --- /dev/null +++ b/modules/plex/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-plex", + "version": "0.1.0", + "description": "plex — media server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/plex/tools/index.ts b/modules/plex/tools/index.ts new file mode 100644 index 0000000..f175a7d --- /dev/null +++ b/modules/plex/tools/index.ts @@ -0,0 +1,68 @@ +// plex's tools — moved here from the shared sdk (novox/hq ADR 0044), importing plex's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PlexClient } from "../client.js"; + +export function getPlexTools(plex: PlexClient): ToolDefinition[] { + return [ + { + name: "plex_status", + description: "Plex server status: server info, libraries, active sessions, recently added.", + input: {}, + run: async () => { + const [server, libraries, sessions, recent] = await Promise.all([ + plex.getServerInfo(), + plex.getLibraries(), + plex.getSessions(), + plex.getRecentlyAdded(10), + ]); + return { server, libraries, sessions, recentlyAdded: recent }; + }, + }, + { + name: "plex_search", + description: "Search across all Plex libraries — movies, shows, episodes, music.", + input: { query: { type: "string", description: "the search query" } }, + run: async (args) => ({ query: String(args.query), results: await plex.search(String(args.query)) }), + }, + { + name: "plex_sessions", + description: "Active Plex playback sessions — who is watching what, and where.", + input: {}, + run: async () => { + const sessions = await plex.getSessions(); + return { count: sessions.length, sessions }; + }, + }, + { + name: "plex_recently_added", + description: "Recently added media in Plex.", + input: { limit: { type: "number", description: "how many items (default 20)" } }, + run: async (args) => ({ items: await plex.getRecentlyAdded(args.limit ? Number(args.limit) : 20) }), + }, + { + name: "plex_refresh", + description: "Ask Plex to rescan its libraries so new files on disk become visible items.", + input: { library: { type: "string", description: "a library section key; omitted rescans all" } }, + run: async (args) => { + if (args.library) { + await plex.refreshLibrary(String(args.library)); + return { refreshed: String(args.library) }; + } + await plex.refreshAll(); + return { refreshed: "all" }; + }, + }, + ]; +} + +// The tools exist only when a token can be found; without one, plex contributes none rather than +// failing the whole runtime. +registerModuleTools("plex", (env) => { + try { + return getPlexTools(PlexClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/plex/tsconfig.json b/modules/plex/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/plex/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From 1498a22c94086ed8e8c2a582ed5bf64356a98e02 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:29:38 +0200 Subject: [PATCH 05/28] =?UTF-8?q?mailu:=20full=20nox=20module=20=E2=80=94?= =?UTF-8?q?=20client,=20tools=20and=20events=20(ADR=200044/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email server. Users/aliases/domains over the admin REST API, mail read via doveadm in the imap container; ten tools. Emits user/alias created/deleted by polling and diffing the admin API, so a change in the web admin is announced as readily as one via a tool. Consumes nothing — deliberately, since mutating mail accounts off another module's event could silently lose mail. Typechecks against the sdk; manifest parses. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/mailu/client.ts | 220 +++++++++++++++++++++++++++++++++++ modules/mailu/index.ts | 61 ++++++++++ modules/mailu/module.json | 9 +- modules/mailu/package.json | 14 +++ modules/mailu/tools/index.ts | 154 ++++++++++++++++++++++++ modules/mailu/tsconfig.json | 12 ++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 modules/mailu/client.ts create mode 100644 modules/mailu/index.ts create mode 100644 modules/mailu/package.json create mode 100644 modules/mailu/tools/index.ts create mode 100644 modules/mailu/tsconfig.json diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts new file mode 100644 index 0000000..fef227f --- /dev/null +++ b/modules/mailu/client.ts @@ -0,0 +1,220 @@ +// The Mailu API client — mailu's own code, living in the module (novox/hq ADR 0044). Moved out of +// the shared hal sdk, where a change to Mailu's surface rebuilt everything; here it rebuilds only +// mailu. Both this module's tools and its events entrypoint import it, and nothing outside mailu does. +// +// hal drove Mailu through its flask CLI over `docker compose exec` into the admin container. That +// coupling was to the container, not to Mailu: it needed a shell on the box. The module's real +// coupling is the admin REST API, so that is what this client speaks — a token and a URL, no shell. +// The one exception is reading a mailbox: the admin API exposes no message reads, so that alone +// falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export interface MailuUser { + email: string; + displayed_name?: string; + global_admin?: boolean; + enabled?: boolean; + forward_enabled?: boolean; + forward_destination?: string[]; + quota_bytes?: number; +} + +export interface MailuAlias { + email: string; + destination: string[]; + wildcard?: boolean; +} + +export interface MailuDomain { + name: string; +} + +/** One parsed message from a doveadm fetch — the subset the read/search tools surface. */ +export interface MailMessage { + date?: string; + from?: string; + subject?: string; + preview?: string; +} + +// The fields we ask doveadm for, once — kept together so read and search stay identical in shape. +const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet"; + +export class MailuClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + /** The container name doveadm runs in — reads bypass the API, so they need the box, not a token. */ + private readonly imapContainer: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. MESH_MAILU_URL points at the admin API (e.g. the + * admin container's /api/v1), MESH_MAILU_API_KEY authenticates against it. Both are required — a + * client with neither would only fail later, one call at a time, so it fails here instead. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient { + const url = env.MESH_MAILU_URL; + const apiKey = env.MESH_MAILU_API_KEY; + if (!url || !apiKey) { + throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY"); + } + const imapContainer = env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; + return new MailuClient(url, apiKey, imapContainer); + } + + // --- The admin REST API: users, aliases, domains. --------------------------------------------- + + private async api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + // Mailu's admin API takes the token directly in Authorization, no scheme prefix. + Authorization: this.apiKey, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + if (!res.ok) throw new Error(`Mailu API ${method} ${path}: ${res.status} ${await res.text()}`); + // DELETE and some writes answer with an empty body or a bare string; guard the JSON parse. + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; + } + + async listUsers(): Promise { + const users = await this.api("GET", "/user"); + return (users ?? []).map((u) => ({ + email: u.email, + displayed_name: u.displayed_name, + global_admin: u.global_admin, + enabled: u.enabled, + forward_enabled: u.forward_enabled, + forward_destination: u.forward_destination, + quota_bytes: u.quota_bytes, + })); + } + + /** Create a mailbox. Mailu wants the full address and the plaintext password it will hash. */ + async createUser(email: string, password: string): Promise { + await this.api("POST", "/user", { email, raw_password: password }); + } + + async changePassword(email: string, password: string): Promise { + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); + } + + async deleteUser(email: string): Promise { + await this.api("DELETE", `/user/${encodeURIComponent(email)}`); + } + + async listAliases(): Promise { + const aliases = await this.api("GET", "/alias"); + return (aliases ?? []).map((a) => ({ + email: a.email, + // The API returns destination as a comma-joined string on some versions, a list on others. + destination: Array.isArray(a.destination) + ? a.destination + : String(a.destination ?? "").split(",").map((d: string) => d.trim()).filter(Boolean), + wildcard: a.wildcard, + })); + } + + async createAlias(email: string, destination: string[], wildcard = false): Promise { + await this.api("POST", "/alias", { email, destination, wildcard }); + } + + async deleteAlias(email: string): Promise { + await this.api("DELETE", `/alias/${encodeURIComponent(email)}`); + } + + async listDomains(): Promise { + const domains = await this.api("GET", "/domain"); + return (domains ?? []).map((d) => ({ name: d.name })); + } + + // --- Reading mail: doveadm, because the admin API has no message reads. ----------------------- + + /** Recent messages in a mailbox, newest last, capped to `limit`. */ + async readMail(user: string, mailbox = "INBOX", limit = 10): Promise { + const messages = await this.doveadmFetch(user, ["mailbox", mailbox]); + return messages.slice(-limit); + } + + /** + * Search a mailbox by subject, sender and/or date. doveadm fetch takes a search query directly, + * so we build one from whichever criteria were given — `all` when none were, to avoid an empty + * query that would match nothing. + */ + async searchMail( + user: string, + criteria: { subject?: string; from?: string; since?: string }, + limit = 10, + ): Promise { + const query: string[] = []; + if (criteria.subject) query.push("subject", criteria.subject); + if (criteria.from) query.push("from", criteria.from); + if (criteria.since) query.push("since", criteria.since); + if (query.length === 0) query.push("all"); + const messages = await this.doveadmFetch(user, query); + return messages.slice(-limit); + } + + private async doveadmFetch(user: string, query: string[]): Promise { + const output = await run( + "docker", + ["exec", "-i", this.imapContainer, "doveadm", "fetch", "-u", user, FETCH_FIELDS, ...query], + { timeout: 30_000 }, + ) + .then((r) => r.stdout) + // An empty mailbox is not an error; doveadm says so on stderr and exits non-zero. + .catch((e: { stderr?: string; message?: string }) => { + const text = `${e.stderr ?? ""}${e.message ?? ""}`; + if (text.includes("no matching mails")) return ""; + throw e; + }); + return parseDoveadmFetch(output).map(toMailMessage); + } +} + +// doveadm fetch prints one record per message, records separated by a blank line (a form feed in +// some builds), each field on its own `name: value` line. A folded value continues on later lines. +function parseDoveadmFetch(output: string): Array> { + const messages: Array> = []; + let current: Record = {}; + for (const line of output.split("\n")) { + if (line === "" || line === "\f") { + if (Object.keys(current).length) { + messages.push(current); + current = {}; + } + continue; + } + const colonIdx = line.indexOf(": "); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx); + const value = line.slice(colonIdx + 2); + current[key] = current[key] ? `${current[key]}\n${value}` : value; + } + } + if (Object.keys(current).length) messages.push(current); + return messages; +} + +function toMailMessage(m: Record): MailMessage { + const preview = m["body.snippet"]; + return { + date: m["date.received"]?.trim(), + from: m["hdr.from"]?.trim(), + subject: m["hdr.subject"]?.trim(), + preview: preview ? preview.trim().slice(0, 200) : undefined, + }; +} diff --git a/modules/mailu/index.ts b/modules/mailu/index.ts new file mode 100644 index 0000000..3526dd7 --- /dev/null +++ b/modules/mailu/index.ts @@ -0,0 +1,61 @@ +// mailu's events. The tool runtime imports this once the broker is bound. It watches the mail +// server's accounts and announces what changed, so the rest of the mesh can react to a mailbox +// appearing or an alias being pointed somewhere new. +// +// Emits (novox/hq ADR 0046/0047): +// module.mailu.user.created / .deleted — a mailbox appeared or was removed +// module.mailu.alias.created / .deleted — an alias was added or removed +// +// Consumes: nothing. Mail accounts are not something the mesh should mutate in the background off +// another module's event — a wrong reaction here silently loses mail. mailu observes and announces; +// it does not act on what others do. If a real consumer is ever wanted, it is a deliberate addition. +// +// Detection is by polling the admin API and diffing, exactly as plex diffs its sessions: the change +// may have come from the web admin as easily as from a tool, and a diff catches both. The first +// look primes silently, or a restart would re-announce every existing account as freshly created. + +import { emit } from "@novox/mesh-sdk/events"; +import { MailuClient } from "./client.js"; + +const mailu = MailuClient.fromEnv(); + +// A generic diff over a keyed set: emit `created` for keys that appeared, `deleted` for keys that +// went away, and stay silent until primed. Users and aliases are the same shape of watch. +function watcher(created: string, deleted: string): (keys: string[]) => Promise { + const known = new Set(); + let primed = false; + return async (keys: string[]) => { + const now = new Set(keys); + if (primed) { + for (const key of now) if (!known.has(key)) await emit(created, { email: key }); + for (const key of known) if (!now.has(key)) await emit(deleted, { email: key }); + } + known.clear(); + for (const key of now) known.add(key); + primed = true; + }; +} + +const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted"); +const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted"); + +async function pollUsers(): Promise { + await watchUsers((await mailu.listUsers()).map((u) => u.email)); +} + +async function pollAliases(): Promise { + await watchAliases((await mailu.listAliases()).map((a) => a.email)); +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const runOnce = (): void => void fn().catch((err) => console.error(`[mailu] ${err}`)); + setInterval(runOnce, everyMs); + runOnce(); +}; + +// Accounts and aliases change on human time, not machine time — a minute's latency is fine, and +// polling the admin API harder buys immediacy nobody asked for. +tick(pollUsers, 60_000); +tick(pollAliases, 60_000); + +console.log("[mailu] watching users and aliases"); diff --git a/modules/mailu/module.json b/modules/mailu/module.json index b1fe323..33aafe4 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.mailu.user.created", + "module.mailu.user.deleted", + "module.mailu.alias.created", + "module.mailu.alias.deleted" + ], "listens": [ { "port": 25, @@ -43,7 +49,8 @@ "own-secrets": { "secret-key": "/var/lib/mailu/secret-key.secret", "database": "/var/lib/mailu/database.secret", - "admin": "/var/lib/mailu/admin.secret" + "admin": "/var/lib/mailu/admin.secret", + "broker": "/var/lib/mailu/broker" }, "resources": [ { diff --git a/modules/mailu/package.json b/modules/mailu/package.json new file mode 100644 index 0000000..c640eac --- /dev/null +++ b/modules/mailu/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-mailu", + "version": "0.1.0", + "description": "mailu — mail server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/mailu/tools/index.ts b/modules/mailu/tools/index.ts new file mode 100644 index 0000000..e948075 --- /dev/null +++ b/modules/mailu/tools/index.ts @@ -0,0 +1,154 @@ +// mailu's tools — moved here from the shared sdk (novox/hq ADR 0044), importing mailu's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. Deletions are +// guarded by an explicit `confirm`, since removing a mailbox destroys its mail and cannot be undone. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { MailuClient } from "../client.js"; + +export function getMailuTools(mailu: MailuClient): ToolDefinition[] { + return [ + { + name: "mailu_list_users", + description: "List all email accounts on the mail server.", + input: {}, + run: async () => { + const users = await mailu.listUsers(); + return { count: users.length, users }; + }, + }, + { + name: "mailu_create_user", + description: "Create a new email account.", + input: { + email: { type: "string", description: "full address, e.g. user@example.com" }, + password: { type: "string", description: "the account's initial password" }, + }, + run: async (args) => { + const email = String(args.email); + await mailu.createUser(email, String(args.password)); + return { created: email }; + }, + }, + { + name: "mailu_change_password", + description: "Change the password of an email account.", + input: { + email: { type: "string", description: "full address of the account" }, + password: { type: "string", description: "the new password" }, + }, + run: async (args) => { + const email = String(args.email); + await mailu.changePassword(email, String(args.password)); + return { changed: email }; + }, + }, + { + name: "mailu_delete_user", + description: "Delete an email account. DESTRUCTIVE — removes the mailbox and all its mail.", + input: { + email: { type: "string", description: "full address of the account to delete" }, + confirm: { type: "boolean", description: "must be true — deletion is irreversible" }, + }, + run: async (args) => { + const email = String(args.email); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a user", email }; + await mailu.deleteUser(email); + return { deleted: email }; + }, + }, + { + name: "mailu_list_aliases", + description: "List all email aliases and where they forward.", + input: {}, + run: async () => { + const aliases = await mailu.listAliases(); + return { count: aliases.length, aliases }; + }, + }, + { + name: "mailu_create_alias", + description: "Create an email alias forwarding to one or more destinations.", + input: { + localpart: { type: "string", description: "the part before @, e.g. 'sales'" }, + domain: { type: "string", description: "the domain, e.g. example.com" }, + destination: { type: "string", description: "destination address(es), comma-separated" }, + wildcard: { type: "boolean", description: "match any localpart under the domain (optional)" }, + }, + run: async (args) => { + const email = `${String(args.localpart)}@${String(args.domain)}`; + const destination = String(args.destination).split(",").map((d) => d.trim()).filter(Boolean); + await mailu.createAlias(email, destination, args.wildcard === true); + return { created: email, destination }; + }, + }, + { + name: "mailu_delete_alias", + description: "Delete an email alias.", + input: { + email: { type: "string", description: "the alias address to delete" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const email = String(args.email); + if (args.confirm !== true) return { aborted: "confirm must be true to delete an alias", email }; + await mailu.deleteAlias(email); + return { deleted: email }; + }, + }, + { + name: "mailu_list_domains", + description: "List the mail domains the server handles.", + input: {}, + run: async () => { + const domains = await mailu.listDomains(); + return { count: domains.length, domains }; + }, + }, + { + name: "mailu_read_mail", + description: "Read recent messages in a user's mailbox — date, from, subject and a preview.", + input: { + user: { type: "string", description: "the mailbox owner's address" }, + mailbox: { type: "string", description: "which mailbox (default INBOX)" }, + limit: { type: "number", description: "how many recent messages (default 10)" }, + }, + run: async (args) => { + const user = String(args.user); + const mailbox = args.mailbox ? String(args.mailbox) : "INBOX"; + const messages = await mailu.readMail(user, mailbox, args.limit ? Number(args.limit) : 10); + return { user, mailbox, count: messages.length, messages }; + }, + }, + { + name: "mailu_search_mail", + description: "Search a user's mailbox by subject, sender, and/or date.", + input: { + user: { type: "string", description: "the mailbox owner's address" }, + subject: { type: "string", description: "substring to match in the subject (optional)" }, + from: { type: "string", description: "sender address or name to match (optional)" }, + since: { type: "string", description: "only messages since a date, e.g. 01-Jan-2026 (optional)" }, + limit: { type: "number", description: "maximum results (default 10)" }, + }, + run: async (args) => { + const user = String(args.user); + const criteria = { + subject: args.subject ? String(args.subject) : undefined, + from: args.from ? String(args.from) : undefined, + since: args.since ? String(args.since) : undefined, + }; + const messages = await mailu.searchMail(user, criteria, args.limit ? Number(args.limit) : 10); + return { user, count: messages.length, messages }; + }, + }, + ]; +} + +// The tools exist only when the admin API is configured; without it, mailu contributes none rather +// than failing the whole runtime. +registerModuleTools("mailu", (env) => { + try { + return getMailuTools(MailuClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/mailu/tsconfig.json b/modules/mailu/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/mailu/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From 4d98da18a0565658ede239054c33c724c6b119b3 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:30:40 +0200 Subject: [PATCH 06/28] =?UTF-8?q?keycloak:=20full=20nox=20module=20?= =?UTF-8?q?=E2=80=94=20client,=20tools=20and=20events=20(ADR=200044/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity provider. 22 admin tools (realms, users, clients + secrets, groups, roles) over the admin API, moved out of the shared sdk. Emits user created/ deleted, password reset, client/group/role created — from the write tools themselves, since Keycloak's value is the changes it makes, not pollable state. Consumes nothing: it is upstream of everything that authenticates against it. Typechecks; manifest parses. --- modules/keycloak/client.ts | 219 ++++++++++++++++++ modules/keycloak/index.ts | 44 ++++ modules/keycloak/module.json | 11 +- modules/keycloak/package.json | 14 ++ modules/keycloak/tools/index.ts | 378 ++++++++++++++++++++++++++++++++ modules/keycloak/tsconfig.json | 12 + 6 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 modules/keycloak/client.ts create mode 100644 modules/keycloak/index.ts create mode 100644 modules/keycloak/package.json create mode 100644 modules/keycloak/tools/index.ts create mode 100644 modules/keycloak/tsconfig.json diff --git a/modules/keycloak/client.ts b/modules/keycloak/client.ts new file mode 100644 index 0000000..431d331 --- /dev/null +++ b/modules/keycloak/client.ts @@ -0,0 +1,219 @@ +// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0044). +// Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here +// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and +// nothing outside keycloak does. + +export class KeycloakClient { + readonly baseUrl: string; + readonly defaultRealm: string; + // The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh + // password grant, and a 401 mid-flight refreshes it once rather than failing the request. + private tokenCache: { token: string; expiresAt: number } | null = null; + + constructor( + url: string, + private readonly adminUser: string, + private readonly adminPass: string, + defaultRealm = "master", + ) { + this.baseUrl = url.replace(/\/+$/, ""); + this.defaultRealm = defaultRealm; + } + + /** + * Build from the module's resolved environment. Admin URL, credentials and the fallback realm are + * read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own + * KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice. + * Throws when no admin password can be found: without it the client can do nothing, so failing + * here lets the tool runtime expose no keycloak tools rather than tools that always error. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { + const url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; + const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; + const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; + if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD"); + const realm = env.MESH_KEYCLOAK_REALM ?? "master"; + return new KeycloakClient(url, adminUser, adminPass, realm); + } + + private async getToken(): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token; + + const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "password", + client_id: "admin-cli", + username: this.adminUser, + password: this.adminPass, + }), + }); + if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`); + + const data = (await res.json()) as { access_token: string; expires_in: number }; + this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 }; + return data.access_token; + } + + private async request(path: string, options: RequestInit = {}): Promise { + const doRequest = async (token: string): Promise => + fetch(`${this.baseUrl}/admin/realms${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(options.headers as Record), + }, + }); + + let res = await doRequest(await this.getToken()); + // A cached token that expired against the server's clock reads as 401; drop it and retry once. + if (res.status === 401) { + this.tokenCache = null; + res = await doRequest(await this.getToken()); + } + if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`); + // 201/204 carry no body — the admin API's create/update/delete answer with an empty response. + if (res.status === 201 || res.status === 204) return null as T; + return res.json() as Promise; + } + + // Realms + async listRealms(): Promise> { + return this.request("/"); + } + + // Users + async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (params.search) qs.set("search", params.search); + if (params.max) qs.set("max", String(params.max)); + const query = qs.toString(); + return this.request(`/${realm}/users${query ? `?${query}` : ""}`); + } + + async createUser(realm: string, data: { + username: string; + email?: string; + enabled?: boolean; + credentials?: Array<{ type: string; value: string; temporary: boolean }>; + }): Promise { + await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) }); + } + + async updateUser(realm: string, userId: string, data: Record): Promise { + await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) }); + } + + async deleteUser(realm: string, userId: string): Promise { + await this.request(`/${realm}/users/${userId}`, { method: "DELETE" }); + } + + async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise { + await this.request(`/${realm}/users/${userId}/reset-password`, { + method: "PUT", + body: JSON.stringify({ type: "password", value: password, temporary }), + }); + } + + async getUserSessions(realm: string, userId: string): Promise { + return this.request(`/${realm}/users/${userId}/sessions`); + } + + // Clients + async listClients(realm: string): Promise { + return this.request(`/${realm}/clients`); + } + + async createClient(realm: string, data: { + clientId: string; + name?: string; + rootUrl?: string; + redirectUris?: string[]; + publicClient?: boolean; + protocol?: string; + }): Promise { + await this.request(`/${realm}/clients`, { + method: "POST", + body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }), + }); + } + + // The admin API addresses a client by its internal UUID, not the human clientId a caller knows; + // every client-scoped call resolves the one to the other first. + private async resolveClientId(realm: string, clientId: string): Promise { + const clients = (await this.listClients(realm)) as Array>; + const client = clients.find((c) => c.clientId === clientId); + if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + return client.id as string; + } + + async deleteClient(realm: string, clientId: string): Promise { + await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" }); + } + + async getClientSecret(realm: string, clientId: string): Promise { + const id = await this.resolveClientId(realm, clientId); + const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`); + return result.value; + } + + async addProtocolMapper(realm: string, clientId: string, mapper: { + name: string; + protocolMapper: string; + config: Record; + }): Promise { + const id = await this.resolveClientId(realm, clientId); + await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, { + method: "POST", + body: JSON.stringify({ protocol: "openid-connect", ...mapper }), + }); + } + + // Roles + async listRealmRoles(realm: string): Promise> { + return this.request(`/${realm}/roles`); + } + + async createRealmRole(realm: string, data: { name: string; description?: string }): Promise { + await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) }); + } + + async getUserRealmRoles(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/role-mappings/realm`); + } + + async getAvailableRealmRoles(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`); + } + + async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { + await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) }); + } + + async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { + await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) }); + } + + // Groups + async listGroups(realm: string): Promise> { + return this.request(`/${realm}/groups`); + } + + async createGroup(realm: string, name: string): Promise { + await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) }); + } + + async getUserGroups(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/groups`); + } + + async addUserToGroup(realm: string, userId: string, groupId: string): Promise { + await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" }); + } + + async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise { + await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" }); + } +} diff --git a/modules/keycloak/index.ts b/modules/keycloak/index.ts new file mode 100644 index 0000000..1a3bd85 --- /dev/null +++ b/modules/keycloak/index.ts @@ -0,0 +1,44 @@ +// keycloak's events. Keycloak's worth to the mesh is in what it changes — an identity created, a +// client registered, a password reset — so its events are emitted from the admin actions themselves +// (novox/hq ADR 0046/0047), not scraped back by polling. This module is the single vocabulary for +// them: every keycloak event goes through one of the helpers here, and the tools call them at the +// point the change succeeds. +// +// Emits: +// module.keycloak.user.created / .deleted — an identity appeared or was removed +// module.keycloak.password.reset — a user's credential was reset (no secret in the body) +// module.keycloak.client.created — an OIDC client was registered +// module.keycloak.group.created — a group was created +// module.keycloak.role.created — a realm role was created +// Consumes: +// nothing — Keycloak is upstream of the things that authenticate against it; it reacts to none of +// their events. There is no honest `on(...)` to write, so there is none. + +import { emit } from "@novox/mesh-sdk/events"; + +// A completed admin action must not be undone by a flaky broker: the change already happened in +// Keycloak, so a failed emit is logged and swallowed rather than thrown back through the tool. +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[keycloak] emit ${type} failed: ${err}`); + } +} + +export const events = { + userCreated: (realm: string, username: string, email?: string) => + announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }), + userDeleted: (realm: string, userId: string) => + announce("module.keycloak.user.deleted", { realm, userId }), + passwordReset: (realm: string, userId: string) => + announce("module.keycloak.password.reset", { realm, userId }), + clientCreated: (realm: string, clientId: string, name?: string) => + announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }), + groupCreated: (realm: string, name: string) => + announce("module.keycloak.group.created", { realm, name }), + roleCreated: (realm: string, name: string) => + announce("module.keycloak.role.created", { realm, name }), +}; + +console.log("[keycloak] event surface ready — identity, client, group and role changes are announced"); diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 5096b20..404cce3 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -18,6 +18,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.keycloak.user.created", + "module.keycloak.user.deleted", + "module.keycloak.password.reset", + "module.keycloak.client.created", + "module.keycloak.group.created", + "module.keycloak.role.created" + ], "listens": [ { "port": 8080, @@ -27,7 +35,8 @@ } ], "own-secrets": { - "admin": "/var/lib/keycloak/admin.secret" + "admin": "/var/lib/keycloak/admin.secret", + "broker": "/var/lib/keycloak/broker" }, "resources": [ { diff --git a/modules/keycloak/package.json b/modules/keycloak/package.json new file mode 100644 index 0000000..6076ad3 --- /dev/null +++ b/modules/keycloak/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-keycloak", + "version": "0.1.0", + "description": "keycloak — identity and access. Its admin API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/keycloak/tools/index.ts b/modules/keycloak/tools/index.ts new file mode 100644 index 0000000..0382713 --- /dev/null +++ b/modules/keycloak/tools/index.ts @@ -0,0 +1,378 @@ +// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0044), importing keycloak's own +// client. They return structured data (not the hal MCP `{content:[...]}` shape); the mesh serves +// them through the sdk's tool harness. Write actions announce themselves through the module's event +// surface at the point they succeed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { KeycloakClient } from "../client.js"; +import { events } from "../index.js"; + +export function getKeycloakTools(kc: KeycloakClient): ToolDefinition[] { + // Almost every tool is realm-scoped; an omitted realm falls back to the one the module resolved + // from its environment, so the common single-realm case needs no argument. + const realmOf = (args: Readonly>): string => + args.realm ? String(args.realm) : kc.defaultRealm; + + return [ + // Realms & sessions + { + name: "keycloak_list_realms", + description: "List all Keycloak realms.", + input: {}, + run: async () => { + const realms = await kc.listRealms(); + return { realms: realms.map((r) => ({ id: r.id, realm: r.realm, displayName: r.displayName, enabled: r.enabled })) }; + }, + }, + { + name: "keycloak_list_sessions", + description: "List active sessions for a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ sessions: await kc.getUserSessions(realmOf(args), String(args.user_id)) }), + }, + + // Users + { + name: "keycloak_list_users", + description: "List users in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + search: { type: "string", description: "search by username, email, first/last name" }, + max: { type: "number", description: "maximum number of results" }, + }, + run: async (args) => ({ + users: await kc.listUsers(realmOf(args), { + search: args.search ? String(args.search) : undefined, + max: args.max ? Number(args.max) : undefined, + }), + }), + }, + { + name: "keycloak_create_user", + description: "Create a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + username: { type: "string", description: "username" }, + email: { type: "string", description: "email address" }, + password: { type: "string", description: "initial password" }, + temporary_password: { type: "boolean", description: "require a password change on first login (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const username = String(args.username); + const email = args.email ? String(args.email) : undefined; + const credentials = args.password + ? [{ type: "password", value: String(args.password), temporary: args.temporary_password !== false }] + : undefined; + await kc.createUser(realm, { username, email, credentials }); + await events.userCreated(realm, username, email); + return { created: { realm, username, email } }; + }, + }, + { + name: "keycloak_delete_user", + description: "Delete a user from a Keycloak realm (requires confirm).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a user" }; + await kc.deleteUser(realm, userId); + await events.userDeleted(realm, userId); + return { deleted: { realm, userId } }; + }, + }, + { + name: "keycloak_update_user", + description: "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + enabled: { type: "boolean", description: "enable or disable the user" }, + email: { type: "string", description: "new email address" }, + firstName: { type: "string", description: "new first name" }, + lastName: { type: "string", description: "new last name" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const updates: Record = {}; + if (args.enabled !== undefined) updates.enabled = args.enabled === true; + if (args.email !== undefined) updates.email = String(args.email); + if (args.firstName !== undefined) updates.firstName = String(args.firstName); + if (args.lastName !== undefined) updates.lastName = String(args.lastName); + if (Object.keys(updates).length === 0) return { aborted: "no updates provided" }; + await kc.updateUser(realm, userId, updates); + return { updated: { realm, userId, fields: Object.keys(updates) } }; + }, + }, + { + name: "keycloak_reset_password", + description: "Reset a user's password in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + password: { type: "string", description: "new password" }, + temporary: { type: "boolean", description: "require a password change on next login (default false)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + await kc.resetPassword(realm, userId, String(args.password), args.temporary === true); + await events.passwordReset(realm, userId); + return { reset: { realm, userId } }; + }, + }, + + // Clients + { + name: "keycloak_list_clients", + description: "List OIDC clients in a Keycloak realm.", + input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } }, + run: async (args) => { + const clients = (await kc.listClients(realmOf(args))) as Array>; + return { + clients: clients.map((c) => ({ + id: c.id, clientId: c.clientId, name: c.name, enabled: c.enabled, + protocol: c.protocol, publicClient: c.publicClient, rootUrl: c.rootUrl, + })), + }; + }, + }, + { + name: "keycloak_create_client", + description: "Create an OIDC client in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'my-app')" }, + name: { type: "string", description: "display name" }, + root_url: { type: "string", description: "root URL of the application" }, + redirect_uris: { type: "array", description: "allowed redirect URIs" }, + public_client: { type: "boolean", description: "public client, no client secret (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + const name = args.name ? String(args.name) : undefined; + await kc.createClient(realm, { + clientId, + name, + rootUrl: args.root_url ? String(args.root_url) : undefined, + redirectUris: Array.isArray(args.redirect_uris) ? args.redirect_uris.map(String) : undefined, + publicClient: args.public_client !== false, + }); + await events.clientCreated(realm, clientId, name); + return { created: { realm, clientId, name } }; + }, + }, + { + name: "keycloak_delete_client", + description: "Delete an OIDC client from a Keycloak realm (requires confirm).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'my-app')" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a client" }; + await kc.deleteClient(realm, clientId); + return { deleted: { realm, clientId } }; + }, + }, + { + name: "keycloak_get_client_secret", + description: "Get the client secret for a confidential OIDC client.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID" }, + }, + run: async (args) => ({ secret: await kc.getClientSecret(realmOf(args), String(args.client_id)) }), + }, + { + name: "keycloak_add_protocol_mapper", + description: + "Add a protocol mapper to an OIDC client. Common types: oidc-usermodel-realm-role-mapper " + + "(realm roles), oidc-usermodel-attribute-mapper (user attributes), oidc-audience-mapper.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'grafana')" }, + name: { type: "string", description: "mapper name (e.g. 'realm roles')" }, + mapper_type: { type: "string", description: "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')" }, + claim_name: { type: "string", description: "token claim name (e.g. 'realm_access.roles')" }, + claim_type: { type: "string", description: "JSON type: String, long, int, boolean (default String)" }, + multivalued: { type: "boolean", description: "whether the claim has multiple values (default false)" }, + id_token: { type: "boolean", description: "include in ID token (default true)" }, + access_token: { type: "boolean", description: "include in access token (default true)" }, + userinfo: { type: "boolean", description: "include in userinfo response (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + const name = String(args.name); + await kc.addProtocolMapper(realm, clientId, { + name, + protocolMapper: String(args.mapper_type), + config: { + "claim.name": String(args.claim_name), + "jsonType.label": args.claim_type ? String(args.claim_type) : "String", + "multivalued": String(args.multivalued === true), + "id.token.claim": String(args.id_token !== false), + "access.token.claim": String(args.access_token !== false), + "userinfo.token.claim": String(args.userinfo !== false), + }, + }); + return { added: { realm, clientId, mapper: name } }; + }, + }, + + // Groups + { + name: "keycloak_list_groups", + description: "List groups in a Keycloak realm.", + input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } }, + run: async (args) => ({ groups: await kc.listGroups(realmOf(args)) }), + }, + { + name: "keycloak_create_group", + description: "Create a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + name: { type: "string", description: "group name" }, + }, + run: async (args) => { + const realm = realmOf(args); + const name = String(args.name); + await kc.createGroup(realm, name); + await events.groupCreated(realm, name); + return { created: { realm, group: name } }; + }, + }, + { + name: "keycloak_get_user_groups", + description: "List the groups a user belongs to in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ groups: await kc.getUserGroups(realmOf(args), String(args.user_id)) }), + }, + { + name: "keycloak_add_user_to_group", + description: "Add a user to a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + group_id: { type: "string", description: "group ID (UUID)" }, + }, + run: async (args) => { + const realm = realmOf(args); + await kc.addUserToGroup(realm, String(args.user_id), String(args.group_id)); + return { added: { realm, userId: String(args.user_id), groupId: String(args.group_id) } }; + }, + }, + { + name: "keycloak_remove_user_from_group", + description: "Remove a user from a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + group_id: { type: "string", description: "group ID (UUID)" }, + }, + run: async (args) => { + const realm = realmOf(args); + await kc.removeUserFromGroup(realm, String(args.user_id), String(args.group_id)); + return { removed: { realm, userId: String(args.user_id), groupId: String(args.group_id) } }; + }, + }, + + // Roles + { + name: "keycloak_get_user_roles", + description: "List the realm roles assigned to a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ roles: await kc.getUserRealmRoles(realmOf(args), String(args.user_id)) }), + }, + { + name: "keycloak_create_role", + description: "Create a realm role in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + role_name: { type: "string", description: "role name" }, + description: { type: "string", description: "role description" }, + }, + run: async (args) => { + const realm = realmOf(args); + const name = String(args.role_name); + await kc.createRealmRole(realm, { name, description: args.description ? String(args.description) : undefined }); + await events.roleCreated(realm, name); + return { created: { realm, role: name } }; + }, + }, + { + name: "keycloak_assign_user_role", + description: "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + role_name: { type: "string", description: "role name to assign" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const roleName = String(args.role_name); + // The mapping API needs the role's UUID, which only the "available" list carries; if the + // role is neither available nor already assigned it does not exist in this realm. + const available = await kc.getAvailableRealmRoles(realm, userId); + const role = available.find((r) => r.name === roleName); + if (!role) { + const assigned = await kc.getUserRealmRoles(realm, userId); + if (assigned.find((r) => r.name === roleName)) return { alreadyAssigned: { realm, userId, role: roleName } }; + return { notFound: { realm, role: roleName } }; + } + await kc.assignRealmRoles(realm, userId, [{ id: role.id, name: role.name }]); + return { assigned: { realm, userId, role: roleName } }; + }, + }, + { + name: "keycloak_remove_user_role", + description: "Remove a realm role from a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + role_name: { type: "string", description: "role name to remove" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const roleName = String(args.role_name); + const assigned = await kc.getUserRealmRoles(realm, userId); + const role = assigned.find((r) => r.name === roleName); + if (!role) return { notAssigned: { realm, userId, role: roleName } }; + await kc.removeRealmRoles(realm, userId, [{ id: role.id, name: role.name }]); + return { removed: { realm, userId, role: roleName } }; + }, + }, + ]; +} + +// The tools exist only when the client can be configured; without an admin password, keycloak +// contributes none rather than failing the whole runtime. +registerModuleTools("keycloak", (env) => { + try { + return getKeycloakTools(KeycloakClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/keycloak/tsconfig.json b/modules/keycloak/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/keycloak/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From 259c3721b53d7ae92b1a056e91ec7ff3daa2e5da Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:30:40 +0200 Subject: [PATCH 07/28] =?UTF-8?q?gitea:=20full=20nox=20module=20=E2=80=94?= =?UTF-8?q?=20client,=20tools=20and=20events=20(ADR=200044/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git hosting. 15 tools (repos, issues, PRs, labels, api passthrough) moved out of the shared sdk. Emits repo.created (from a light poll, catching repos born of git push or the web UI), issue.opened and pull.merged (from the tools at the moment of the action) — the poll owns repo.created alone so it is never announced twice. Typechecks; manifest parses. --- modules/gitea/client.ts | 241 +++++++++++++++++++++++++++ modules/gitea/index.ts | 59 +++++++ modules/gitea/module.json | 8 +- modules/gitea/package.json | 14 ++ modules/gitea/tools/index.ts | 306 +++++++++++++++++++++++++++++++++++ modules/gitea/tsconfig.json | 12 ++ 6 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 modules/gitea/client.ts create mode 100644 modules/gitea/index.ts create mode 100644 modules/gitea/package.json create mode 100644 modules/gitea/tools/index.ts create mode 100644 modules/gitea/tsconfig.json diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts new file mode 100644 index 0000000..c17b84a --- /dev/null +++ b/modules/gitea/client.ts @@ -0,0 +1,241 @@ +// The Gitea API client — gitea's own code, living in the module (novox/hq ADR 0044). Moved out of +// the shared hal sdk, where a change to Gitea's API rebuilt everything; here it rebuilds only +// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea +// does. + +/** A repository, trimmed to what the mesh cares about. */ +export interface GiteaRepo { + full_name: string; + name: string; + owner: string; + private: boolean; + description?: string; + html_url: string; + default_branch?: string; +} + +/** An issue, with its labels flattened to names. */ +export interface GiteaIssue { + number: number; + title: string; + state: string; + user?: string; + labels: string[]; + html_url: string; + body?: string; +} + +/** A pull request, trimmed to the fields a reviewer or an event body needs. */ +export interface GiteaPull { + number: number; + title: string; + state: string; + merged: boolean; + user?: string; + head?: string; + base?: string; + html_url: string; +} + +export interface GiteaLabel { + id: number; + name: string; +} + +export class GiteaClient { + readonly baseUrl: string; + private cachedUsername: string | null = null; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. URL and token come from MESH_GITEA_URL / + * MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the + * URL, to the forge's loopback port. A token is required — without one there is no authenticated + * call to make, so this throws rather than hand back a client that fails on first use. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { + const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; + const token = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); + return new GiteaClient(url, token); + } + + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `token ${this.token}`, + ...(options.headers as Record | undefined), + }, + }); + if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); + if (res.status === 204) return null as T; + const text = await res.text(); + return (text ? JSON.parse(text) : null) as T; + } + + /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. + * Path is relative to /api/v1. */ + async api(path: string, options: RequestInit = {}): Promise { + return this.request(path, options); + } + + // ---- Repositories ---- + + async listRepos(page = 1, limit = 20): Promise { + const repos = await this.request(`/user/repos?page=${page}&limit=${limit}`); + return (repos ?? []).map(GiteaClient.mapRepo); + } + + async createRepo(data: { + name: string; + description?: string; + private?: boolean; + auto_init?: boolean; + }): Promise { + return GiteaClient.mapRepo(await this.request("/user/repos", { method: "POST", body: JSON.stringify(data) })); + } + + async deleteRepo(owner: string, repo: string): Promise { + await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" }); + } + + // ---- Issues ---- + + async listIssues(owner: string, repo: string, params: Record = {}): Promise { + const qs = new URLSearchParams({ type: "issues", ...params }).toString(); + const issues = await this.request(`/repos/${owner}/${repo}/issues?${qs}`); + return (issues ?? []).map(GiteaClient.mapIssue); + } + + async getIssue(owner: string, repo: string, index: number): Promise { + return GiteaClient.mapIssue(await this.request(`/repos/${owner}/${repo}/issues/${index}`)); + } + + async createIssue( + owner: string, + repo: string, + data: { title: string; body?: string; labels?: number[] }, + ): Promise { + return GiteaClient.mapIssue( + await this.request(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify(data) }), + ); + } + + /** Patch an issue's state — the one edit the close tool needs. */ + async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise { + return GiteaClient.mapIssue( + await this.request(`/repos/${owner}/${repo}/issues/${index}`, { + method: "PATCH", + body: JSON.stringify({ state }), + }), + ); + } + + async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> { + const c = await this.request(`/repos/${owner}/${repo}/issues/${index}/comments`, { + method: "POST", + body: JSON.stringify({ body }), + }); + return { id: c.id, html_url: c.html_url }; + } + + // ---- Labels ---- + + async listLabels(owner: string, repo: string): Promise { + const labels = await this.request(`/repos/${owner}/${repo}/labels`); + return (labels ?? []).map((l: any) => ({ id: l.id, name: l.name })); + } + + async createLabel( + owner: string, + repo: string, + data: { name: string; color: string; description?: string }, + ): Promise { + const l = await this.request(`/repos/${owner}/${repo}/labels`, { method: "POST", body: JSON.stringify(data) }); + return { id: l.id, name: l.name }; + } + + /** Resolve a label name to its id, creating it if it does not exist — so create-issue can take + * human label names and not numeric ids. */ + async getOrCreateLabel(owner: string, repo: string, name: string, color = "#0075ca"): Promise { + const existing = (await this.listLabels(owner, repo)).find((l) => l.name === name); + if (existing) return existing.id; + return (await this.createLabel(owner, repo, { name, color })).id; + } + + // ---- Pull requests ---- + + async listPullRequests(owner: string, repo: string, params: Record = {}): Promise { + const qs = new URLSearchParams(params).toString(); + const prs = await this.request(`/repos/${owner}/${repo}/pulls?${qs}`); + return (prs ?? []).map(GiteaClient.mapPull); + } + + async getPullRequest(owner: string, repo: string, index: number): Promise { + return GiteaClient.mapPull(await this.request(`/repos/${owner}/${repo}/pulls/${index}`)); + } + + async createPullRequest( + owner: string, + repo: string, + data: { title: string; body?: string; head: string; base: string }, + ): Promise { + return GiteaClient.mapPull( + await this.request(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }), + ); + } + + async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise { + await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { + method: "POST", + body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }), + }); + } + + // ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ---- + + private static mapRepo(r: any): GiteaRepo { + return { + full_name: r.full_name, + name: r.name, + owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", + private: Boolean(r.private), + description: r.description || undefined, + html_url: r.html_url, + default_branch: r.default_branch, + }; + } + + private static mapIssue(i: any): GiteaIssue { + return { + number: i.number, + title: i.title, + state: i.state, + user: i.user?.login, + labels: (i.labels ?? []).map((l: any) => l.name), + html_url: i.html_url, + body: i.body || undefined, + }; + } + + private static mapPull(p: any): GiteaPull { + return { + number: p.number, + title: p.title, + state: p.state, + merged: Boolean(p.merged), + user: p.user?.login, + head: p.head?.ref, + base: p.base?.ref, + html_url: p.html_url, + }; + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts new file mode 100644 index 0000000..15366b1 --- /dev/null +++ b/modules/gitea/index.ts @@ -0,0 +1,59 @@ +// gitea's events. The tool runtime imports this once the broker is bound. It watches the forge and +// emits what appeared. +// +// Emits (novox/hq ADR 0046/0047): +// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool) +// +// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh +// takes that action — the natural point, and one process only. repo.created belongs here instead: +// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the +// repo list is the only way to catch every path — and keeping it out of the create-repo tool means +// the fact is never announced twice from two processes. +// +// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering +// the forge for an immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { GiteaClient } from "./client.js"; + +// Without a token there is nothing to watch; log and stay quiet rather than crash the runtime. +let gitea: GiteaClient | null = null; +try { + gitea = GiteaClient.fromEnv(); +} catch (err) { + console.log(`[gitea] not watching — ${err instanceof Error ? err.message : String(err)}`); +} + +// New repositories, by diffing the repo list. Primed silently on the first look, or a restart would +// re-announce every existing repository as freshly created. +const seen = new Set(); +let primed = false; +async function pollRepos(client: GiteaClient): Promise { + const repos = await client.listRepos(1, 50); + for (const repo of repos) { + if (!seen.has(repo.full_name)) { + if (primed) { + await emit("module.gitea.repo.created", { + full_name: repo.full_name, + owner: repo.owner, + name: repo.name, + private: repo.private, + html_url: repo.html_url, + }); + } + seen.add(repo.full_name); + } + } + primed = true; +} + +if (gitea) { + const client = gitea; + const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`)); + setInterval(run, everyMs); + run(); + }; + tick(() => pollRepos(client), 60_000); + console.log("[gitea] watching for new repositories"); +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index feb28b8..83133c8 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -18,6 +18,11 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.gitea.repo.created", + "module.gitea.issue.opened", + "module.gitea.pull.merged" + ], "listens": [ { "port": 3000, @@ -33,7 +38,8 @@ } ], "own-secrets": { - "internal-token": "/var/lib/gitea/internal-token.secret" + "internal-token": "/var/lib/gitea/internal-token.secret", + "broker": "/var/lib/gitea/broker" }, "resources": [ { diff --git a/modules/gitea/package.json b/modules/gitea/package.json new file mode 100644 index 0000000..fe30cf5 --- /dev/null +++ b/modules/gitea/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-gitea", + "version": "0.1.0", + "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts new file mode 100644 index 0000000..d970d9f --- /dev/null +++ b/modules/gitea/tools/index.ts @@ -0,0 +1,306 @@ +// gitea's tools — moved here from the shared sdk (novox/hq ADR 0044), importing gitea's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. +// +// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0046/0047): +// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on +// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos +// are far more often born from a `git push` or the web UI than from this tool, so the events +// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and +// the poll double-announcing the same repo from two processes. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { emit } from "@novox/mesh-sdk/events"; +import { GiteaClient } from "../client.js"; + +/** Coerce a comma-separated label string into names; empty/absent yields none. */ +function parseLabels(raw: unknown): string[] { + if (raw === undefined || raw === null || raw === "") return []; + return String(raw) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { + return [ + // ---- Repositories ---- + { + name: "gitea_list_repos", + description: "List repositories for the authenticated Gitea user.", + input: { + page: { type: "number", description: "page number (default 1)" }, + limit: { type: "number", description: "how many per page (default 20)" }, + }, + run: async (args) => ({ + repos: await gitea.listRepos(args.page ? Number(args.page) : 1, args.limit ? Number(args.limit) : 20), + }), + }, + { + name: "gitea_create_repo", + description: "Create a repository owned by the authenticated user.", + input: { + name: { type: "string", description: "the repository name" }, + description: { type: "string", description: "an optional description" }, + private: { type: "boolean", description: "private repo (default true)" }, + auto_init: { type: "boolean", description: "initialise with a README (default true)" }, + }, + run: async (args) => { + const repo = await gitea.createRepo({ + name: String(args.name), + description: args.description ? String(args.description) : undefined, + private: args.private === undefined ? true : Boolean(args.private), + auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init), + }); + return { repo }; + }, + }, + { + name: "gitea_delete_repo", + description: "Delete a repository. Destructive and irreversible — requires confirm=true.", + input: { + owner: { type: "string", description: "the repository owner" }, + name: { type: "string", description: "the repository name" }, + confirm: { type: "boolean", description: "must be true to actually delete" }, + }, + run: async (args) => { + if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" }; + await gitea.deleteRepo(String(args.owner), String(args.name)); + return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` }; + }, + }, + + // ---- Issues ---- + { + name: "gitea_list_issues", + description: "List issues for a repository, filterable by state and labels.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + state: { type: "string", description: "open | closed | all (default open)" }, + labels: { type: "string", description: "comma-separated label names to filter by" }, + page: { type: "number", description: "page number (default 1)" }, + }, + run: async (args) => { + const params: Record = { + state: args.state ? String(args.state) : "open", + page: String(args.page ? Number(args.page) : 1), + }; + if (args.labels) params.labels = String(args.labels); + return { issues: await gitea.listIssues(String(args.owner), String(args.repo), params) }; + }, + }, + { + name: "gitea_get_issue", + description: "Get a single issue by its number.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the issue number" }, + }, + run: async (args) => ({ + issue: await gitea.getIssue(String(args.owner), String(args.repo), Number(args.number)), + }), + }, + { + name: "gitea_create_issue", + description: "Open a new issue. Label names are resolved to ids, creating any that are missing.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + title: { type: "string", description: "the issue title" }, + body: { type: "string", description: "the issue body (markdown)" }, + labels: { type: "string", description: "comma-separated label names" }, + }, + run: async (args) => { + const owner = String(args.owner); + const repo = String(args.repo); + const names = parseLabels(args.labels); + const labelIds = names.length + ? await Promise.all(names.map((n) => gitea.getOrCreateLabel(owner, repo, n))) + : undefined; + const issue = await gitea.createIssue(owner, repo, { + title: String(args.title), + body: args.body ? String(args.body) : undefined, + labels: labelIds, + }); + // The mesh just opened an issue — announce it the moment it exists. + await emit("module.gitea.issue.opened", { + owner, + repo, + number: issue.number, + title: issue.title, + user: issue.user, + html_url: issue.html_url, + }); + return { issue }; + }, + }, + { + name: "gitea_close_issue", + description: "Close an open issue.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the issue number" }, + }, + run: async (args) => ({ + issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"), + }), + }, + { + name: "gitea_add_comment", + description: "Add a comment to an issue or pull request.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the issue or PR number" }, + body: { type: "string", description: "the comment body (markdown)" }, + }, + run: async (args) => ({ + comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)), + }), + }, + + // ---- Pull requests ---- + { + name: "gitea_list_pull_requests", + description: "List pull requests for a repository.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + state: { type: "string", description: "open | closed | all (default open)" }, + page: { type: "number", description: "page number (default 1)" }, + limit: { type: "number", description: "how many per page (default 20)" }, + }, + run: async (args) => ({ + pulls: await gitea.listPullRequests(String(args.owner), String(args.repo), { + state: args.state ? String(args.state) : "open", + page: String(args.page ? Number(args.page) : 1), + limit: String(args.limit ? Number(args.limit) : 20), + }), + }), + }, + { + name: "gitea_get_pull_request", + description: "Get a single pull request by its number.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the PR number" }, + }, + run: async (args) => ({ + pull: await gitea.getPullRequest(String(args.owner), String(args.repo), Number(args.number)), + }), + }, + { + name: "gitea_create_pull_request", + description: "Open a pull request from a head branch into a base branch.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + title: { type: "string", description: "the PR title" }, + body: { type: "string", description: "the PR body (markdown)" }, + head: { type: "string", description: "the source branch" }, + base: { type: "string", description: "the target branch (default main)" }, + }, + run: async (args) => ({ + pull: await gitea.createPullRequest(String(args.owner), String(args.repo), { + title: String(args.title), + body: args.body ? String(args.body) : undefined, + head: String(args.head), + base: args.base ? String(args.base) : "main", + }), + }), + }, + { + name: "gitea_merge_pull_request", + description: "Merge a pull request, optionally deleting the source branch afterwards.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + number: { type: "number", description: "the PR number" }, + method: { type: "string", description: "merge | rebase | squash (default merge)" }, + delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" }, + }, + run: async (args) => { + const owner = String(args.owner); + const repo = String(args.repo); + const number = Number(args.number); + const method = args.method ? String(args.method) : "merge"; + const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); + // Read the PR first, so the merged event carries a title and branches, not just a number. + const pull = await gitea.getPullRequest(owner, repo, number); + await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); + await emit("module.gitea.pull.merged", { + owner, + repo, + number, + title: pull.title, + head: pull.head, + base: pull.base, + method, + html_url: pull.html_url, + }); + return { merged: true, number, method, deleted_branch: deleteBranch }; + }, + }, + + // ---- Labels ---- + { + name: "gitea_list_labels", + description: "List every label defined in a repository.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + }, + run: async (args) => ({ labels: await gitea.listLabels(String(args.owner), String(args.repo)) }), + }, + { + name: "gitea_create_label", + description: "Create a label in a repository.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + name: { type: "string", description: "the label name" }, + color: { type: "string", description: "hex colour, e.g. #0075ca" }, + description: { type: "string", description: "an optional description" }, + }, + run: async (args) => ({ + label: await gitea.createLabel(String(args.owner), String(args.repo), { + name: String(args.name), + color: String(args.color), + description: args.description ? String(args.description) : undefined, + }), + }), + }, + + // ---- Escape hatch ---- + { + name: "gitea_api", + description: "Make an authenticated Gitea API call for any endpoint without a dedicated tool. Path is relative to /api/v1.", + input: { + path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" }, + method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" }, + body: { type: "object", description: "JSON request body for POST/PUT/PATCH" }, + }, + run: async (args) => { + const method = args.method ? String(args.method) : "GET"; + const result = await gitea.api(String(args.path), { + method, + ...(args.body ? { body: JSON.stringify(args.body) } : {}), + }); + return { result }; + }, + }, + ]; +} + +// The tools exist only when a token can be found; without one, gitea contributes none rather than +// failing the whole runtime. +registerModuleTools("gitea", (env) => { + try { + return getGiteaTools(GiteaClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/gitea/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From fb42fb956bae402ff47e45788e67caeb0b3c93b0 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:33:32 +0200 Subject: [PATCH 08/28] =?UTF-8?q?sonarr,=20radarr:=20full=20nox=20modules?= =?UTF-8?q?=20=E2=80=94=20clients,=20tools=20and=20events=20(ADR=200044/00?= =?UTF-8?q?46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Servarr apps (TV, movies), each self-contained from hal's shared arr client. Tools: status, library, search, queue, calendar. Events by polling the download queue: a new item emits module...grabbed, an item that leaves as completed emits module..download.completed — the exact key plex consumes to rescan. A grab that left as failed/warning is not reported as a completion. Both typecheck; manifests parse. --- modules/radarr/client.ts | 127 ++++++++++++++++++++++++++++++++++ modules/radarr/index.ts | 55 +++++++++++++++ modules/radarr/module.json | 8 +++ modules/radarr/package.json | 14 ++++ modules/radarr/tools/index.ts | 78 +++++++++++++++++++++ modules/radarr/tsconfig.json | 12 ++++ modules/sonarr/client.ts | 127 ++++++++++++++++++++++++++++++++++ modules/sonarr/index.ts | 55 +++++++++++++++ modules/sonarr/module.json | 8 +++ modules/sonarr/package.json | 14 ++++ modules/sonarr/tools/index.ts | 78 +++++++++++++++++++++ modules/sonarr/tsconfig.json | 12 ++++ 12 files changed, 588 insertions(+) create mode 100644 modules/radarr/client.ts create mode 100644 modules/radarr/index.ts create mode 100644 modules/radarr/package.json create mode 100644 modules/radarr/tools/index.ts create mode 100644 modules/radarr/tsconfig.json create mode 100644 modules/sonarr/client.ts create mode 100644 modules/sonarr/index.ts create mode 100644 modules/sonarr/package.json create mode 100644 modules/sonarr/tools/index.ts create mode 100644 modules/sonarr/tsconfig.json diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts new file mode 100644 index 0000000..74a4bff --- /dev/null +++ b/modules/radarr/client.ts @@ -0,0 +1,127 @@ +// The Radarr API client — radarr's own code, living in the module (novox/hq ADR 0044). Ported from +// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a +// change to Radarr's API rebuilds only radarr and nothing else. Both this module's tools and its +// events entrypoint import it, and nothing outside radarr does. + +// Radarr speaks the v3 API; its content is "movie". +const API_VERSION = "v3"; +const CONTENT_ENDPOINT = "movie"; +const APP_NAME = "Radarr"; + +export interface RadarrQueueItem { + /** The queue record id — stable while the item is in the queue, so events can diff on it. */ + id: number; + title: string; + status: string; + size: string; + sizeleft: string; + timeleft?: string; +} + +export interface RadarrCalendarItem { + title: string; + date: string; + overview?: string; +} + +export interface RadarrContentItem { + title: string; + year?: number; + status?: string; + monitored: boolean; +} + +export class RadarrClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. URL and key are read from MESH_RADARR_URL and + * MESH_RADARR_API_KEY; both must be present — an unconfigured Radarr throws rather than pretend to + * be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes + * nothing"). + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): RadarrClient { + const url = env.MESH_RADARR_URL; + const apiKey = env.MESH_RADARR_API_KEY; + if (!url || !apiKey) { + throw new Error("Radarr not configured — set MESH_RADARR_URL and MESH_RADARR_API_KEY"); + } + return new RadarrClient(url, apiKey); + } + + private async get(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); + if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getStatus(): Promise<{ appName: string; version: string }> { + const data = (await this.get("system/status")) as { appName?: string; version?: string }; + return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; + } + + async getContent(limit?: number): Promise { + const data = await this.get(CONTENT_ENDPOINT); + const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); + const mapped = items.map((item) => ({ + title: item.title ?? "Unknown", + year: item.year, + status: item.status, + monitored: item.monitored ?? true, + })); + return limit ? mapped.slice(0, limit) : mapped; + } + + /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ + async searchContent(term: string): Promise { + const all = await this.getContent(); + const lower = term.toLowerCase(); + return all.filter((item) => item.title.toLowerCase().includes(lower)); + } + + async getQueue(): Promise<{ totalRecords: number; items: RadarrQueueItem[] }> { + const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; + const records = data.records ?? []; + return { + totalRecords: data.totalRecords ?? records.length, + items: records.map((r) => ({ + id: r.id, + title: r.title ?? r.movie?.title ?? "Unknown", + status: r.status ?? "unknown", + size: formatBytes(r.size ?? 0), + sizeleft: formatBytes(r.sizeleft ?? 0), + timeleft: r.timeleft, + })), + }; + } + + async getCalendar(days = 7): Promise { + const start = new Date().toISOString().split("T")[0]; + const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; + const data = await this.get("calendar", { start, end }); + const items: any[] = Array.isArray(data) ? data : []; + return items.map((item) => ({ + title: item.title ?? item.movie?.title ?? "Unknown", + date: item.inCinemas ?? item.digitalRelease ?? "", + overview: item.overview?.slice(0, 150), + })); + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} diff --git a/modules/radarr/index.ts b/modules/radarr/index.ts new file mode 100644 index 0000000..3b08be7 --- /dev/null +++ b/modules/radarr/index.ts @@ -0,0 +1,55 @@ +// radarr's events. The tool runtime imports this once the broker is bound. It watches the download +// queue and turns its comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.radarr.movie.grabbed — a release entered the queue (Radarr grabbed it) +// module.radarr.download.completed — a release left the queue, imported. This exact routing key +// is what the plex module consumes (module.*.download.completed) +// to rescan, so the new movie becomes a visible item. +// Consumes: none. +// +// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a +// restart mid-download does not re-announce everything already in flight as freshly grabbed. + +import { emit } from "@novox/mesh-sdk/events"; +import { RadarrClient, type RadarrQueueItem } from "./client.js"; + +const radarr = RadarrClient.fromEnv(); + +// Radarr removes an item from the queue once it has been imported; a "warning"/"failed" status is +// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. +const FAILED_STATUSES = new Set(["failed", "warning"]); + +const inQueue = new Map(); +let primed = false; + +async function pollQueue(): Promise { + const { items } = await radarr.getQueue(); + const now = new Map(items.map((i) => [i.id, i])); + + if (primed) { + // Entered the queue since last look — Radarr grabbed a release. + for (const [id, item] of now) { + if (!inQueue.has(id)) await emit("module.radarr.movie.grabbed", { title: item.title, status: item.status }); + } + // Left the queue — imported and done, unless it was last seen failing. + for (const [id, item] of inQueue) { + if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { + await emit("module.radarr.download.completed", { title: item.title }); + } + } + } + + inQueue.clear(); + for (const [id, item] of now) inQueue.set(id, item); + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[radarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 30_000); + +console.log("[radarr] watching the download queue, emitting grabs and completions"); diff --git a/modules/radarr/module.json b/modules/radarr/module.json index 51a782f..e05b77d 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.radarr.movie.grabbed", + "module.radarr.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/radarr/broker" + }, "listens": [ { "port": 7878, diff --git a/modules/radarr/package.json b/modules/radarr/package.json new file mode 100644 index 0000000..9393ff4 --- /dev/null +++ b/modules/radarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-radarr", + "version": "0.1.0", + "description": "radarr — movie management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/radarr/tools/index.ts b/modules/radarr/tools/index.ts new file mode 100644 index 0000000..d147012 --- /dev/null +++ b/modules/radarr/tools/index.ts @@ -0,0 +1,78 @@ +// radarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing radarr's own +// client. They return structured data (not pre-formatted text as hal did); the mesh serves them +// through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { RadarrClient } from "../client.js"; + +export function getRadarrTools(radarr: RadarrClient): ToolDefinition[] { + return [ + { + name: "radarr_status", + description: "Radarr status overview: version, movie count, monitored count, queue size.", + input: {}, + run: async () => { + const [status, content, queue] = await Promise.all([ + radarr.getStatus(), + radarr.getContent(), + radarr.getQueue(), + ]); + return { + app: status.appName, + version: status.version, + movies: content.length, + monitored: content.filter((c) => c.monitored).length, + queue: queue.totalRecords, + }; + }, + }, + { + name: "radarr_library", + description: "List movies from the Radarr library.", + input: { limit: { type: "number", description: "max items to return (default 50)" } }, + run: async (args) => { + const items = await radarr.getContent(args.limit ? Number(args.limit) : 50); + return { count: items.length, movies: items }; + }, + }, + { + name: "radarr_search", + description: "Search the Radarr library for movies by title (filters existing content, not indexers).", + input: { query: { type: "string", description: "the search term" } }, + run: async (args) => { + const query = String(args.query); + return { query, results: await radarr.searchContent(query) }; + }, + }, + { + name: "radarr_queue", + description: "Show the Radarr download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const queue = await radarr.getQueue(); + return { count: queue.totalRecords, items: queue.items }; + }, + }, + { + name: "radarr_calendar", + description: "Upcoming movie releases from the Radarr calendar.", + input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, + run: async (args) => { + const days = args.days ? Number(args.days) : 7; + const items = await radarr.getCalendar(days); + items.sort((a, b) => a.date.localeCompare(b.date)); + return { days, count: items.length, items }; + }, + }, + ]; +} + +// The tools exist only when Radarr is configured; without a URL and key, radarr contributes none +// rather than failing the whole runtime. +registerModuleTools("radarr", (env) => { + try { + return getRadarrTools(RadarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/radarr/tsconfig.json b/modules/radarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/radarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts new file mode 100644 index 0000000..d5f9c89 --- /dev/null +++ b/modules/sonarr/client.ts @@ -0,0 +1,127 @@ +// The Sonarr API client — sonarr's own code, living in the module (novox/hq ADR 0044). Ported from +// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a +// change to Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its +// events entrypoint import it, and nothing outside sonarr does. + +// Sonarr speaks the v3 API; its content is "series". +const API_VERSION = "v3"; +const CONTENT_ENDPOINT = "series"; +const APP_NAME = "Sonarr"; + +export interface SonarrQueueItem { + /** The queue record id — stable while the item is in the queue, so events can diff on it. */ + id: number; + title: string; + status: string; + size: string; + sizeleft: string; + timeleft?: string; +} + +export interface SonarrCalendarItem { + title: string; + date: string; + overview?: string; +} + +export interface SonarrContentItem { + title: string; + year?: number; + status?: string; + monitored: boolean; +} + +export class SonarrClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. URL and key are read from MESH_SONARR_URL and + * MESH_SONARR_API_KEY; both must be present — an unconfigured Sonarr throws rather than pretend to + * be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes + * nothing"). + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): SonarrClient { + const url = env.MESH_SONARR_URL; + const apiKey = env.MESH_SONARR_API_KEY; + if (!url || !apiKey) { + throw new Error("Sonarr not configured — set MESH_SONARR_URL and MESH_SONARR_API_KEY"); + } + return new SonarrClient(url, apiKey); + } + + private async get(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } }); + if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getStatus(): Promise<{ appName: string; version: string }> { + const data = (await this.get("system/status")) as { appName?: string; version?: string }; + return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" }; + } + + async getContent(limit?: number): Promise { + const data = await this.get(CONTENT_ENDPOINT); + const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []); + const mapped = items.map((item) => ({ + title: item.title ?? "Unknown", + year: item.year, + status: item.status, + monitored: item.monitored ?? true, + })); + return limit ? mapped.slice(0, limit) : mapped; + } + + /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ + async searchContent(term: string): Promise { + const all = await this.getContent(); + const lower = term.toLowerCase(); + return all.filter((item) => item.title.toLowerCase().includes(lower)); + } + + async getQueue(): Promise<{ totalRecords: number; items: SonarrQueueItem[] }> { + const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] }; + const records = data.records ?? []; + return { + totalRecords: data.totalRecords ?? records.length, + items: records.map((r) => ({ + id: r.id, + title: r.title ?? r.series?.title ?? "Unknown", + status: r.status ?? "unknown", + size: formatBytes(r.size ?? 0), + sizeleft: formatBytes(r.sizeleft ?? 0), + timeleft: r.timeleft, + })), + }; + } + + async getCalendar(days = 7): Promise { + const start = new Date().toISOString().split("T")[0]; + const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0]; + const data = await this.get("calendar", { start, end }); + const items: any[] = Array.isArray(data) ? data : []; + return items.map((item) => ({ + title: item.title ?? item.series?.title ?? "Unknown", + date: item.airDateUtc ?? "", + overview: item.overview?.slice(0, 150), + })); + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} diff --git a/modules/sonarr/index.ts b/modules/sonarr/index.ts new file mode 100644 index 0000000..fb03857 --- /dev/null +++ b/modules/sonarr/index.ts @@ -0,0 +1,55 @@ +// sonarr's events. The tool runtime imports this once the broker is bound. It watches the download +// queue and turns its comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.sonarr.episode.grabbed — a release entered the queue (Sonarr grabbed it) +// module.sonarr.download.completed — a release left the queue, imported. This exact routing key +// is what the plex module consumes (module.*.download.completed) +// to rescan, so the new episode becomes a visible item. +// Consumes: none. +// +// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a +// restart mid-download does not re-announce everything already in flight as freshly grabbed. + +import { emit } from "@novox/mesh-sdk/events"; +import { SonarrClient, type SonarrQueueItem } from "./client.js"; + +const sonarr = SonarrClient.fromEnv(); + +// Sonarr removes an item from the queue once it has been imported; a "warning"/"failed" status is +// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish. +const FAILED_STATUSES = new Set(["failed", "warning"]); + +const inQueue = new Map(); +let primed = false; + +async function pollQueue(): Promise { + const { items } = await sonarr.getQueue(); + const now = new Map(items.map((i) => [i.id, i])); + + if (primed) { + // Entered the queue since last look — Sonarr grabbed a release. + for (const [id, item] of now) { + if (!inQueue.has(id)) await emit("module.sonarr.episode.grabbed", { title: item.title, status: item.status }); + } + // Left the queue — imported and done, unless it was last seen failing. + for (const [id, item] of inQueue) { + if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { + await emit("module.sonarr.download.completed", { title: item.title }); + } + } + } + + inQueue.clear(); + for (const [id, item] of now) inQueue.set(id, item); + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[sonarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 30_000); + +console.log("[sonarr] watching the download queue, emitting grabs and completions"); diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index b42fb6c..aa0a983 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.sonarr.episode.grabbed", + "module.sonarr.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/sonarr/broker" + }, "listens": [ { "port": 8989, diff --git a/modules/sonarr/package.json b/modules/sonarr/package.json new file mode 100644 index 0000000..7fd642d --- /dev/null +++ b/modules/sonarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-sonarr", + "version": "0.1.0", + "description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/sonarr/tools/index.ts b/modules/sonarr/tools/index.ts new file mode 100644 index 0000000..ea5a586 --- /dev/null +++ b/modules/sonarr/tools/index.ts @@ -0,0 +1,78 @@ +// sonarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing sonarr's own +// client. They return structured data (not pre-formatted text as hal did); the mesh serves them +// through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { SonarrClient } from "../client.js"; + +export function getSonarrTools(sonarr: SonarrClient): ToolDefinition[] { + return [ + { + name: "sonarr_status", + description: "Sonarr status overview: version, series count, monitored count, queue size.", + input: {}, + run: async () => { + const [status, content, queue] = await Promise.all([ + sonarr.getStatus(), + sonarr.getContent(), + sonarr.getQueue(), + ]); + return { + app: status.appName, + version: status.version, + series: content.length, + monitored: content.filter((c) => c.monitored).length, + queue: queue.totalRecords, + }; + }, + }, + { + name: "sonarr_library", + description: "List series from the Sonarr library.", + input: { limit: { type: "number", description: "max items to return (default 50)" } }, + run: async (args) => { + const items = await sonarr.getContent(args.limit ? Number(args.limit) : 50); + return { count: items.length, series: items }; + }, + }, + { + name: "sonarr_search", + description: "Search the Sonarr library for series by title (filters existing content, not indexers).", + input: { query: { type: "string", description: "the search term" } }, + run: async (args) => { + const query = String(args.query); + return { query, results: await sonarr.searchContent(query) }; + }, + }, + { + name: "sonarr_queue", + description: "Show the Sonarr download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const queue = await sonarr.getQueue(); + return { count: queue.totalRecords, items: queue.items }; + }, + }, + { + name: "sonarr_calendar", + description: "Upcoming episode releases from the Sonarr calendar.", + input: { days: { type: "number", description: "how many days to look ahead (default 7)" } }, + run: async (args) => { + const days = args.days ? Number(args.days) : 7; + const items = await sonarr.getCalendar(days); + items.sort((a, b) => a.date.localeCompare(b.date)); + return { days, count: items.length, items }; + }, + }, + ]; +} + +// The tools exist only when Sonarr is configured; without a URL and key, sonarr contributes none +// rather than failing the whole runtime. +registerModuleTools("sonarr", (env) => { + try { + return getSonarrTools(SonarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/sonarr/tsconfig.json b/modules/sonarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/sonarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From f689b7dfa6ac97d504e126045d577b049270ae3a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:35:51 +0200 Subject: [PATCH 09/28] =?UTF-8?q?minio:=20full=20nox=20module=20=E2=80=94?= =?UTF-8?q?=20client,=20tools,=20provisioner=20and=20events=20(ADR=200044/?= =?UTF-8?q?0045/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object store, an s3-bucket provider. Client ported with no npm deps: S3 data plane over fetch + SigV4 (node:crypto), scoped access keys via the mc CLI (the admin API needs an Argon2 payload node built-ins can't make — the honest port hal also used). Tools: list buckets/objects, bucket info, presigned url. The provisioner makes a bucket + scoped key per grant and emits module.minio.bucket.created/removed (the secret stays off the bus). Typechecks; manifest parses. (Trimmed the generated self-consuming ledger: a provider need not subscribe to its own emits.) --- modules/minio/client.ts | 351 +++++++++++++++++++++++++++++ modules/minio/module.json | 15 +- modules/minio/package.json | 14 ++ modules/minio/provisioner/index.ts | 66 ++++++ modules/minio/tools/index.ts | 80 +++++++ modules/minio/tsconfig.json | 16 ++ 6 files changed, 537 insertions(+), 5 deletions(-) create mode 100644 modules/minio/client.ts create mode 100644 modules/minio/package.json create mode 100644 modules/minio/provisioner/index.ts create mode 100644 modules/minio/tools/index.ts create mode 100644 modules/minio/tsconfig.json diff --git a/modules/minio/client.ts b/modules/minio/client.ts new file mode 100644 index 0000000..5a0f29d --- /dev/null +++ b/modules/minio/client.ts @@ -0,0 +1,351 @@ +// The MinIO admin client — minio's own code, living in the module (novox/hq ADR 0044). Ported out +// of the shared hal sdk, where a change to MinIO's surface rebuilt everything; here it rebuilds only +// minio. This module's tools, its provisioner and its events entrypoint import it; nothing outside +// minio does. +// +// It speaks two planes with node built-ins only (never the `minio` npm package): +// - the S3 data plane over `fetch`, signed with AWS Signature V4 (node:crypto) — bucket and object +// operations, and presigned URLs; +// - the admin plane through the `mc` CLI (node:child_process) — scoped service accounts, whose +// creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form. +// This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env. + +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface MinioBucket { + name: string; + creationDate?: Date; +} + +export interface MinioObject { + name: string; + size: number; + lastModified: Date; + etag?: string; +} + +export interface MinioObjectStat { + size: number; + lastModified: Date; + etag?: string; + contentType?: string; +} + +export interface MinioBucketInfo { + name: string; + exists: boolean; + region: string; + /** Sampled from the first page of a listing (up to 1000 keys) — a summary, not an audit. */ + sampledObjects: number; + sampledBytes: number; +} + +/** A scoped credential a consumer receives: an access key/secret pair confined to one bucket. */ +export interface AccessKey { + accessKey: string; + secretKey: string; +} + +interface MinioOptions { + endpoint: string; + rootUser: string; + rootPassword: string; + region: string; + mcBin: string; + mcConfigDir: string; +} + +export class MinioClient { + readonly baseUrl: string; + readonly region: string; + private readonly rootUser: string; + private readonly rootPassword: string; + private readonly mcBin: string; + private readonly mcConfigDir: string; + private aliasReady = false; + + constructor(opts: MinioOptions) { + this.baseUrl = opts.endpoint.replace(/\/$/, ""); + this.region = opts.region; + this.rootUser = opts.rootUser; + this.rootPassword = opts.rootPassword; + this.mcBin = opts.mcBin; + this.mcConfigDir = opts.mcConfigDir; + } + + /** + * Build from the module's resolved environment. The endpoint and root identity come from + * MESH_MINIO_*; the password may be given inline or as a mounted secret file (the manifest mounts + * root.secret), so the provisioner container needs nothing written by hand. Throws when + * unconfigured — an object store the module cannot reach is not a usable client. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): MinioClient { + const endpoint = env.MESH_MINIO_ENDPOINT; + const rootUser = env.MESH_MINIO_ROOT_USER; + const passwordFile = env.MESH_MINIO_ROOT_PASSWORD_FILE; + const rootPassword = env.MESH_MINIO_ROOT_PASSWORD ?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined); + if (!endpoint || !rootUser || !rootPassword) { + throw new Error("minio is not configured — set MESH_MINIO_ENDPOINT, MESH_MINIO_ROOT_USER and MESH_MINIO_ROOT_PASSWORD"); + } + return new MinioClient({ + endpoint, + rootUser, + rootPassword, + region: env.MESH_MINIO_REGION ?? "us-east-1", + mcBin: env.MESH_MINIO_MC_BIN ?? "mc", + mcConfigDir: env.MESH_MINIO_MC_CONFIG ?? join(tmpdir(), ".mc-mesh"), + }); + } + + // --- S3 data plane (signed fetch) --------------------------------------- + + async listBuckets(): Promise { + const { status, text } = await this.request("GET", "/"); + if (status !== 200) throw new Error(`minio listBuckets: ${status} ${text}`); + const out: MinioBucket[] = []; + const re = /\s*([^<]+)<\/Name>\s*([^<]*)<\/CreationDate>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + out.push({ name: m[1], creationDate: m[2] ? new Date(m[2]) : undefined }); + } + return out; + } + + async bucketExists(bucket: string): Promise { + const { status } = await this.request("HEAD", `/${bucket}`); + if (status === 200) return true; + if (status === 404) return false; + throw new Error(`minio bucketExists ${bucket}: ${status}`); + } + + async createBucket(bucket: string): Promise { + const { status, text } = await this.request("PUT", `/${bucket}`); + // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. + if (status !== 200 && status !== 409) throw new Error(`minio createBucket ${bucket}: ${status} ${text}`); + } + + async removeBucket(bucket: string): Promise { + const { status, text } = await this.request("DELETE", `/${bucket}`); + // 204 removed; 404 already gone — removal is idempotent too. + if (status !== 204 && status !== 404) throw new Error(`minio removeBucket ${bucket}: ${status} ${text}`); + } + + async listObjects(bucket: string, prefix = "", recursive = false, maxKeys = 100): Promise { + const query: Record = { "list-type": "2", "max-keys": String(maxKeys) }; + if (prefix) query.prefix = prefix; + if (!recursive) query.delimiter = "/"; + const { status, text } = await this.request("GET", `/${bucket}`, query); + if (status !== 200) throw new Error(`minio listObjects ${bucket}: ${status} ${text}`); + const out: MinioObject[] = []; + for (const block of text.split("").slice(1)) { + const key = tag(block, "Key"); + if (!key) continue; + out.push({ + name: key, + size: Number(tag(block, "Size") ?? "0"), + lastModified: new Date(tag(block, "LastModified") ?? 0), + etag: tag(block, "ETag")?.replace(/"|"/g, ""), + }); + } + return out; + } + + async statObject(bucket: string, object: string): Promise { + const { status, headers } = await this.request("HEAD", `/${bucket}/${object}`); + if (status !== 200) throw new Error(`minio statObject ${bucket}/${object}: ${status}`); + const lm = headers.get("last-modified"); + return { + size: Number(headers.get("content-length") ?? "0"), + lastModified: lm ? new Date(lm) : new Date(0), + etag: headers.get("etag")?.replace(/"/g, "") ?? undefined, + contentType: headers.get("content-type") ?? undefined, + }; + } + + async bucketInfo(bucket: string): Promise { + const exists = await this.bucketExists(bucket); + if (!exists) return { name: bucket, exists: false, region: this.region, sampledObjects: 0, sampledBytes: 0 }; + const objects = await this.listObjects(bucket, "", true, 1000); + return { + name: bucket, + exists: true, + region: this.region, + sampledObjects: objects.length, + sampledBytes: objects.reduce((n, o) => n + o.size, 0), + }; + } + + /** A time-limited URL for GET (download) or PUT (upload) of one object — query-string SigV4. */ + presignedUrl(method: "GET" | "PUT", bucket: string, object: string, expires = 86400): string { + const { amzDate, dateStamp } = this.stamp(); + const scope = `${dateStamp}/${this.region}/s3/aws4_request`; + const host = new URL(this.baseUrl).host; + const params: Record = { + "X-Amz-Algorithm": "AWS4-HMAC-SHA256", + "X-Amz-Credential": `${this.rootUser}/${scope}`, + "X-Amz-Date": amzDate, + "X-Amz-Expires": String(expires), + "X-Amz-SignedHeaders": "host", + }; + const path = uriEncode(`/${bucket}/${object}`, false); + const canonicalQuery = encodeQuery(params); + const canonicalRequest = [method, path, canonicalQuery, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n"); + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); + const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); + return `${this.baseUrl}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`; + } + + // --- admin plane (mc CLI) ------------------------------------------------ + + /** + * Create a service account scoped to one bucket and return its credential. The MinIO admin REST + * API encrypts this request with a key derived (Argon2) from the root secret, which node built-ins + * cannot reproduce — so, as hal did, the module drives the `mc` CLI, which the provisioner image + * bundles. + */ + async createAccessKey(bucket: string, accessKey: string): Promise { + await this.ensureAlias(); + const secretKey = randomBytes(20).toString("hex"); + const policyPath = join(this.mcConfigDir, `policy-${accessKey}.json`); + writeFileSync(policyPath, bucketPolicy(bucket), { mode: 0o600 }); + try { + await this.mc( + "admin", "user", "svcacct", "add", "mesh", this.rootUser, + "--access-key", accessKey, + "--secret-key", secretKey, + "--policy", policyPath, + ); + } finally { + try { unlinkSync(policyPath); } catch { /* best effort */ } + } + return { accessKey, secretKey }; + } + + async removeAccessKey(accessKey: string): Promise { + await this.ensureAlias(); + await this.mc("admin", "user", "svcacct", "rm", "mesh", accessKey); + } + + private async ensureAlias(): Promise { + if (this.aliasReady) return; + await this.mc("alias", "set", "mesh", this.baseUrl, this.rootUser, this.rootPassword); + this.aliasReady = true; + } + + private async mc(...args: string[]): Promise { + const { stdout } = await execFileAsync(this.mcBin, ["--config-dir", this.mcConfigDir, ...args], { timeout: 30_000 }); + return stdout.trim(); + } + + // --- SigV4 request plumbing --------------------------------------------- + + private async request( + method: string, + path: string, + query: Record = {}, + ): Promise<{ status: number; headers: Headers; text: string }> { + const { amzDate, dateStamp } = this.stamp(); + const host = new URL(this.baseUrl).host; + const payloadHash = sha256hex(""); // no request bodies are sent on this client + const encodedPath = uriEncode(path, false); + const canonicalQuery = encodeQuery(query); + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`; + const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n"); + const scope = `${dateStamp}/${this.region}/s3/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); + const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); + const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; + const res = await fetch(url, { + method, + // `host` is set by fetch from the URL; the wire header matches what we signed. + headers: { Authorization: authorization, "x-amz-content-sha256": payloadHash, "x-amz-date": amzDate }, + }); + const text = method === "HEAD" ? "" : await res.text(); + return { status: res.status, headers: res.headers, text }; + } + + private signingKey(dateStamp: string): Buffer { + const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp); + const kRegion = hmac(kDate, this.region); + const kService = hmac(kRegion, "s3"); + return hmac(kService, "aws4_request"); + } + + private stamp(): { amzDate: string; dateStamp: string } { + const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); + return { amzDate, dateStamp: amzDate.slice(0, 8) }; + } +} + +// --- module-scoped helpers ------------------------------------------------- + +/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state: + * the provisioner recomputes the same id at teardown that it minted at creation. */ +export function accessKeyFor(consumer: string): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const digest = createHash("sha256").update(consumer).digest(); + let out = ""; + for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length]; + return out; +} + +/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */ +export function bucketFor(consumer: string): string { + const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63); + return name.length >= 3 ? name : `mesh-${name}`; +} + +function bucketPolicy(bucket: string): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [{ + Effect: "Allow", + Action: ["s3:*"], + Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`], + }], + }); +} + +function tag(xml: string, name: string): string | undefined { + const m = new RegExp(`<${name}>([^<]*)`).exec(xml); + return m ? m[1] : undefined; +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac("sha256", key).update(data, "utf8").digest(); +} + +function sha256hex(data: string): string { + return createHash("sha256").update(data, "utf8").digest("hex"); +} + +/** AWS canonical query: each key/value URI-encoded (slash included), sorted by encoded key. */ +function encodeQuery(params: Record): string { + return Object.keys(params) + .map((k) => [uriEncode(k, true), uriEncode(params[k], true)] as const) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`) + .join("&"); +} + +/** RFC 3986 URI encoding, byte-correct via UTF-8. Path callers keep "/" literal; query callers don't. */ +function uriEncode(str: string, encodeSlash: boolean): string { + let out = ""; + for (const byte of Buffer.from(str, "utf8")) { + const c = String.fromCharCode(byte); + if (/[A-Za-z0-9_.~-]/.test(c)) out += c; + else if (c === "/" && !encodeSlash) out += c; + else out += "%" + byte.toString(16).toUpperCase().padStart(2, "0"); + } + return out; +} diff --git a/modules/minio/module.json b/modules/minio/module.json index 12c285a..98b5cfa 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -10,6 +10,10 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.minio.bucket.created", + "module.minio.bucket.removed" + ], "listens": [ { "port": 9000, @@ -31,7 +35,8 @@ "s3-bucket": "/var/lib/minio/grants" }, "own-secrets": { - "root": "/var/lib/minio/root.secret" + "root": "/var/lib/minio/root.secret", + "broker": "/var/lib/minio/broker" }, "resources": [ { @@ -94,9 +99,9 @@ "network": "minio", "env": { "GRANTS": "/var/lib/minio/grants", - "MESH_OBJECTSTORE_URL": "http://minio:9000", - "MESH_OBJECTSTORE_ROOT_USER": "meshroot", - "MESH_OBJECTSTORE_ROOT_PASSWORD_FILE": "/run/secrets/root" + "MESH_MINIO_ENDPOINT": "http://minio:9000", + "MESH_MINIO_ROOT_USER": "meshroot", + "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" }, "volumes": [ "/var/lib/minio/grants:/var/lib/minio/grants:ro", @@ -104,4 +109,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/modules/minio/package.json b/modules/minio/package.json new file mode 100644 index 0000000..c1839d6 --- /dev/null +++ b/modules/minio/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-minio", + "version": "0.1.0", + "description": "minio — S3-compatible object store. Its admin client, tools, provisioner and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts new file mode 100644 index 0000000..fc999a1 --- /dev/null +++ b/modules/minio/provisioner/index.ts @@ -0,0 +1,66 @@ +// minio's provisioner — the adapter that makes minio a provider of the mesh `s3-bucket` interface +// (the name in module.json's `provides`). The reconcile loop, sealing and grant-file handling are the +// sdk harness's; this writes only the per-service half: how minio creates and removes a consumer's +// bucket and its scoped access key (novox/hq ADR 0044/0045). +// +// The `s3-bucket` interface: a consumer receives `{ endpoint, bucket, accessKey, secretKey, region }` +// — an S3 endpoint and a credential confined to its own bucket. It depends on `s3-bucket`, not on +// minio, so any S3-compatible provider could serve it. +// +// The bucket and access-key id are derived deterministically from the consumer's identity, because +// the harness hands `remove` only that identity (no stored values) — so teardown recomputes exactly +// what creation minted, with nothing to persist. The emits fire here, at the real provisioning +// points (novox/hq ADR 0046/0047); the module's events entrypoint (../index.ts) consumes them. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { MinioClient, accessKeyFor, bucketFor } from "../client.js"; + +const minio = MinioClient.fromEnv(); + +runProvisioner("s3-bucket", { + async create(grant: Grant): Promise { + const bucket = bucketFor(grant.consumer); + const accessKeyId = accessKeyFor(grant.consumer); + + if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); + // Re-mint the scoped key idempotently: drop any prior one under this id, then add fresh. + try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } + const key = await minio.createAccessKey(bucket, accessKeyId); + + await emit("module.minio.bucket.created", { + bucket, + consumer: grant.consumer, + node: grant.node, + accessKey: key.accessKey, // the secret is never put on the bus — only the credential file carries it + endpoint: minio.baseUrl, + }); + + return { + fields: { + endpoint: minio.baseUrl, + bucket, + accessKey: key.accessKey, + secretKey: key.secretKey, + region: minio.region, + }, + }; + }, + + async remove(grant: Grant): Promise { + const bucket = bucketFor(grant.consumer); + const accessKeyId = accessKeyFor(grant.consumer); + + // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if + // empty; a bucket that still holds objects is left for an operator rather than erroring on every + // reconcile tick — access is already gone, and silently deleting a consumer's data would be worse. + try { await minio.removeAccessKey(accessKeyId); } catch { /* already gone */ } + try { + await minio.removeBucket(bucket); + } catch (err) { + console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); + } + + await emit("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node }); + }, +}); diff --git a/modules/minio/tools/index.ts b/modules/minio/tools/index.ts new file mode 100644 index 0000000..dc218b5 --- /dev/null +++ b/modules/minio/tools/index.ts @@ -0,0 +1,80 @@ +// minio's tools — ported here from the shared sdk (novox/hq ADR 0044), importing minio's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. These are the +// read/inspect operations useful to an operator; creating storage for a consumer is the provisioner's +// job, not a tool's. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { MinioClient } from "../client.js"; + +export function getMinioTools(minio: MinioClient): ToolDefinition[] { + return [ + { + name: "minio_list_buckets", + description: "List every S3 bucket in the object store, with creation dates.", + input: {}, + run: async () => { + const buckets = await minio.listBuckets(); + return { + count: buckets.length, + buckets: buckets.map((b) => ({ name: b.name, createdAt: b.creationDate?.toISOString() })), + }; + }, + }, + { + name: "minio_list_objects", + description: "List objects in a bucket, optionally under a prefix.", + input: { + bucket: { type: "string", description: "the bucket name" }, + prefix: { type: "string", description: "only keys under this prefix (e.g. 'photos/')" }, + recursive: { type: "boolean", description: "descend into nested prefixes (default false)" }, + limit: { type: "number", description: "max keys to return (default 100)" }, + }, + run: async (args) => { + const bucket = String(args.bucket); + const objects = await minio.listObjects( + bucket, + args.prefix ? String(args.prefix) : "", + Boolean(args.recursive), + args.limit ? Number(args.limit) : 100, + ); + return { + bucket, + count: objects.length, + objects: objects.map((o) => ({ key: o.name, size: o.size, lastModified: o.lastModified.toISOString(), etag: o.etag })), + }; + }, + }, + { + name: "minio_bucket_info", + description: "Summary of one bucket: whether it exists, its region, and a sampled object count and size.", + input: { bucket: { type: "string", description: "the bucket name" } }, + run: async (args) => minio.bucketInfo(String(args.bucket)), + }, + { + name: "minio_presigned_url", + description: "A time-limited URL to download (GET) or upload (PUT) one object without credentials.", + input: { + bucket: { type: "string", description: "the bucket name" }, + object: { type: "string", description: "the object key" }, + method: { type: "string", description: "'GET' to download (default) or 'PUT' to upload" }, + expires: { type: "number", description: "seconds until the URL expires (default 86400 = 24h)" }, + }, + run: async (args) => { + const method = String(args.method ?? "GET").toUpperCase() === "PUT" ? "PUT" : "GET"; + const expires = args.expires ? Number(args.expires) : 86400; + const url = minio.presignedUrl(method, String(args.bucket), String(args.object), expires); + return { method, bucket: String(args.bucket), object: String(args.object), expiresInSeconds: expires, url }; + }, + }, + ]; +} + +// The tools exist only when the object store is configured and reachable; without it minio +// contributes none rather than failing the whole tool runtime. +registerModuleTools("minio", (env) => { + try { + return getMinioTools(MinioClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/minio/tsconfig.json b/modules/minio/tsconfig.json new file mode 100644 index 0000000..c2a8df0 --- /dev/null +++ b/modules/minio/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts", + "provisioner/index.ts" + ] +} \ No newline at end of file From 4d045858315acf75bf4f123d30248c14b1644fbd Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:39:03 +0200 Subject: [PATCH 10/28] =?UTF-8?q?redis,=20postgres:=20full=20nox=20provide?= =?UTF-8?q?r=20modules=20=E2=80=94=20client,=20tools,=20provisioner,=20eve?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redis provides redis-cache: a real admin client speaking RESP over a raw socket (node:net, no deps); provisioner makes a keyspace-scoped ACL user per grant. postgres provides postgres-database: admin client executing through psql (the consistent shell-out port, like minio's mc), full DDL for create/drop database+ role, a CSV row parser, read-only query tool. Both emit module...provisioned/.deprovisioned from the provisioner. Both typecheck; manifests parse. --- modules/postgres/client.ts | 193 +++++++++++++++++++++ modules/postgres/module.json | 7 +- modules/postgres/package.json | 14 ++ modules/postgres/provisioner/index.ts | 64 +++++++ modules/postgres/tools/index.ts | 44 +++++ modules/postgres/tsconfig.json | 12 ++ modules/redis/client.ts | 232 ++++++++++++++++++++++++++ modules/redis/module.json | 7 +- modules/redis/package.json | 14 ++ modules/redis/provisioner/index.ts | 57 +++++++ modules/redis/tools/index.ts | 52 ++++++ modules/redis/tsconfig.json | 12 ++ 12 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 modules/postgres/client.ts create mode 100644 modules/postgres/package.json create mode 100644 modules/postgres/provisioner/index.ts create mode 100644 modules/postgres/tools/index.ts create mode 100644 modules/postgres/tsconfig.json create mode 100644 modules/redis/client.ts create mode 100644 modules/redis/package.json create mode 100644 modules/redis/provisioner/index.ts create mode 100644 modules/redis/tools/index.ts create mode 100644 modules/redis/tsconfig.json diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts new file mode 100644 index 0000000..aae7c60 --- /dev/null +++ b/modules/postgres/client.ts @@ -0,0 +1,193 @@ +// postgres's admin client — postgres's own code, living in the module (novox/hq ADR 0044). Both this +// module's tools and its provisioner import it, and nothing outside postgres does. +// +// SQL is executed through `psql`, not a wire-protocol driver: the module may take NO npm dependency +// beyond @novox/mesh-sdk, and hand-rolling startup + SCRAM auth + the query protocol is more surface +// than this should carry — so it shells out to the client the postgres tools ship, the same way +// minio drives itself through `mc` and mailu through doveadm. One boundary, `query()`, and every +// method is built on it. + +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export interface QueryResult { + /** The command tag postgres returns, e.g. "SELECT", "CREATE DATABASE". */ + readonly command: string; + readonly rows: Record[]; +} + +export interface PgConn { + readonly host: string; + readonly port: number; + readonly user: string; + readonly password: string; +} + + +export class PostgresClient { + constructor(private readonly conn: PgConn) {} + + /** + * Build from the module's resolved environment. Reads MESH_POSTGRES_* first (the documented + * names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner + * container. Throws if it cannot find a host and an admin password. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PostgresClient { + const url = env.MESH_PROVISION_POSTGRES ? safeUrl(env.MESH_PROVISION_POSTGRES) : undefined; + const host = env.MESH_POSTGRES_HOST ?? url?.hostname; + const port = Number(env.MESH_POSTGRES_PORT ?? url?.port ?? "5432") || 5432; + const user = env.MESH_POSTGRES_USER ?? url?.username ?? "postgres"; + const password = env.MESH_POSTGRES_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); + if (!host || !password) { + throw new Error("postgres host or admin password is not set — postgres's own code cannot reach the server"); + } + return new PostgresClient({ host, port, user, password }); + } + + get host(): string { + return this.conn.host; + } + + get port(): number { + return this.conn.port; + } + + /** Execute SQL against a database as the admin and return its rows, through `psql` (see header). */ + async query(sql: string, database = "postgres"): Promise { + // Executed through `psql`, the way minio drives itself through `mc` and mailu through doveadm: + // node has no postgres wire client without an npm dependency, and the module owns its own code + // (ADR 0044), so it shells out to the client the postgres tools ship. CSV so the rows come back + // structured; ON_ERROR_STOP so a failed statement is an error here, not a success with a warning. + const { stdout } = await run( + "psql", + ["-h", this.conn.host, "-p", String(this.conn.port), "-U", this.conn.user, "-d", database, + "-v", "ON_ERROR_STOP=1", "--no-psqlrc", "--csv", "-c", sql], + { env: { ...process.env, PGPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, + ); + const rows = parseCsvRows(stdout); + return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows }; + } + + /** + * Create a login role and a database it owns, idempotently. The DDL is the full, correct shape, run through query(). Extensions can be requested per + * database and are created as the admin (a plain owner cannot install most of them). + */ + async createDatabaseAndRole(database: string, role: string, password: string): Promise { + const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role)); + if (roles.rows.length === 0) { + await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + } else { + await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + } + const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database)); + if (dbs.rows.length === 0) { + await this.query(`CREATE DATABASE ${ident(database)} OWNER ${ident(role)}`); + } + await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`); + } + + /** Drop a database and its owning role, idempotently, after evicting live connections. */ + async dropDatabaseAndRole(database: string, role: string): Promise { + await this.query( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " + + literal(database) + " AND pid <> pg_backend_pid()", + ); + await this.query(`DROP DATABASE IF EXISTS ${ident(database)}`); + await this.query(`DROP ROLE IF EXISTS ${ident(role)}`); + } + + /** List the non-template databases, with size, for the postgres_list_databases tool. */ + async listDatabases(): Promise<{ name: string; sizeBytes: number }[]> { + const res = await this.query( + "SELECT datname, pg_database_size(datname) AS size FROM pg_database WHERE datistemplate = false ORDER BY datname", + ); + return res.rows.map((r) => ({ name: String(r.datname), sizeBytes: Number(r.size) })); + } + + /** Run a read-only SQL statement against a named database, for the postgres_query tool. */ + async readOnlyQuery(database: string, sql: string): Promise { + // The read-only guarantee is a wrapping transaction the server honours. + return this.query(`BEGIN TRANSACTION READ ONLY; ${sql}; ROLLBACK;`, database); + } +} + +/** Generate a URL-safe password. */ +export function generatePassword(): string { + return randomBytes(24).toString("base64url"); +} + +/** Quote a SQL identifier (double quotes, doubled internal quotes). */ +export function ident(id: string): string { + return '"' + id.replace(/"/g, '""') + '"'; +} + +/** Quote a SQL string literal (single quotes, doubled internal quotes). */ +export function literal(val: string): string { + return "'" + val.replace(/'/g, "''") + "'"; +} + +function readSecretFile(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +function safeUrl(raw: string): URL | undefined { + try { + return new URL(raw); + } catch { + return undefined; + } +} + +/** Parse psql --csv output into row objects. RFC-4180: fields may be quoted, an embedded quote is + * doubled, and a quoted field may span newlines. Empty output (a DDL statement) yields no rows. */ +function parseCsvRows(csv: string): Record[] { + const records = parseCsv(csv); + if (records.length === 0) return []; + const [header, ...rows] = records; + return rows.map((cells) => { + const row: Record = {}; + header.forEach((name, i) => (row[name] = cells[i] ?? null)); + return row; + }); +} + +function parseCsv(text: string): string[][] { + const records: string[][] = []; + let field = ""; + let record: string[] = []; + let inQuotes = false; + let started = false; + const endRecord = (): void => { + if (started || field.length > 0 || record.length > 0) { + record.push(field); + records.push(record); + } + field = ""; + record = []; + started = false; + }; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; + } else field += c; + } else if (c === '"') { inQuotes = true; started = true; } + else if (c === ",") { record.push(field); field = ""; started = true; } + else if (c === "\n" || c === "\r") { + if (c === "\r" && text[i + 1] === "\n") i++; + endRecord(); + } else { field += c; started = true; } + } + endRecord(); + return records; +} diff --git a/modules/postgres/module.json b/modules/postgres/module.json index e2d8f03..ec68156 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -10,6 +10,10 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.postgres.database.provisioned", + "module.postgres.database.deprovisioned" + ], "listens": [ { "port": 5432, @@ -28,7 +32,8 @@ "postgres-database": "/var/lib/postgres/grants" }, "own-secrets": { - "superuser": "/var/lib/postgres/superuser.secret" + "superuser": "/var/lib/postgres/superuser.secret", + "broker": "/var/lib/postgres/broker" }, "resources": [ { diff --git a/modules/postgres/package.json b/modules/postgres/package.json new file mode 100644 index 0000000..5412054 --- /dev/null +++ b/modules/postgres/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-postgres", + "version": "0.1.0", + "description": "postgres — provides the mesh postgres-database interface. Its client, provisioner, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts new file mode 100644 index 0000000..3368b38 --- /dev/null +++ b/modules/postgres/provisioner/index.ts @@ -0,0 +1,64 @@ +// postgres's provisioner — the adapter that makes postgres a provider of the mesh +// `postgres-database` interface. The watching, sealing and grant-file handling are the sdk +// harness's; this writes only the per-service half: how postgres creates and removes a consumer's +// database + owning role (novox/hq ADR 0044/0045). +// +// The `postgres-database` interface: a consumer receives `{ host, port, database, user, password }` +// and connects to a database only it owns. +// +// Identity (the database and role names) is derived from `grant.consumer` alone — never from +// `grant.values` — because on removal the harness hands the adapter a grant carrying only the +// consumer. Deriving from the consumer keeps create and remove naming the same resource. +// +// The credential is composed here and returned; the DDL runs through PostgresClient.query(), which +// is the module's one pending boundary (see client.ts). Until that boundary is backed, create() +// surfaces the TODO honestly rather than sealing a credential for a database that was never made. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { PostgresClient, generatePassword } from "../client.js"; + +const postgres = PostgresClient.fromEnv(); + +/** A stable postgres identifier for a consumer: lowercase [a-z0-9_], never starting with a digit. */ +function identity(consumer: string): string { + let safe = consumer.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/^_+|_+$/g, ""); + if (safe === "" ) safe = "consumer"; + if (/^[0-9]/.test(safe)) safe = "_" + safe; + return safe.slice(0, 63); // postgres identifier limit +} + +/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[provisioner:postgres-database] emit ${type} failed: ${err}`); + } +} + +runProvisioner("postgres-database", { + async create(grant: Grant): Promise { + const database = identity(grant.consumer); + const user = database; + const password = generatePassword(); + await postgres.createDatabaseAndRole(database, user, password); + await announce("module.postgres.database.provisioned", { consumer: grant.consumer, database, user }); + return { + fields: { + host: postgres.host, + port: String(postgres.port), + database, + user, + password, + }, + }; + }, + + async remove(grant: Grant): Promise { + const database = identity(grant.consumer); + const user = database; + await postgres.dropDatabaseAndRole(database, user); + await announce("module.postgres.database.deprovisioned", { consumer: grant.consumer, database }); + }, +}); diff --git a/modules/postgres/tools/index.ts b/modules/postgres/tools/index.ts new file mode 100644 index 0000000..31ad53b --- /dev/null +++ b/modules/postgres/tools/index.ts @@ -0,0 +1,44 @@ +// postgres's tools — postgres's own code (novox/hq ADR 0044), importing postgres's own client. They +// return structured data; the mesh serves them through the sdk's tool harness. Both call through +// PostgresClient.query(), the module's one pending execution boundary (see client.ts): the tool +// shapes are fixed and correct, and surface the TODO honestly until that boundary is backed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PostgresClient } from "../client.js"; + +export function getPostgresTools(postgres: PostgresClient): ToolDefinition[] { + return [ + { + name: "postgres_list_databases", + description: "List the databases on the postgres server, with their on-disk size.", + input: {}, + run: async () => ({ databases: await postgres.listDatabases() }), + }, + { + name: "postgres_query", + description: "Run a read-only SQL query against a named database (wrapped in a read-only transaction).", + input: { + database: { type: "string", description: "the database to query" }, + sql: { type: "string", description: "the SELECT (or other read-only) statement" }, + }, + run: async (args) => { + const database = String(args.database ?? ""); + const sql = String(args.sql ?? ""); + if (!database) throw new Error("postgres_query: database is required"); + if (!sql) throw new Error("postgres_query: sql is required"); + const result = await postgres.readOnlyQuery(database, sql); + return { database, command: result.command, rows: result.rows }; + }, + }, + ]; +} + +// The tools exist only when the server can be reached from the environment; without it, postgres +// contributes none rather than failing the whole tool runtime. +registerModuleTools("postgres", (env) => { + try { + return getPostgresTools(PostgresClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/postgres/tsconfig.json b/modules/postgres/tsconfig.json new file mode 100644 index 0000000..51f4046 --- /dev/null +++ b/modules/postgres/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] +} diff --git a/modules/redis/client.ts b/modules/redis/client.ts new file mode 100644 index 0000000..cb17695 --- /dev/null +++ b/modules/redis/client.ts @@ -0,0 +1,232 @@ +// redis's admin client — redis's own code, living in the module (novox/hq ADR 0044). It speaks +// RESP directly over a raw TCP socket (node:net) so the module carries no npm dependency beyond +// @novox/mesh-sdk: no redis driver, no redis-cli in the image. Both this module's tools and its +// provisioner import it, and nothing outside redis does. +// +// The client keeps a single connection and runs one command at a time in FIFO order — enough for +// an admin surface (PING, INFO, ACL SETUSER/DELUSER, arbitrary commands). Replies come back in the +// order requests were sent, which is what the queue below relies on. + +import { createConnection, type Socket } from "node:net"; +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; + +/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */ +export type RespValue = string | number | null | RespValue[]; + +interface Waiter { + resolve: (v: RespValue) => void; + reject: (e: Error) => void; +} + +export class RedisClient { + private socket: Socket | null = null; + private buffer = Buffer.alloc(0); + private queue: Waiter[] = []; + + constructor( + readonly host: string, + readonly port: number, + private readonly password: string, + private readonly username = "default", + ) {} + + /** + * Build from the module's resolved environment. Reads MESH_REDIS_* first (the documented + * names), falling back to the MESH_PROVISION_* keys the manifest already sets on the + * provisioner container so the module runs unchanged there. Throws if it cannot find a host and + * an admin password — the right failure, because without them nothing it does can work. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): RedisClient { + const endpoint = env.MESH_PROVISION_REDIS ?? ""; // "host:port" + const host = env.MESH_REDIS_HOST ?? (endpoint ? endpoint.split(":")[0] : undefined); + const port = Number(env.MESH_REDIS_PORT ?? (endpoint.includes(":") ? endpoint.split(":")[1] : "") ?? "6379") || 6379; + const username = env.MESH_REDIS_USERNAME ?? "default"; + const password = env.MESH_REDIS_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); + if (!host || !password) { + throw new Error("redis host or admin password is not set — redis's own code cannot reach the server"); + } + return new RedisClient(host, port, password, username); + } + + /** Open the connection (idempotent) and authenticate. Reconnects if the socket has gone away. */ + async connect(): Promise { + if (this.socket && !this.socket.destroyed) return; + await new Promise((resolve, reject) => { + const sock = createConnection({ host: this.host, port: this.port }); + this.socket = sock; + sock.on("data", (chunk: Buffer | string) => this.onData(typeof chunk === "string" ? Buffer.from(chunk) : chunk)); + sock.on("error", (err) => { + this.failAll(err); + reject(err); + }); + sock.on("close", () => this.failAll(new Error("redis connection closed"))); + sock.once("connect", () => resolve()); + }); + if (this.password) { + const args = this.username && this.username !== "default" + ? ["AUTH", this.username, this.password] + : ["AUTH", this.password]; + await this.send(args); + } + } + + /** Run one command and return its parsed reply. A RESP error reply rejects the promise. */ + async command(...args: (string | number)[]): Promise { + await this.connect(); + return this.send(args.map(String)); + } + + async ping(): Promise { + return (await this.command("PING")) === "PONG"; + } + + /** Server INFO, returned both raw and parsed into the flat key/value map redis emits. */ + async info(section?: string): Promise<{ raw: string; fields: Record }> { + const raw = String((await this.command("INFO", ...(section ? [section] : []))) ?? ""); + const fields: Record = {}; + for (const line of raw.split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue; + const idx = line.indexOf(":"); + if (idx > 0) fields[line.slice(0, idx)] = line.slice(idx + 1); + } + return { raw, fields }; + } + + /** + * Create (or reset to a known state) an ACL user scoped to one keyspace prefix. `reset` first + * clears any prior rules so the call is idempotent, then the user is enabled with the given + * password, confined to keys matching `:*`, and allowed the ordinary command set. The + * consumer connects as this user and can touch nothing outside its prefix. + */ + async createAclUser(username: string, password: string, keyspacePrefix: string): Promise { + await this.command("ACL", "SETUSER", username, "reset", "on", `>${password}`, `~${keyspacePrefix}:*`, "+@all"); + } + + async deleteAclUser(username: string): Promise { + await this.command("ACL", "DELUSER", username); + } + + close(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } + + // --- connection plumbing --- + + /** Send an already-connected command, queuing its reply against the FIFO of in-flight requests. */ + private send(args: string[]): Promise { + const sock = this.socket; + if (!sock) return Promise.reject(new Error("redis socket is not connected")); + return new Promise((resolve, reject) => { + this.queue.push({ resolve, reject }); + sock.write(encodeCommand(args)); + }); + } + + /** Feed incoming bytes to the parser, resolving as many queued replies as the buffer completes. */ + private onData(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.queue.length > 0) { + let parsed: { value: RespValue | Error; next: number } | null; + try { + parsed = parseReply(this.buffer, 0); + } catch (err) { + const waiter = this.queue.shift(); + waiter?.reject(err instanceof Error ? err : new Error(String(err))); + this.buffer = Buffer.alloc(0); + continue; + } + if (!parsed) break; // one full reply not yet in the buffer + this.buffer = this.buffer.subarray(parsed.next); + const waiter = this.queue.shift(); + if (!waiter) break; + if (parsed.value instanceof Error) waiter.reject(parsed.value); + else waiter.resolve(parsed.value); + } + } + + /** A socket error or close fails every pending command and forces a fresh connect next time. */ + private failAll(err: Error): void { + const pending = this.queue; + this.queue = []; + for (const waiter of pending) waiter.reject(err); + this.buffer = Buffer.alloc(0); + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } +} + +/** Generate a URL-safe password with no RESP-hostile characters. */ +export function generatePassword(): string { + return randomBytes(24).toString("base64url"); +} + +function readSecretFile(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +// --- RESP wire format --- + +/** Encode a command as a RESP array of bulk strings. */ +function encodeCommand(args: string[]): Buffer { + let head = `*${args.length}\r\n`; + for (const a of args) head += `$${Buffer.byteLength(a)}\r\n${a}\r\n`; + return Buffer.from(head, "utf8"); +} + +/** + * Parse one RESP reply starting at `i`. Returns the value and the offset just past it, or null if + * the buffer does not yet hold a complete reply (the caller waits for more bytes). A `-` error + * reply is returned as an Error value; the client turns that into a rejection. + */ +function parseReply(buf: Buffer, i: number): { value: RespValue | Error; next: number } | null { + if (i >= buf.length) return null; + const type = buf[i]; + const eol = buf.indexOf("\r\n", i + 1); + if (eol === -1) return null; // header line not yet complete + const line = buf.toString("utf8", i + 1, eol); + const after = eol + 2; + switch (type) { + case 0x2b: // '+' simple string + return { value: line, next: after }; + case 0x2d: // '-' error + return { value: new Error(line), next: after }; + case 0x3a: // ':' integer + return { value: Number(line), next: after }; + case 0x24: { + // '$' bulk string + const len = Number(line); + if (len === -1) return { value: null, next: after }; + const end = after + len; + if (buf.length < end + 2) return null; // body not fully arrived + return { value: buf.toString("utf8", after, end), next: end + 2 }; + } + case 0x2a: { + // '*' array + const count = Number(line); + if (count === -1) return { value: null, next: after }; + const arr: RespValue[] = []; + let cursor = after; + for (let k = 0; k < count; k++) { + const el = parseReply(buf, cursor); + if (!el) return null; // array not fully arrived + if (el.value instanceof Error) throw el.value; + arr.push(el.value); + cursor = el.next; + } + return { value: arr, next: cursor }; + } + default: + return { value: new Error(`unexpected RESP type byte 0x${type.toString(16)}`), next: after }; + } +} diff --git a/modules/redis/module.json b/modules/redis/module.json index 5f223c6..3acb6c9 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -10,6 +10,10 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.redis.cache.provisioned", + "module.redis.cache.deprovisioned" + ], "serves": { "redis-cache": {} }, @@ -20,7 +24,8 @@ "redis-cache": "/var/lib/redis-module/grants" }, "own-secrets": { - "default": "/var/lib/redis-module/default.secret" + "default": "/var/lib/redis-module/default.secret", + "broker": "/var/lib/redis-module/broker" }, "listens": [ { diff --git a/modules/redis/package.json b/modules/redis/package.json new file mode 100644 index 0000000..3032fb0 --- /dev/null +++ b/modules/redis/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-redis", + "version": "0.1.0", + "description": "redis — provides the mesh redis-cache interface. Its RESP client, provisioner, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts new file mode 100644 index 0000000..f8d7df7 --- /dev/null +++ b/modules/redis/provisioner/index.ts @@ -0,0 +1,57 @@ +// redis's provisioner — the adapter that makes redis a provider of the mesh `redis-cache` +// interface. The watching, sealing and grant-file handling are the sdk harness's; this writes only +// the per-service half: how redis creates and removes a per-consumer cache (novox/hq ADR 0044/0045). +// +// The `redis-cache` interface: a consumer receives `{ host, port, username, password, +// keyspacePrefix }` and stores its keys under `:*`, isolated from every other +// consumer by an ACL user scoped to exactly that prefix. +// +// Identity (the ACL username and keyspace) is derived from `grant.consumer` alone — never from +// `grant.values` — because on removal the harness hands the adapter a grant carrying only the +// consumer. Deriving from the consumer keeps create and remove naming the same resource. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { RedisClient, generatePassword } from "../client.js"; + +const redis = RedisClient.fromEnv(); + +/** A stable, ACL-safe identity for a consumer: only [A-Za-z0-9_.-], never empty. */ +function identity(consumer: string): string { + const safe = consumer.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^_+|_+$/g, ""); + return safe || "consumer"; +} + +/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[provisioner:redis-cache] emit ${type} failed: ${err}`); + } +} + +runProvisioner("redis-cache", { + async create(grant: Grant): Promise { + const username = identity(grant.consumer); + const keyspacePrefix = username; + const password = generatePassword(); + await redis.createAclUser(username, password, keyspacePrefix); + await announce("module.redis.cache.provisioned", { consumer: grant.consumer, username, keyspacePrefix }); + return { + fields: { + host: redis.host, + port: String(redis.port), + username, + password, + keyspacePrefix, + }, + }; + }, + + async remove(grant: Grant): Promise { + const username = identity(grant.consumer); + await redis.deleteAclUser(username); + await announce("module.redis.cache.deprovisioned", { consumer: grant.consumer, username }); + }, +}); diff --git a/modules/redis/tools/index.ts b/modules/redis/tools/index.ts new file mode 100644 index 0000000..239758c --- /dev/null +++ b/modules/redis/tools/index.ts @@ -0,0 +1,52 @@ +// redis's tools — redis's own code (novox/hq ADR 0044), importing redis's own RESP client. They +// return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { RedisClient, type RespValue } from "../client.js"; + +export function getRedisTools(redis: RedisClient): ToolDefinition[] { + return [ + { + name: "redis_ping", + description: "Check that the redis server is reachable and responding (PONG).", + input: {}, + run: async () => ({ ok: await redis.ping() }), + }, + { + name: "redis_info", + description: "Redis server info — memory, clients, keyspace. Omit section for everything.", + input: { section: { type: "string", description: "an INFO section, e.g. 'memory', 'clients', 'keyspace'" } }, + run: async (args) => { + const { raw, fields } = await redis.info(args.section ? String(args.section) : undefined); + return { fields, raw }; + }, + }, + { + name: "redis_command", + description: "Run an arbitrary redis command, e.g. 'DBSIZE', 'GET key', 'ACL LIST'. Admin surface.", + input: { command: { type: "string", description: "the command and its arguments, space-separated" } }, + run: async (args) => { + const parts = tokenize(String(args.command ?? "")); + if (parts.length === 0) throw new Error("redis_command: empty command"); + const reply: RespValue = await redis.command(...parts); + return { command: parts.join(" "), reply }; + }, + }, + ]; +} + +/** Split a command line into arguments, honouring double-quoted spans. */ +function tokenize(command: string): string[] { + const matches = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + return matches.map((p) => p.replace(/^"|"$/g, "")); +} + +// The tools exist only when the server can be reached from the environment; without it, redis +// contributes none rather than failing the whole tool runtime. +registerModuleTools("redis", (env) => { + try { + return getRedisTools(RedisClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/redis/tsconfig.json b/modules/redis/tsconfig.json new file mode 100644 index 0000000..51f4046 --- /dev/null +++ b/modules/redis/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] +} From d7ceb05e537529992b24fa5a87c069b18478454e Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:39:50 +0200 Subject: [PATCH 11/28] jackett, bazarr, ombi: full nox modules (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported their real HTTP APIs (hal carried no client for these). jackett: an indexer proxy — tools only (list indexers, search), no events, no broker account, because it answers queries and has no timeline to observe. bazarr: subtitle tools + emits module.bazarr.subtitle.downloaded (poll history, diff). ombi: request tools + emits request.created/.approved. All pure emitters (their decisions originate here). Typecheck; manifests parse. --- modules/bazarr/client.ts | 155 +++++++++++++++++++++++++++++++++ modules/bazarr/index.ts | 47 ++++++++++ modules/bazarr/module.json | 6 ++ modules/bazarr/package.json | 14 +++ modules/bazarr/tools/index.ts | 57 ++++++++++++ modules/bazarr/tsconfig.json | 12 +++ modules/jackett/client.ts | 89 +++++++++++++++++++ modules/jackett/package.json | 14 +++ modules/jackett/tools/index.ts | 48 ++++++++++ modules/jackett/tsconfig.json | 12 +++ modules/ombi/client.ts | 104 ++++++++++++++++++++++ modules/ombi/index.ts | 58 ++++++++++++ modules/ombi/module.json | 7 ++ modules/ombi/package.json | 14 +++ modules/ombi/tools/index.ts | 46 ++++++++++ modules/ombi/tsconfig.json | 12 +++ 16 files changed, 695 insertions(+) create mode 100644 modules/bazarr/client.ts create mode 100644 modules/bazarr/index.ts create mode 100644 modules/bazarr/package.json create mode 100644 modules/bazarr/tools/index.ts create mode 100644 modules/bazarr/tsconfig.json create mode 100644 modules/jackett/client.ts create mode 100644 modules/jackett/package.json create mode 100644 modules/jackett/tools/index.ts create mode 100644 modules/jackett/tsconfig.json create mode 100644 modules/ombi/client.ts create mode 100644 modules/ombi/index.ts create mode 100644 modules/ombi/package.json create mode 100644 modules/ombi/tools/index.ts create mode 100644 modules/ombi/tsconfig.json diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts new file mode 100644 index 0000000..b84f6c5 --- /dev/null +++ b/modules/bazarr/client.ts @@ -0,0 +1,155 @@ +// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0044). Bazarr +// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still +// missing subtitles, searches providers for them, and records what it downloaded. This client +// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it. + +export interface WantedSubtitle { + kind: "episode" | "movie"; + title: string; // series + episode, or movie title + path?: string; + seriesId?: number; // sonarr series id (episodes) + episodeId?: number; // sonarr episode id (episodes) + radarrId?: number; // radarr movie id (movies) + missing: string[]; // language names still missing +} + +export interface ProviderSubtitle { + provider: string; + language: string; + hearingImpaired: boolean; + forced: boolean; + score?: number; + release?: string; + subtitle: string; // the opaque token Bazarr uses to download this exact result +} + +export interface HistoryEntry { + kind: "episode" | "movie"; + id: string; // stable dedup key across polls + title: string; + language?: string; + provider?: string; + path?: string; + timestamp?: string; + description?: string; +} + +export class BazarrClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key + * there is nothing to talk to, so this throws rather than run half-configured. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { + const url = env.MESH_BAZARR_URL; + const apiKey = env.MESH_BAZARR_API_KEY; + if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL"); + if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY"); + return new BazarrClient(url, apiKey); + } + + private async request(method: string, path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}/api${path}`); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } }); + if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`); + // Downloads/patches return an empty body; only GETs carry JSON. + const text = await res.text(); + return text ? JSON.parse(text) : {}; + } + + private get(path: string, params?: Record): Promise { + return this.request("GET", path, params); + } + + private languageNames(missing: any[]): string[] { + return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean); + } + + /** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */ + async getWanted(limit = 50): Promise { + const [eps, movies] = await Promise.all([ + this.get("/episodes/wanted", { start: "0", length: String(limit) }), + this.get("/movies/wanted", { start: "0", length: String(limit) }), + ]); + const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({ + kind: "episode" as const, + title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(), + path: e.path, + seriesId: e.sonarrSeriesId, + episodeId: e.sonarrEpisodeId, + missing: this.languageNames(e.missing_subtitles), + })); + const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({ + kind: "movie" as const, + title: m.title ?? "Unknown", + path: m.path, + radarrId: m.radarrId, + missing: this.languageNames(m.missing_subtitles), + })); + return [...episodes, ...films]; + } + + /** Ask providers what subtitles are available for one wanted episode — a manual search. */ + async searchEpisode(episodeId: number): Promise { + const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) }); + return this.mapProviderResults(raw); + } + + /** Ask providers what subtitles are available for one movie — a manual search. */ + async searchMovie(radarrId: number): Promise { + const raw = await this.get("/providers/movies", { radarrid: String(radarrId) }); + return this.mapProviderResults(raw); + } + + private mapProviderResults(raw: any): ProviderSubtitle[] { + const list = Array.isArray(raw) ? raw : (raw?.data ?? []); + return list.map((r: any) => ({ + provider: r.provider, + language: r.language?.name ?? r.language ?? "unknown", + hearingImpaired: Boolean(r.hearing_impaired ?? r.hi), + forced: Boolean(r.forced), + score: r.score, + release: r.release_info?.[0] ?? r.release_info, + subtitle: r.subtitle, + })); + } + + /** Recent subtitle-download history, episodes and movies together, newest first. Each entry + * carries a stable id so the events poller can tell a fresh download from one already seen. */ + async getHistory(limit = 40): Promise { + const [eps, movies] = await Promise.all([ + this.get("/episodes/history", { start: "0", length: String(limit) }), + this.get("/movies/history", { start: "0", length: String(limit) }), + ]); + const key = (kind: string, r: any): string => + `${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`; + const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({ + kind: "episode" as const, + id: key("episode", r), + title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(), + language: r.language?.name ?? r.language, + provider: r.provider, + path: r.subtitles_path, + timestamp: r.timestamp, + description: r.description, + })); + const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({ + kind: "movie" as const, + id: key("movie", r), + title: r.title ?? "Unknown", + language: r.language?.name ?? r.language, + provider: r.provider, + path: r.subtitles_path, + timestamp: r.timestamp, + description: r.description, + })); + return [...episodes, ...films]; + } +} diff --git a/modules/bazarr/index.ts b/modules/bazarr/index.ts new file mode 100644 index 0000000..d915972 --- /dev/null +++ b/modules/bazarr/index.ts @@ -0,0 +1,47 @@ +// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely +// observable thing is a subtitle arriving: it works away in the background, searching providers for +// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth +// announcing to the mesh. +// +// Emits (novox/hq ADR 0046/0047): +// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie +// +// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's +// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter. +// +// The event is observation-based: poll history and diff. Primed silently on the first look, or a +// restart would re-announce the whole recent history as freshly downloaded. + +import { emit } from "@novox/mesh-sdk/events"; +import { BazarrClient } from "./client.js"; + +const bazarr = BazarrClient.fromEnv(); + +const seen = new Set(); +let primed = false; +async function pollHistory(): Promise { + const entries = await bazarr.getHistory(40); + for (const entry of entries) { + if (seen.has(entry.id)) continue; + if (primed) { + await emit("module.bazarr.subtitle.downloaded", { + kind: entry.kind, + title: entry.title, + language: entry.language, + provider: entry.provider, + path: entry.path, + }); + } + seen.add(entry.id); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollHistory, 60_000); + +console.log("[bazarr] watching subtitle-download history"); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index 90e6ac2..050700b 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.bazarr.subtitle.downloaded" + ], + "own-secrets": { + "broker": "/var/lib/bazarr/broker" + }, "listens": [ { "port": 6767, diff --git a/modules/bazarr/package.json b/modules/bazarr/package.json new file mode 100644 index 0000000..20c3e13 --- /dev/null +++ b/modules/bazarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-bazarr", + "version": "0.1.0", + "description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/bazarr/tools/index.ts b/modules/bazarr/tools/index.ts new file mode 100644 index 0000000..3e0a574 --- /dev/null +++ b/modules/bazarr/tools/index.ts @@ -0,0 +1,57 @@ +// bazarr's tools — its own code (novox/hq ADR 0044), importing bazarr's client. They return +// structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { BazarrClient } from "../client.js"; + +export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] { + return [ + { + name: "bazarr_wanted", + description: "Episodes and movies still missing subtitles, with the languages each still needs.", + input: { limit: { type: "number", description: "max items per kind (default 50)" } }, + run: async (args) => { + const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50); + return { count: wanted.length, wanted }; + }, + }, + { + name: "bazarr_search_subtitles", + description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.", + input: { + episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" }, + radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" }, + }, + run: async (args) => { + if (args.episodeId !== undefined) { + const results = await bazarr.searchEpisode(Number(args.episodeId)); + return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results }; + } + if (args.radarrId !== undefined) { + const results = await bazarr.searchMovie(Number(args.radarrId)); + return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results }; + } + throw new Error("pass either episodeId or radarrId"); + }, + }, + { + name: "bazarr_history", + description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.", + input: { limit: { type: "number", description: "max entries per kind (default 40)" } }, + run: async (args) => { + const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40); + return { count: history.length, history }; + }, + }, + ]; +} + +// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than +// failing the whole runtime. +registerModuleTools("bazarr", (env) => { + try { + return getBazarrTools(BazarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/bazarr/tsconfig.json b/modules/bazarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/bazarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts new file mode 100644 index 0000000..bf52da9 --- /dev/null +++ b/modules/jackett/client.ts @@ -0,0 +1,89 @@ +// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0044). Jackett is +// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client +// talks its /api/v2.0 REST API, and only jackett's tools import it. + +export interface JackettIndexer { + id: string; + name: string; + type: string; // "public" | "private" | "semi-public" + configured: boolean; + siteLink?: string; + lastError?: string; +} + +export interface JackettResult { + title: string; + tracker: string; + category?: string; + size: number; + seeders?: number; + peers?: number; + publishDate?: string; + link?: string; +} + +export class JackettClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and + * the key must be present — without them there is nothing to talk to, so this throws and the + * module contributes no tools rather than failing half-configured. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { + const url = env.MESH_JACKETT_URL; + const apiKey = env.MESH_JACKETT_API_KEY; + if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); + if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY"); + return new JackettClient(url, apiKey); + } + + private async get(path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}${path}`); + url.searchParams.set("apikey", this.apiKey); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Jackett API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + /** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */ + async getIndexers(configuredOnly = true): Promise { + const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" }); + const list = Array.isArray(raw) ? raw : []; + return list.map((i: any) => ({ + id: i.id, + name: i.name, + type: i.type, + configured: i.configured ?? false, + siteLink: i.site_link, + lastError: i.last_error || undefined, + })); + } + + /** + * A Torznab search across one indexer, or the "all" aggregate. Jackett returns a normalised JSON + * result set regardless of the underlying tracker, which is the whole point of the proxy. + */ + async search(query: string, indexer = "all", limit = 25): Promise { + const raw = await this.get(`/api/v2.0/indexers/${encodeURIComponent(indexer)}/results`, { Query: query }); + const results = Array.isArray(raw?.Results) ? raw.Results : []; + return results.slice(0, limit).map((r: any) => ({ + title: r.Title, + tracker: r.Tracker ?? r.TrackerId ?? "unknown", + category: Array.isArray(r.CategoryDesc) ? r.CategoryDesc.join(", ") : r.CategoryDesc, + size: r.Size ?? 0, + seeders: r.Seeders, + peers: r.Peers, + publishDate: r.PublishDate, + link: r.Link ?? r.Details, + })); + } +} diff --git a/modules/jackett/package.json b/modules/jackett/package.json new file mode 100644 index 0000000..8ef721f --- /dev/null +++ b/modules/jackett/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-jackett", + "version": "0.1.0", + "description": "jackett — indexer proxy. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/jackett/tools/index.ts b/modules/jackett/tools/index.ts new file mode 100644 index 0000000..b10ccad --- /dev/null +++ b/modules/jackett/tools/index.ts @@ -0,0 +1,48 @@ +// jackett's tools — its own code (novox/hq ADR 0044), importing jackett's client. Jackett has +// nothing worth watching (an indexer proxy answers queries; it has no timeline of its own), so it +// is a tools-only module: no events entrypoint, no broker. What is useful is asking it things. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { JackettClient } from "../client.js"; + +export function getJackettTools(jackett: JackettClient): ToolDefinition[] { + return [ + { + name: "jackett_indexers", + description: "List the indexers Jackett proxies, with their type and any last error.", + input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } }, + run: async (args) => { + const indexers = await jackett.getIndexers(!args.all); + return { count: indexers.length, indexers }; + }, + }, + { + name: "jackett_search", + description: "Torznab search across Jackett's indexers, returning normalised torrent results.", + input: { + query: { type: "string", description: "the search query" }, + indexer: { type: "string", description: 'an indexer id, or "all" to aggregate (default "all")' }, + limit: { type: "number", description: "max results (default 25)" }, + }, + run: async (args) => { + const query = String(args.query); + const results = await jackett.search( + query, + args.indexer ? String(args.indexer) : "all", + args.limit ? Number(args.limit) : 25, + ); + return { query, count: results.length, results }; + }, + }, + ]; +} + +// Only exposed when Jackett is configured; otherwise jackett contributes no tools rather than +// failing the whole runtime. +registerModuleTools("jackett", (env) => { + try { + return getJackettTools(JackettClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/jackett/tsconfig.json b/modules/jackett/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/jackett/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts new file mode 100644 index 0000000..870e37e --- /dev/null +++ b/modules/ombi/client.ts @@ -0,0 +1,104 @@ +// The Ombi API client — ombi's own code, living in the module (novox/hq ADR 0044). Ombi is the +// request front-end: viewers ask for movies and shows, and an operator approves them. This client +// talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. + +export interface OmbiRequest { + kind: "movie" | "tv"; + id: number; + title: string; + requestedBy?: string; + requestedDate?: string; + approved: boolean; + available: boolean; + denied: boolean; + tmdbId?: number; +} + +export interface RequestCounts { + pending: number; + approved: number; + available: number; +} + +export class OmbiClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there + * is nothing to talk to, so this throws rather than run half-configured. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { + const url = env.MESH_OMBI_URL; + const apiKey = env.MESH_OMBI_API_KEY; + if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); + if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); + return new OmbiClient(url, apiKey); + } + + private async request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + method, + headers: { + ApiKey: this.apiKey, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`Ombi API ${method} ${path}: ${res.status} ${await res.text()}`); + const text = await res.text(); + return text ? JSON.parse(text) : {}; + } + + /** All requests, movies and TV together — who asked for what, and where each stands. */ + async getRequests(): Promise { + const [movies, tv] = await Promise.all([ + this.request("GET", "/Request/movie"), + this.request("GET", "/Request/tv"), + ]); + const films: OmbiRequest[] = (Array.isArray(movies) ? movies : []).map((r: any) => ({ + kind: "movie" as const, + id: r.id, + title: r.title ?? "Unknown", + requestedBy: r.requestedUser?.userName ?? r.requestedUser?.userAlias, + requestedDate: r.requestedDate, + approved: Boolean(r.approved), + available: Boolean(r.available), + denied: Boolean(r.denied), + tmdbId: r.theMovieDbId, + })); + // TV requests carry per-season child requests; the top-level record is approved when all its + // children are, which is the grain an operator acts on. + const shows: OmbiRequest[] = (Array.isArray(tv) ? tv : []).map((r: any) => { + const children: any[] = r.childRequests ?? []; + return { + kind: "tv" as const, + id: r.id, + title: r.title ?? "Unknown", + requestedBy: children[0]?.requestedUser?.userName, + requestedDate: children[0]?.requestedDate, + approved: children.length > 0 && children.every((c) => c.approved), + available: children.length > 0 && children.every((c) => c.available), + denied: children.some((c) => c.denied), + tmdbId: r.theMovieDbId, + }; + }); + return [...films, ...shows]; + } + + /** Live pending/approved/available counts — a one-line health read without listing everything. */ + async getCounts(): Promise { + const c = await this.request("GET", "/Request/count"); + return { pending: c.pending ?? 0, approved: c.approved ?? 0, available: c.available ?? 0 }; + } + + /** Approve a request. TV approval fans out to the request's child (per-season) requests. */ + async approve(kind: "movie" | "tv", id: number): Promise { + await this.request("POST", `/Request/${kind}/approve`, { id }); + } +} diff --git a/modules/ombi/index.ts b/modules/ombi/index.ts new file mode 100644 index 0000000..4b6ca9f --- /dev/null +++ b/modules/ombi/index.ts @@ -0,0 +1,58 @@ +// ombi's events. The tool runtime imports this once the broker is bound. Ombi's timeline is the +// request lifecycle: a viewer files a request, and later an operator approves it. Both transitions +// are worth announcing — the mesh can notify on a new request, and act on an approval (that is when +// a downloader should start looking). +// +// Emits (novox/hq ADR 0046/0047): +// module.ombi.request.created — a viewer filed a new request +// module.ombi.request.approved — a request was approved +// +// Ombi is the origin of these decisions, not a reactor to the mesh, so it consumes nothing. +// +// Both events are observation-based: poll the request list and diff. Creation is diffed on the set +// of request ids; approval on each request's approved flag flipping true. Primed silently on the +// first look, or a restart would re-announce every existing request and approval. + +import { emit } from "@novox/mesh-sdk/events"; +import { OmbiClient, type OmbiRequest } from "./client.js"; + +const ombi = OmbiClient.fromEnv(); + +// Remember each seen request and whether it was approved last time, keyed by kind+id (ids are only +// unique within a kind). +const approvedState = new Map(); +let primed = false; + +const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`; + +async function pollRequests(): Promise { + const requests = await ombi.getRequests(); + for (const r of requests) { + const key = keyOf(r); + const known = approvedState.has(key); + if (primed && !known) { + await emit("module.ombi.request.created", { + kind: r.kind, + id: r.id, + title: r.title, + requestedBy: r.requestedBy, + tmdbId: r.tmdbId, + }); + } + // Approval: the flag went from false to true for a request we already knew about. + if (primed && known && r.approved && approvedState.get(key) === false) { + await emit("module.ombi.request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId }); + } + approvedState.set(key, r.approved); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[ombi] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollRequests, 30_000); + +console.log("[ombi] watching requests for new filings and approvals"); diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 45d8a62..edc4fb4 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -4,6 +4,13 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.ombi.request.created", + "module.ombi.request.approved" + ], + "own-secrets": { + "broker": "/var/lib/ombi/broker" + }, "listens": [ { "port": 3579, diff --git a/modules/ombi/package.json b/modules/ombi/package.json new file mode 100644 index 0000000..d7542a1 --- /dev/null +++ b/modules/ombi/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-ombi", + "version": "0.1.0", + "description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/ombi/tools/index.ts b/modules/ombi/tools/index.ts new file mode 100644 index 0000000..00d854e --- /dev/null +++ b/modules/ombi/tools/index.ts @@ -0,0 +1,46 @@ +// ombi's tools — its own code (novox/hq ADR 0044), importing ombi's client. They return structured +// data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { OmbiClient } from "../client.js"; + +export function getOmbiTools(ombi: OmbiClient): ToolDefinition[] { + return [ + { + name: "ombi_requests", + description: "List media requests — movies and shows — with who asked and whether each is approved or available.", + input: { pending: { type: "boolean", description: "only requests not yet approved (default false)" } }, + run: async (args) => { + let requests = await ombi.getRequests(); + if (args.pending) requests = requests.filter((r) => !r.approved && !r.denied); + const counts = await ombi.getCounts(); + return { counts, count: requests.length, requests }; + }, + }, + { + name: "ombi_approve", + description: "Approve a media request by its kind and id (from ombi_requests).", + input: { + kind: { type: "string", description: '"movie" or "tv"' }, + id: { type: "number", description: "the request id" }, + }, + run: async (args) => { + const kind = String(args.kind); + if (kind !== "movie" && kind !== "tv") throw new Error('kind must be "movie" or "tv"'); + const id = Number(args.id); + await ombi.approve(kind, id); + return { approved: { kind, id } }; + }, + }, + ]; +} + +// Exposed only when Ombi is configured; otherwise ombi contributes no tools rather than failing the +// whole runtime. +registerModuleTools("ombi", (env) => { + try { + return getOmbiTools(OmbiClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/ombi/tsconfig.json b/modules/ombi/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/ombi/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From 1e2a849b901c32f0e2a4802c039aafebbaa68596 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:40:06 +0200 Subject: [PATCH 12/28] nzbget, qbittorrent: full nox downloader modules (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nzbget (usenet, JSON-RPC) and qbittorrent (torrents, WebUI API with manual SID session). Tools: status, queue/torrents, add, pause/resume, delete. Both poll and emit module..download.added and module..download.completed — the key plex consumes to rescan; completion is keyed off real success (nzbget history SUCCESS, qbittorrent progress reaching 1), not mere queue disappearance, so a failed or deleted item is not reported as done. Typecheck; manifests parse. --- modules/nzbget/client.ts | 160 +++++++++++++++++++++++++++ modules/nzbget/index.ts | 64 +++++++++++ modules/nzbget/module.json | 8 ++ modules/nzbget/package.json | 14 +++ modules/nzbget/tools/index.ts | 88 +++++++++++++++ modules/nzbget/tsconfig.json | 12 ++ modules/qbittorrent/client.ts | 170 +++++++++++++++++++++++++++++ modules/qbittorrent/index.ts | 52 +++++++++ modules/qbittorrent/module.json | 8 ++ modules/qbittorrent/package.json | 14 +++ modules/qbittorrent/tools/index.ts | 98 +++++++++++++++++ modules/qbittorrent/tsconfig.json | 12 ++ 12 files changed, 700 insertions(+) create mode 100644 modules/nzbget/client.ts create mode 100644 modules/nzbget/index.ts create mode 100644 modules/nzbget/package.json create mode 100644 modules/nzbget/tools/index.ts create mode 100644 modules/nzbget/tsconfig.json create mode 100644 modules/qbittorrent/client.ts create mode 100644 modules/qbittorrent/index.ts create mode 100644 modules/qbittorrent/package.json create mode 100644 modules/qbittorrent/tools/index.ts create mode 100644 modules/qbittorrent/tsconfig.json diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts new file mode 100644 index 0000000..668a586 --- /dev/null +++ b/modules/nzbget/client.ts @@ -0,0 +1,160 @@ +// The NZBGet API client — nzbget's own code, living in the module (novox/hq ADR 0044). Ported from +// hal's shared nzbget tools, but self-contained: a change to NZBGet's JSON-RPC now rebuilds only +// nzbget and nothing else. Both this module's tools and its events entrypoint import it, and +// nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. + +export interface NzbgetStatus { + /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ + speedBytesPerSec: number; + remainingMB: number; + downloadedTodayMB: number; + downloadedMonthMB: number; + freeDiskMB: number; + paused: boolean; + postJobs: number; + uptimeSec: number; +} + +export interface NzbgetQueueItem { + /** The NZBID — stable while the item is queued, so events can diff on it. */ + id: number; + name: string; + status: string; + category: string; + sizeMB: number; + remainingMB: number; + percent: number; +} + +export interface NzbgetHistoryItem { + /** The NZBID — the same id the item carried in the queue. */ + id: number; + name: string; + /** NZBGet's own status string, e.g. "SUCCESS/ALL", "FAILURE/PAR", "DELETED/MANUAL". */ + status: string; + category: string; + sizeMB: number; + /** A genuine completion (status starts "SUCCESS") vs a failed or deleted entry — the difference + * between something to announce as done and something that merely left the queue. */ + success: boolean; +} + +export class NzbgetClient { + readonly rpcUrl: string; + private readonly auth: string; + + constructor(url: string, user: string, password: string) { + this.rpcUrl = `${url.replace(/\/$/, "")}/jsonrpc`; + this.auth = Buffer.from(`${user}:${password}`).toString("base64"); + } + + /** + * Build from the module's resolved environment. URL and password are read from MESH_NZBGET_URL + * and MESH_NZBGET_PASSWORD; both must be present — an unconfigured NZBGet throws rather than + * pretend to be reachable, so the tools/events simply do not load (the harness treats the throw + * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { + const url = env.MESH_NZBGET_URL; + const password = env.MESH_NZBGET_PASSWORD; + if (!url || !password) { + throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); + } + const user = env.MESH_NZBGET_USER ?? "nzbget"; + return new NzbgetClient(url, user, password); + } + + private async rpc(method: string, params: unknown[] = []): Promise { + const res = await fetch(this.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Basic ${this.auth}` }, + body: JSON.stringify({ method, params, id: 1 }), + }); + if (!res.ok) throw new Error(`NZBGet API ${method}: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { result?: T; error?: unknown }; + if (data.error) throw new Error(`NZBGet RPC ${method}: ${JSON.stringify(data.error)}`); + return data.result as T; + } + + async getVersion(): Promise { + return this.rpc("version"); + } + + async getStatus(): Promise { + const s = await this.rpc>("status"); + const lo = Number(s.DownloadRateLo ?? 0); + const hi = Number(s.DownloadRateHi ?? 0); + return { + speedBytesPerSec: lo + hi * 4294967296, + remainingMB: Number(s.RemainingSizeMB ?? 0), + downloadedTodayMB: Number(s.DaySizeMB ?? 0), + downloadedMonthMB: Number(s.MonthSizeMB ?? 0), + freeDiskMB: Number(s.FreeDiskSpaceMB ?? 0), + paused: Boolean(s.DownloadPaused), + postJobs: Number(s.PostJobCount ?? 0), + uptimeSec: Number(s.UpTimeSec ?? 0), + }; + } + + async getQueue(): Promise { + const groups = await this.rpc[]>("listgroups", [0]); + return groups.map((g) => { + const size = Number(g.FileSizeMB ?? 0); + const remaining = Number(g.RemainingSizeMB ?? 0); + return { + id: Number(g.NZBID), + name: String(g.NZBName ?? "Unknown"), + status: String(g.Status ?? "unknown"), + category: String(g.Category ?? ""), + sizeMB: size, + remainingMB: remaining, + percent: size > 0 ? Math.round(((size - remaining) / size) * 100) : 0, + }; + }); + } + + async getHistory(limit = 20): Promise { + const history = await this.rpc[]>("history", [false]); + return history.slice(0, limit).map((h) => { + const status = String(h.Status ?? ""); + return { + id: Number(h.NZBID), + name: String(h.Name ?? "Unknown"), + status, + category: String(h.Category ?? ""), + sizeMB: Number(h.FileSizeMB ?? 0), + success: status.startsWith("SUCCESS"), + }; + }); + } + + /** Queue an NZB by URL. Returns the new NZBID; a non-positive id means NZBGet refused it. */ + async add(url: string, category = "", priority = 0, paused = false): Promise { + const id = await this.rpc("append", [ + "", url, category, priority, false, paused, "", 0, "SCORE", false, [], + ]); + if (!id || id <= 0) throw new Error("NZBGet refused the NZB (append returned 0)"); + return id; + } + + async pauseAll(): Promise { + await this.rpc("pausedownload"); + } + + async resumeAll(): Promise { + await this.rpc("resumedownload"); + } + + async pauseItem(id: number): Promise { + await this.rpc("editqueue", ["GroupPause", "", [id]]); + } + + async resumeItem(id: number): Promise { + await this.rpc("editqueue", ["GroupResume", "", [id]]); + } + + /** Delete an item from the queue or from history. */ + async delete(id: number, from: "queue" | "history" = "queue"): Promise { + await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]); + } +} diff --git a/modules/nzbget/index.ts b/modules/nzbget/index.ts new file mode 100644 index 0000000..bf576ae --- /dev/null +++ b/modules/nzbget/index.ts @@ -0,0 +1,64 @@ +// nzbget's events. The tool runtime imports this once the broker is bound. It watches the download +// queue and the history and turns their comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.nzbget.download.added — an NZB entered the queue +// module.nzbget.download.completed — an NZB finished successfully (left the queue, landed in +// history as SUCCESS). This exact routing key is what the +// plex module consumes (module.*.download.completed) to +// rescan, so the new file becomes a visible item. +// Consumes: none. +// +// Two diffs, each primed silently on the first look (like plex's and sonarr's index.ts) so a +// restart mid-download does not re-announce everything already in flight or already finished. The +// queue tells us what was grabbed; history — not the queue's disappearance — tells us what actually +// succeeded, since a failed or deleted download also leaves the queue. + +import { emit } from "@novox/mesh-sdk/events"; +import { NzbgetClient } from "./client.js"; + +const nzbget = NzbgetClient.fromEnv(); + +const inQueue = new Set(); +let queuePrimed = false; +async function pollQueue(): Promise { + const items = await nzbget.getQueue(); + const now = new Set(items.map((i) => i.id)); + if (queuePrimed) { + for (const item of items) { + if (!inQueue.has(item.id)) { + await emit("module.nzbget.download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB }); + } + } + } + inQueue.clear(); + for (const id of now) inQueue.add(id); + queuePrimed = true; +} + +const seenHistory = new Set(); +let historyPrimed = false; +async function pollHistory(): Promise { + const items = await nzbget.getHistory(50); + for (const item of items) { + if (!seenHistory.has(item.id)) { + // A newly-appeared history entry is a completion only if it actually succeeded; a failure or + // a manual delete lands in history too, and neither is a "download.completed". + if (historyPrimed && item.success) { + await emit("module.nzbget.download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB }); + } + seenHistory.add(item.id); + } + } + historyPrimed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[nzbget] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollQueue, 20_000); +tick(pollHistory, 30_000); + +console.log("[nzbget] watching the queue and history, emitting adds and completions"); diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 95b41ca..c6dabae 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.nzbget.download.added", + "module.nzbget.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/nzbget/broker" + }, "listens": [ { "port": 6789, diff --git a/modules/nzbget/package.json b/modules/nzbget/package.json new file mode 100644 index 0000000..6e1af7f --- /dev/null +++ b/modules/nzbget/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-nzbget", + "version": "0.1.0", + "description": "nzbget — Usenet download client. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/nzbget/tools/index.ts b/modules/nzbget/tools/index.ts new file mode 100644 index 0000000..1f87377 --- /dev/null +++ b/modules/nzbget/tools/index.ts @@ -0,0 +1,88 @@ +// nzbget's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing nzbget's own +// client. They return structured data (not the pre-formatted text hal returned); the mesh serves +// them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { NzbgetClient } from "../client.js"; + +export function getNzbgetTools(nzbget: NzbgetClient): ToolDefinition[] { + return [ + { + name: "nzbget_status", + description: "NZBGet server status: download speed, queue remaining, disk free, paused state.", + input: {}, + run: async () => nzbget.getStatus(), + }, + { + name: "nzbget_queue", + description: "List the current NZBGet download queue — what is downloading and how far along.", + input: {}, + run: async () => { + const items = await nzbget.getQueue(); + return { count: items.length, items }; + }, + }, + { + name: "nzbget_history", + description: "Recent NZBGet download history, newest first — completed, failed and deleted items.", + input: { limit: { type: "number", description: "how many entries (default 20)" } }, + run: async (args) => { + const items = await nzbget.getHistory(args.limit ? Number(args.limit) : 20); + return { count: items.length, items }; + }, + }, + { + name: "nzbget_add", + description: "Queue an NZB download by URL, optionally into a category.", + input: { + url: { type: "string", description: "URL to the NZB file" }, + category: { type: "string", description: "category name (determines download directory)" }, + priority: { type: "number", description: "-100 very low … 0 normal … 100 very high (default 0)" }, + paused: { type: "boolean", description: "add in paused state (default false)" }, + }, + run: async (args) => { + const id = await nzbget.add( + String(args.url), + args.category ? String(args.category) : "", + args.priority ? Number(args.priority) : 0, + args.paused === true || args.paused === "true", + ); + return { added: id, category: args.category ? String(args.category) : null }; + }, + }, + { + name: "nzbget_pause", + description: "Pause or resume all NZBGet downloads.", + input: { resume: { type: "boolean", description: "true to resume, false to pause (default false)" } }, + run: async (args) => { + const resume = args.resume === true || args.resume === "true"; + if (resume) await nzbget.resumeAll(); + else await nzbget.pauseAll(); + return { paused: !resume }; + }, + }, + { + name: "nzbget_delete", + description: "Delete an NZB from the queue or from history by its NZBID.", + input: { + id: { type: "number", description: "the NZBID to delete" }, + from: { type: "string", description: "'queue' (default) or 'history'" }, + }, + run: async (args) => { + const from = args.from === "history" ? "history" : "queue"; + await nzbget.delete(Number(args.id), from); + return { deleted: Number(args.id), from }; + }, + }, + ]; +} + +// The tools exist only when NZBGet is configured; without a URL and password, nzbget contributes +// none rather than failing the whole runtime. +registerModuleTools("nzbget", (env) => { + try { + return getNzbgetTools(NzbgetClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/nzbget/tsconfig.json b/modules/nzbget/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/nzbget/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts new file mode 100644 index 0000000..24cc65f --- /dev/null +++ b/modules/qbittorrent/client.ts @@ -0,0 +1,170 @@ +// The qBittorrent API client — qbittorrent's own code, living in the module (novox/hq ADR 0044). +// Written against the WebUI API (/api/v2/...), self-contained so a change to it rebuilds only +// qbittorrent. Both this module's tools and its events entrypoint import it, and nothing outside +// qbittorrent does. +// +// The WebUI authenticates with a session cookie (SID) obtained by POSTing credentials, and guards +// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is +// captured on login and carried by hand on every later call, with a single re-login on expiry. + +export interface QbTransferInfo { + dlSpeedBytesPerSec: number; + upSpeedBytesPerSec: number; + dlData: number; + upData: number; + connectionStatus: string; +} + +export interface QbTorrent { + hash: string; + name: string; + /** qBittorrent's state, e.g. "downloading", "stalledUP", "uploading", "pausedUP", "error". */ + state: string; + /** 0..1 — 1 means the download is complete. */ + progress: number; + sizeBytes: number; + dlSpeed: number; + upSpeed: number; + category: string; + ratio: number; + savePath: string; +} + +export class QbittorrentClient { + readonly baseUrl: string; + private sid: string | null = null; + + constructor( + baseUrl: string, + private readonly user: string, + private readonly password: string, + ) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. URL and password are read from + * MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD; both must be present — an unconfigured + * qBittorrent throws rather than pretend to be reachable, so the tools/events simply do not load + * (the harness treats the throw as "exposes nothing"). The user defaults to "admin". + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { + const url = env.MESH_QBITTORRENT_URL; + const password = env.MESH_QBITTORRENT_PASSWORD; + if (!url || !password) { + throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); + } + const user = env.MESH_QBITTORRENT_USER ?? "admin"; + return new QbittorrentClient(url, user, password); + } + + private async login(): Promise { + const res = await fetch(`${this.baseUrl}/api/v2/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: this.baseUrl }, + body: new URLSearchParams({ username: this.user, password: this.password }), + }); + if (!res.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`); + if ((await res.text()).trim() !== "Ok.") { + throw new Error("qBittorrent login rejected — check credentials"); + } + const match = res.headers.get("set-cookie")?.match(/SID=([^;]+)/); + if (!match) throw new Error("qBittorrent login returned no SID cookie"); + this.sid = match[1]; + } + + private async call(method: "GET" | "POST", path: string, form?: Record): Promise { + if (!this.sid) await this.login(); + const doFetch = (): Promise => { + const headers: Record = { Referer: this.baseUrl, Cookie: `SID=${this.sid}` }; + const init: RequestInit = { method, headers }; + if (form) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + init.body = new URLSearchParams(form); + } + return fetch(`${this.baseUrl}/api/v2/${path}`, init); + }; + let res = await doFetch(); + if (res.status === 403) { + // The SID expired — re-authenticate once and retry, rather than fail a routine call. + await this.login(); + res = await doFetch(); + } + return res; + } + + private async getJson(path: string): Promise { + const res = await this.call("GET", path); + if (!res.ok) throw new Error(`qBittorrent GET ${path}: ${res.status} ${await res.text()}`); + return (await res.json()) as T; + } + + async getVersion(): Promise { + const res = await this.call("GET", "app/version"); + if (!res.ok) throw new Error(`qBittorrent app/version: ${res.status}`); + return (await res.text()).trim(); + } + + async getTransferInfo(): Promise { + const d = await this.getJson>("transfer/info"); + return { + dlSpeedBytesPerSec: Number(d.dl_info_speed ?? 0), + upSpeedBytesPerSec: Number(d.up_info_speed ?? 0), + dlData: Number(d.dl_info_data ?? 0), + upData: Number(d.up_info_data ?? 0), + connectionStatus: String(d.connection_status ?? "unknown"), + }; + } + + async getTorrents(filter?: string): Promise { + const path = filter ? `torrents/info?filter=${encodeURIComponent(filter)}` : "torrents/info"; + const list = await this.getJson[]>(path); + return list.map((t) => ({ + hash: String(t.hash), + name: String(t.name ?? "Unknown"), + state: String(t.state ?? "unknown"), + progress: Number(t.progress ?? 0), + sizeBytes: Number(t.size ?? 0), + dlSpeed: Number(t.dlspeed ?? 0), + upSpeed: Number(t.upspeed ?? 0), + category: String(t.category ?? ""), + ratio: Number(t.ratio ?? 0), + savePath: String(t.save_path ?? ""), + })); + } + + /** Add a torrent by magnet or http(s) .torrent URL, optionally into a category / save path. */ + async add(url: string, category = "", savepath = "", paused = false): Promise { + const form: Record = { urls: url, paused: paused ? "true" : "false" }; + if (category) form.category = category; + if (savepath) form.savepath = savepath; + const res = await this.call("POST", "torrents/add", form); + const text = (await res.text()).trim(); + if (!res.ok || text.toLowerCase() === "fails.") { + throw new Error(`qBittorrent refused the torrent: ${res.status} ${text}`); + } + } + + // qBittorrent 5.x renamed pause/resume to stop/start; try the modern name and fall back to the + // legacy one on a 404, so the client works against both. + private async command(modern: string, legacy: string, hashes: string): Promise { + let res = await this.call("POST", `torrents/${modern}`, { hashes }); + if (res.status === 404) res = await this.call("POST", `torrents/${legacy}`, { hashes }); + if (!res.ok) throw new Error(`qBittorrent torrents/${modern}: ${res.status} ${await res.text()}`); + } + + /** Pause torrents — a pipe-separated hash list, or "all" (the default). */ + async pause(hashes = "all"): Promise { + await this.command("stop", "pause", hashes); + } + + /** Resume torrents — a pipe-separated hash list, or "all" (the default). */ + async resume(hashes = "all"): Promise { + await this.command("start", "resume", hashes); + } + + async delete(hashes: string, deleteFiles = false): Promise { + const res = await this.call("POST", "torrents/delete", { hashes, deleteFiles: deleteFiles ? "true" : "false" }); + if (!res.ok) throw new Error(`qBittorrent torrents/delete: ${res.status} ${await res.text()}`); + } +} diff --git a/modules/qbittorrent/index.ts b/modules/qbittorrent/index.ts new file mode 100644 index 0000000..2b57c18 --- /dev/null +++ b/modules/qbittorrent/index.ts @@ -0,0 +1,52 @@ +// qbittorrent's events. The tool runtime imports this once the broker is bound. It watches the +// torrent list and turns its comings and goings into mesh events. +// +// Emits (novox/hq ADR 0046/0047): +// module.qbittorrent.download.added — a torrent was added +// module.qbittorrent.download.completed — a torrent finished downloading (progress reached 1). +// This exact routing key is what the plex module +// consumes (module.*.download.completed) to rescan, so +// the new file becomes a visible item. +// Consumes: none. +// +// The torrent list is polled and diffed by hash, primed silently on the first look (like plex's and +// sonarr's index.ts) so a restart does not re-announce everything already present. Completion is a +// progress crossing from below 1 to exactly 1 — a torrent added already-complete is announced only +// as added, never as freshly completed, since nothing was downloaded. + +import { emit } from "@novox/mesh-sdk/events"; +import { QbittorrentClient } from "./client.js"; + +const qb = QbittorrentClient.fromEnv(); + +const progressByHash = new Map(); +let primed = false; + +async function pollTorrents(): Promise { + const torrents = await qb.getTorrents(); + const now = new Map(torrents.map((t) => [t.hash, t])); + + if (primed) { + for (const [hash, t] of now) { + const before = progressByHash.get(hash); + if (before === undefined) { + await emit("module.qbittorrent.download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); + } else if (before < 1 && t.progress >= 1) { + await emit("module.qbittorrent.download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); + } + } + } + + progressByHash.clear(); + for (const [hash, t] of now) progressByHash.set(hash, t.progress); + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[qbittorrent] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollTorrents, 20_000); + +console.log("[qbittorrent] watching torrents, emitting adds and completions"); diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index a8e8cb5..97690ab 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -4,6 +4,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.qbittorrent.download.added", + "module.qbittorrent.download.completed" + ], + "consumes": [], + "own-secrets": { + "broker": "/var/lib/qbittorrent/broker" + }, "listens": [ { "port": 8080, diff --git a/modules/qbittorrent/package.json b/modules/qbittorrent/package.json new file mode 100644 index 0000000..865b977 --- /dev/null +++ b/modules/qbittorrent/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-qbittorrent", + "version": "0.1.0", + "description": "qbittorrent — BitTorrent download client. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/qbittorrent/tools/index.ts b/modules/qbittorrent/tools/index.ts new file mode 100644 index 0000000..49f9ec5 --- /dev/null +++ b/modules/qbittorrent/tools/index.ts @@ -0,0 +1,98 @@ +// qbittorrent's tools — living in the module (novox/hq ADR 0044), importing qbittorrent's own +// client. They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { QbittorrentClient } from "../client.js"; + +export function getQbittorrentTools(qb: QbittorrentClient): ToolDefinition[] { + return [ + { + name: "qbittorrent_status", + description: "qBittorrent status: version, global transfer rates, and how many torrents are active.", + input: {}, + run: async () => { + const [version, transfer, torrents] = await Promise.all([ + qb.getVersion(), + qb.getTransferInfo(), + qb.getTorrents(), + ]); + const downloading = torrents.filter((t) => t.progress < 1).length; + return { version, transfer, torrents: torrents.length, downloading, seeding: torrents.length - downloading }; + }, + }, + { + name: "qbittorrent_torrents", + description: "List torrents — name, state, progress and speed. Optional filter narrows the set.", + input: { + filter: { type: "string", description: "one of all|downloading|seeding|completed|paused|active|inactive|stalled" }, + }, + run: async (args) => { + const items = await qb.getTorrents(args.filter ? String(args.filter) : undefined); + return { count: items.length, torrents: items }; + }, + }, + { + name: "qbittorrent_add", + description: "Add a torrent by magnet link or .torrent URL, optionally into a category.", + input: { + url: { type: "string", description: "magnet link or http(s) URL to a .torrent" }, + category: { type: "string", description: "category name (determines save directory)" }, + savepath: { type: "string", description: "explicit save path (overrides the category default)" }, + paused: { type: "boolean", description: "add in paused state (default false)" }, + }, + run: async (args) => { + await qb.add( + String(args.url), + args.category ? String(args.category) : "", + args.savepath ? String(args.savepath) : "", + args.paused === true || args.paused === "true", + ); + return { added: String(args.url), category: args.category ? String(args.category) : null }; + }, + }, + { + name: "qbittorrent_pause", + description: "Pause torrents — a pipe-separated hash list, or 'all' (the default).", + input: { hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all' (default)" } }, + run: async (args) => { + const hashes = args.hashes ? String(args.hashes) : "all"; + await qb.pause(hashes); + return { paused: hashes }; + }, + }, + { + name: "qbittorrent_resume", + description: "Resume torrents — a pipe-separated hash list, or 'all' (the default).", + input: { hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all' (default)" } }, + run: async (args) => { + const hashes = args.hashes ? String(args.hashes) : "all"; + await qb.resume(hashes); + return { resumed: hashes }; + }, + }, + { + name: "qbittorrent_delete", + description: "Remove torrents by hash, optionally deleting their files on disk.", + input: { + hashes: { type: "string", description: "pipe-separated torrent hashes, or 'all'" }, + deleteFiles: { type: "boolean", description: "also delete downloaded files (default false)" }, + }, + run: async (args) => { + const hashes = String(args.hashes); + const deleteFiles = args.deleteFiles === true || args.deleteFiles === "true"; + await qb.delete(hashes, deleteFiles); + return { deleted: hashes, deleteFiles }; + }, + }, + ]; +} + +// The tools exist only when qBittorrent is configured; without a URL and password, qbittorrent +// contributes none rather than failing the whole runtime. +registerModuleTools("qbittorrent", (env) => { + try { + return getQbittorrentTools(QbittorrentClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/qbittorrent/tsconfig.json b/modules/qbittorrent/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/qbittorrent/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From eccfc70991c936776038aff8a3ad7fb0f7bd247c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:43:20 +0200 Subject: [PATCH 13/28] grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046) grafana: status/datasources/dashboards/alerts tools, emits alert.firing. tautulli: activity/history/stats tools, emits watch.recorded. nextcloud: users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits user.created/share.created. nodered: flows/nodes/deploy tools, emits flows.deployed inline from the deploy tool. All typecheck; manifests parse. --- modules/grafana/client.ts | 110 +++++++++++++++++++++++++++++++ modules/grafana/index.ts | 58 ++++++++++++++++ modules/grafana/module.json | 6 +- modules/grafana/package.json | 14 ++++ modules/grafana/tools/index.ts | 55 ++++++++++++++++ modules/grafana/tsconfig.json | 12 ++++ modules/nextcloud/client.ts | 93 ++++++++++++++++++++++++++ modules/nextcloud/index.ts | 57 ++++++++++++++++ modules/nextcloud/module.json | 7 +- modules/nextcloud/package.json | 14 ++++ modules/nextcloud/tools/index.ts | 57 ++++++++++++++++ modules/nextcloud/tsconfig.json | 12 ++++ modules/nodered/client.ts | 86 ++++++++++++++++++++++++ modules/nodered/module.json | 6 ++ modules/nodered/package.json | 14 ++++ modules/nodered/tools/index.ts | 64 ++++++++++++++++++ modules/nodered/tsconfig.json | 12 ++++ modules/tautulli/client.ts | 98 +++++++++++++++++++++++++++ modules/tautulli/index.ts | 48 ++++++++++++++ modules/tautulli/module.json | 6 ++ modules/tautulli/package.json | 14 ++++ modules/tautulli/tools/index.ts | 44 +++++++++++++ modules/tautulli/tsconfig.json | 12 ++++ 23 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 modules/grafana/client.ts create mode 100644 modules/grafana/index.ts create mode 100644 modules/grafana/package.json create mode 100644 modules/grafana/tools/index.ts create mode 100644 modules/grafana/tsconfig.json create mode 100644 modules/nextcloud/client.ts create mode 100644 modules/nextcloud/index.ts create mode 100644 modules/nextcloud/package.json create mode 100644 modules/nextcloud/tools/index.ts create mode 100644 modules/nextcloud/tsconfig.json create mode 100644 modules/nodered/client.ts create mode 100644 modules/nodered/package.json create mode 100644 modules/nodered/tools/index.ts create mode 100644 modules/nodered/tsconfig.json create mode 100644 modules/tautulli/client.ts create mode 100644 modules/tautulli/index.ts create mode 100644 modules/tautulli/package.json create mode 100644 modules/tautulli/tools/index.ts create mode 100644 modules/tautulli/tsconfig.json diff --git a/modules/grafana/client.ts b/modules/grafana/client.ts new file mode 100644 index 0000000..41395ae --- /dev/null +++ b/modules/grafana/client.ts @@ -0,0 +1,110 @@ +// Grafana's API client — grafana's own code, living in the module (novox/hq ADR 0044). Ported from +// the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both +// this module's tools and its events entrypoint import it, and nothing outside grafana does. + +export interface GrafanaHealth { + database: string; + version: string; + commit: string; +} + +export interface GrafanaDatasource { + id: number; + uid: string; + name: string; + type: string; + url: string; + isDefault: boolean; + database?: string; +} + +export interface GrafanaDashboard { + uid: string; + title: string; + url: string; + tags: string[]; + folderTitle?: string; +} + +export interface GrafanaAlert { + /** The rule name (labels.alertname), the stable identity a firing alert is diffed on. */ + name: string; + /** Grafana unified-alerting state: "Normal" | "Pending" | "Alerting". */ + state: string; + labels: Record; + activeAt?: string; +} + +export class GrafanaClient { + readonly baseUrl: string; + private readonly authHeader: string; + + constructor(url: string, authHeader: string) { + this.baseUrl = url.replace(/\/$/, ""); + this.authHeader = authHeader; + } + + /** + * Build from the module's resolved environment. Auth is a service-account/API token + * (MESH_GRAFANA_TOKEN, sent as Bearer) when present, else HTTP basic with the admin password the + * module keeps as its own secret (MESH_GRAFANA_PASSWORD, user MESH_GRAFANA_USER, default admin). + * Throws when neither is configured — the module then contributes nothing rather than failing. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { + const url = env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; + const token = env.MESH_GRAFANA_TOKEN; + if (token) return new GrafanaClient(url, `Bearer ${token}`); + const password = env.MESH_GRAFANA_PASSWORD; + if (password) { + const user = env.MESH_GRAFANA_USER ?? "admin"; + return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); + } + throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); + } + + private async get(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + headers: { Authorization: this.authHeader, Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`Grafana API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async health(): Promise { + const h = await this.get("/api/health"); + return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" }; + } + + async listDatasources(): Promise { + const arr = (await this.get("/api/datasources")) as any[]; + return arr.map((d) => ({ + id: d.id, uid: d.uid, name: d.name, type: d.type, url: d.url, + isDefault: !!d.isDefault, database: d.database || undefined, + })); + } + + async listDashboards(query?: string): Promise { + const params = new URLSearchParams({ type: "dash-db" }); + if (query) params.set("query", query); + const arr = (await this.get(`/api/search?${params.toString()}`)) as any[]; + return arr.map((d) => ({ + uid: d.uid, title: d.title, url: d.url, tags: d.tags ?? [], folderTitle: d.folderTitle || undefined, + })); + } + + /** + * Active alert instances from unified alerting's Prometheus-compatible surface. Grafana without + * alerting configured answers this with an empty set (or a 404, surfaced by get) — callers treat + * "no alerts" and "no alerting" alike. + */ + async listAlerts(): Promise { + const data = (await this.get("/api/prometheus/grafana/api/v1/alerts")).data ?? {}; + const alerts = (data.alerts ?? []) as any[]; + return alerts.map((a) => ({ + name: a.labels?.alertname ?? "unknown", + state: a.state ?? "unknown", + labels: a.labels ?? {}, + activeAt: a.activeAt || undefined, + })); + } +} diff --git a/modules/grafana/index.ts b/modules/grafana/index.ts new file mode 100644 index 0000000..bb497c8 --- /dev/null +++ b/modules/grafana/index.ts @@ -0,0 +1,58 @@ +// grafana's events. The tool runtime imports this once the broker is bound. It watches unified +// alerting and announces when an alert instance starts firing. +// +// Emits (novox/hq ADR 0046/0047): +// module.grafana.alert.firing — an alert instance entered the Alerting state +// +// A Grafana with no alerting configured simply never has a firing alert, so this observes nothing +// and emits nothing — no error, no noise. + +import { emit } from "@novox/mesh-sdk/events"; +import { GrafanaClient, type GrafanaAlert } from "./client.js"; + +// Constructed lazily so an unconfigured node (no auth) loads this entrypoint without crashing the +// events host — it simply watches nothing. +let grafana: GrafanaClient | undefined; +try { + grafana = GrafanaClient.fromEnv(); +} catch (err) { + console.log(`[grafana] not configured, not watching alerts: ${err}`); +} + +// Firing alerts, by diffing the set currently in the Alerting state. Primed silently on the first +// look so alerts already firing when this started are not announced as freshly firing. +const firing = new Set(); +let primed = false; + +const alertKey = (a: GrafanaAlert): string => + `${a.name}:${Object.entries(a.labels).sort().map(([k, v]) => `${k}=${v}`).join(",")}`; + +async function pollAlerts(client: GrafanaClient): Promise { + const now = new Set(); + const byKey = new Map(); + for (const a of await client.listAlerts()) { + if (a.state.toLowerCase() !== "alerting") continue; + const key = alertKey(a); + now.add(key); + byKey.set(key, a); + } + if (primed) { + for (const key of now) { + if (!firing.has(key)) { + const a = byKey.get(key)!; + await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt }); + } + } + } + firing.clear(); + for (const key of now) firing.add(key); + primed = true; +} + +if (grafana) { + const client = grafana; + const run = (): void => void pollAlerts(client).catch((err) => console.error(`[grafana] ${err}`)); + setInterval(run, 30_000); + run(); + console.log("[grafana] watching for firing alerts"); +} diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 5dcbbd5..e9ae8fa 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -1,8 +1,12 @@ { "module": "grafana", "version": "1", + "emits": [ + "module.grafana.alert.firing" + ], "own-secrets": { - "admin": "/var/lib/grafana-module/admin.secret" + "admin": "/var/lib/grafana-module/admin.secret", + "broker": "/var/lib/grafana-module/broker" }, "capabilities": [ "container-runtime" diff --git a/modules/grafana/package.json b/modules/grafana/package.json new file mode 100644 index 0000000..49003a9 --- /dev/null +++ b/modules/grafana/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-grafana", + "version": "0.1.0", + "description": "grafana — monitoring dashboards. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/grafana/tools/index.ts b/modules/grafana/tools/index.ts new file mode 100644 index 0000000..55fc89b --- /dev/null +++ b/modules/grafana/tools/index.ts @@ -0,0 +1,55 @@ +// grafana's tools — moved here from the shared hal sdk (novox/hq ADR 0044), importing grafana's own +// client. They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { GrafanaClient } from "../client.js"; + +export function getGrafanaTools(grafana: GrafanaClient): ToolDefinition[] { + return [ + { + name: "grafana_status", + description: "Grafana server health — database state, version, build commit.", + input: {}, + run: async () => grafana.health(), + }, + { + name: "grafana_list_datasources", + description: "List Grafana data sources — name, type, backing URL, which is default.", + input: {}, + run: async () => { + const datasources = await grafana.listDatasources(); + return { count: datasources.length, datasources }; + }, + }, + { + name: "grafana_list_dashboards", + description: "List Grafana dashboards, optionally filtered by a name query.", + input: { query: { type: "string", description: "filter dashboards by name (optional)" } }, + run: async (args) => { + const query = args.query ? String(args.query) : undefined; + const dashboards = await grafana.listDashboards(query); + return { count: dashboards.length, dashboards }; + }, + }, + { + name: "grafana_alerts", + description: "Active Grafana alert instances and their state (Alerting, Pending, Normal).", + input: {}, + run: async () => { + const alerts = await grafana.listAlerts(); + const firing = alerts.filter((a) => a.state.toLowerCase() === "alerting"); + return { count: alerts.length, firing: firing.length, alerts }; + }, + }, + ]; +} + +// The tools exist only when Grafana auth can be resolved; without it, grafana contributes none +// rather than failing the whole tool runtime. +registerModuleTools("grafana", (env) => { + try { + return getGrafanaTools(GrafanaClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/grafana/tsconfig.json b/modules/grafana/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/grafana/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts new file mode 100644 index 0000000..5418924 --- /dev/null +++ b/modules/nextcloud/client.ts @@ -0,0 +1,93 @@ +// Nextcloud's client — nextcloud's own code, living in the module (novox/hq ADR 0044). Both this +// module's tools and its events entrypoint import it, and nothing outside nextcloud does. +// +// Nextcloud is administered two ways, and this client speaks both: +// - occ, its admin CLI, is a PHP script inside the container runnable only as the web user. We +// reach it with `docker exec`, the same side channel an operator would use by hand — turned +// into something the mesh can call. Users and apps come from here. +// - the OCS Sharing API answers over HTTP with the admin credentials. Shares come from here, +// because occ has no version-stable "list every share" across the releases we run. + +import { execFileSync } from "node:child_process"; + +export interface NextcloudUser { + uid: string; + displayName: string; +} + +export interface NextcloudShare { + /** The OCS share id — the stable identity a new share is diffed on. */ + id: string; + path: string; + shareType: number; + shareWith?: string; + owner: string; +} + +export class NextcloudClient { + constructor( + private readonly container: string, + private readonly ocsUrl: string, + private readonly adminUser: string, + private readonly adminPassword: string, + ) {} + + /** + * Build from the module's resolved environment. occ needs only the container name (default + * "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret + * (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local + * container). The admin password is treated as the "configured for mesh administration" signal: + * throws without it, and the module then contributes nothing rather than failing. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { + const container = env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; + const ocsUrl = env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + const adminUser = env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; + const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD; + if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD"); + return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); + } + + /** Run occ inside the container as the web user, returning its stdout, throwing its own message. */ + occ(args: string[]): string { + try { + return execFileSync("docker", ["exec", "-u", "www-data", this.container, "php", "occ", ...args], { + encoding: "utf8", timeout: 60_000, + }).trim(); + } catch (err: any) { + const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim(); + throw new Error(detail || `occ produced no output — is the ${this.container} container running?`); + } + } + + listUsers(): NextcloudUser[] { + // user:list --output=json answers an object of uid → display name. + const raw = this.occ(["user:list", "--output=json"]); + const map = JSON.parse(raw || "{}") as Record; + return Object.entries(map).map(([uid, displayName]) => ({ uid, displayName })); + } + + listApps(): { enabled: string[]; disabled: string[] } { + const raw = this.occ(["app:list", "--output=json"]); + const parsed = JSON.parse(raw || "{}") as { enabled?: Record; disabled?: Record }; + return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) }; + } + + /** List every share, over the OCS Sharing API with the admin credentials. */ + async listShares(): Promise { + const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64"); + const res = await fetch( + `${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, + { headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } }, + ); + if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`); + const rows = ((await res.json())?.ocs?.data ?? []) as any[]; + return rows.map((s) => ({ + id: String(s.id), + path: s.path ?? "", + shareType: Number(s.share_type ?? -1), + shareWith: s.share_with || undefined, + owner: s.uid_owner ?? "unknown", + })); + } +} diff --git a/modules/nextcloud/index.ts b/modules/nextcloud/index.ts new file mode 100644 index 0000000..3fc26f1 --- /dev/null +++ b/modules/nextcloud/index.ts @@ -0,0 +1,57 @@ +// nextcloud's events. The tool runtime imports this once the broker is bound. It watches the user +// list and the share list and announces new arrivals. +// +// Emits (novox/hq ADR 0046/0047): +// module.nextcloud.user.created — a user account appeared (occ user:list) +// module.nextcloud.share.created — a share appeared (OCS shares) +// +// Both are diffed and primed silently on the first look, so a restart does not re-announce every +// existing user and share as freshly created. + +import { emit } from "@novox/mesh-sdk/events"; +import { NextcloudClient } from "./client.js"; + +// Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without +// crashing the events host — it simply watches nothing. +let nextcloud: NextcloudClient | undefined; +try { + nextcloud = NextcloudClient.fromEnv(); +} catch (err) { + console.log(`[nextcloud] not configured, not watching: ${err}`); +} + +const knownUsers = new Set(); +let usersPrimed = false; +async function pollUsers(client: NextcloudClient): Promise { + const users = client.listUsers(); + for (const u of users) { + if (knownUsers.has(u.uid)) continue; + if (usersPrimed) await emit("module.nextcloud.user.created", { uid: u.uid, displayName: u.displayName }); + knownUsers.add(u.uid); + } + usersPrimed = true; +} + +const knownShares = new Set(); +let sharesPrimed = false; +async function pollShares(client: NextcloudClient): Promise { + const shares = await client.listShares(); + for (const s of shares) { + if (knownShares.has(s.id)) continue; + if (sharesPrimed) await emit("module.nextcloud.share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner }); + knownShares.add(s.id); + } + sharesPrimed = true; +} + +if (nextcloud) { + const client = nextcloud; + const tick = (fn: (c: NextcloudClient) => Promise): void => { + const run = (): void => void fn(client).catch((err) => console.error(`[nextcloud] ${err}`)); + setInterval(run, 60_000); + run(); + }; + tick(pollUsers); + tick(pollShares); + console.log("[nextcloud] watching users and shares"); +} diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 20faa8d..8d56fee 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -21,8 +21,13 @@ "postgres-database": "/var/lib/nextcloud-module/database.secret", "s3-bucket": "/var/lib/nextcloud-module/store.secret" }, + "emits": [ + "module.nextcloud.user.created", + "module.nextcloud.share.created" + ], "own-secrets": { - "admin": "/var/lib/nextcloud-module/admin.secret" + "admin": "/var/lib/nextcloud-module/admin.secret", + "broker": "/var/lib/nextcloud-module/broker" }, "capabilities": [ "container-runtime" diff --git a/modules/nextcloud/package.json b/modules/nextcloud/package.json new file mode 100644 index 0000000..841026f --- /dev/null +++ b/modules/nextcloud/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-nextcloud", + "version": "0.1.0", + "description": "nextcloud — file sync and share. Its client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/nextcloud/tools/index.ts b/modules/nextcloud/tools/index.ts new file mode 100644 index 0000000..224e8b7 --- /dev/null +++ b/modules/nextcloud/tools/index.ts @@ -0,0 +1,57 @@ +// nextcloud's tools — importing nextcloud's own client (novox/hq ADR 0044). occ runs inside the +// container; shares come over OCS. They return structured data; the mesh serves them through the +// sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { NextcloudClient } from "../client.js"; + +export function getNextcloudTools(nextcloud: NextcloudClient): ToolDefinition[] { + return [ + { + name: "nextcloud_users", + description: "List Nextcloud user accounts — uid and display name — via occ.", + input: {}, + run: async () => { + const users = nextcloud.listUsers(); + return { count: users.length, users }; + }, + }, + { + name: "nextcloud_shares", + description: "List Nextcloud shares — path, type, who it is shared with — via the OCS API.", + input: {}, + run: async () => { + const shares = await nextcloud.listShares(); + return { count: shares.length, shares }; + }, + }, + { + name: "nextcloud_apps", + description: "List Nextcloud apps, split into enabled and disabled, via occ.", + input: {}, + run: async () => nextcloud.listApps(), + }, + { + name: "nextcloud_occ", + description: + "Run an arbitrary occ admin command, e.g. status, 'config:system:get trusted_domains', " + + "user:list. occ is Nextcloud's CLI inside the container, run as the web user.", + input: { args: { type: "array", description: 'occ arguments, e.g. ["config:system:get","trusted_domains"]' } }, + run: async (args) => { + const occArgs = (args.args ?? []) as unknown[]; + if (!Array.isArray(occArgs) || occArgs.length === 0) throw new Error('args must be a non-empty array, e.g. ["status"]'); + return { output: nextcloud.occ(occArgs.map(String)) || "(no output)" }; + }, + }, + ]; +} + +// The tools exist only when the admin password can be resolved; without it, nextcloud contributes +// none rather than failing the whole tool runtime. +registerModuleTools("nextcloud", (env) => { + try { + return getNextcloudTools(NextcloudClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/nextcloud/tsconfig.json b/modules/nextcloud/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/nextcloud/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/nodered/client.ts b/modules/nodered/client.ts new file mode 100644 index 0000000..7b92f59 --- /dev/null +++ b/modules/nodered/client.ts @@ -0,0 +1,86 @@ +// 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. + +export interface NodeRedFlow { + /** The tab (flow) node id. */ + id: string; + label: string; + disabled: boolean; +} + +export interface NodeRedNodeModule { + name: string; + version: string; + types: string[]; +} + +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 url = env.MESH_NODERED_URL; + if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL"); + return new NodeRedClient(url, 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 }; + } +} diff --git a/modules/nodered/module.json b/modules/nodered/module.json index b5d90d9..1bca4dd 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -1,6 +1,12 @@ { "module": "nodered", "version": "1", + "emits": [ + "module.nodered.flows.deployed" + ], + "own-secrets": { + "broker": "/var/lib/nodered/broker" + }, "capabilities": [ "container-runtime" ], diff --git a/modules/nodered/package.json b/modules/nodered/package.json new file mode 100644 index 0000000..cfa5768 --- /dev/null +++ b/modules/nodered/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-nodered", + "version": "0.1.0", + "description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/nodered/tools/index.ts b/modules/nodered/tools/index.ts new file mode 100644 index 0000000..bd6c636 --- /dev/null +++ b/modules/nodered/tools/index.ts @@ -0,0 +1,64 @@ +// nodered's tools — importing nodered's own admin-API client (novox/hq ADR 0044). They return +// structured data; the mesh serves them through the sdk's tool harness. +// +// The deploy tool is nodered's one event source (novox/hq ADR 0046/0047): a successful deploy +// emits module.nodered.flows.deployed. nodered has nothing to observe on a timer, so there is no +// separate events entrypoint — the emit rides the action that causes it. The emit is best-effort: +// if no broker is bound, the deploy still succeeds and the announcement is simply skipped. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { emit } from "@novox/mesh-sdk/events"; +import { NodeRedClient } from "../client.js"; + +export function getNodeRedTools(nodered: NodeRedClient): ToolDefinition[] { + return [ + { + name: "nodered_list_flows", + description: "List Node-RED flows (tabs) — id, label, disabled state — and the total node count.", + input: {}, + run: async () => nodered.listFlows(), + }, + { + name: "nodered_list_nodes", + description: "List the Node-RED node modules installed in the runtime and their versions.", + input: {}, + run: async () => { + const nodes = await nodered.listNodes(); + return { count: nodes.length, nodes }; + }, + }, + { + name: "nodered_deploy", + description: + "Replace the whole Node-RED flow configuration and deploy it. `flows` is the full node " + + "array (as GET /flows returns). Emits module.nodered.flows.deployed on success.", + input: { + flows: { type: "array", description: "the full flow configuration — every node across every tab" }, + type: { type: "string", description: "deployment type: full (default), nodes, or flows" }, + }, + run: async (args) => { + const flows = args.flows as unknown[]; + if (!Array.isArray(flows)) throw new Error("flows must be an array of Node-RED nodes"); + const type = args.type ? String(args.type) : "full"; + const result = await nodered.deployFlows(flows, type); + // Best-effort announcement — a deploy must not fail because the broker is unbound here. + try { + await emit("module.nodered.flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type }); + } catch (err) { + console.error(`[nodered] deployed but could not emit: ${err}`); + } + return result; + }, + }, + ]; +} + +// The tools exist only when a Node-RED URL is configured; without one, nodered contributes none +// rather than failing the whole tool runtime. +registerModuleTools("nodered", (env) => { + try { + return getNodeRedTools(NodeRedClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/nodered/tsconfig.json b/modules/nodered/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/nodered/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts new file mode 100644 index 0000000..6c308c6 --- /dev/null +++ b/modules/tautulli/client.ts @@ -0,0 +1,98 @@ +// Tautulli's API client — tautulli's own code, living in the module (novox/hq ADR 0044). Both this +// module's tools and its events entrypoint import it, and nothing outside tautulli does. +// +// Tautulli speaks one endpoint: GET /api/v2?apikey=…&cmd=…&, answering +// { response: { result: "success" | "error", message, data } }. This client unwraps that envelope +// and hands back only the data. + +export interface TautulliSession { + user: string; + title: string; + mediaType: string; + state: string; + progressPercent: number; + player: string; +} + +export interface TautulliWatch { + /** Tautulli's history row id — the stable identity a recorded watch is diffed on. */ + id: number; + user: string; + title: string; + mediaType: string; + /** "watched" | "watching" | ... — Tautulli's own watched_status label. */ + watchedStatus: string; + percentComplete: number; + /** Unix seconds the play started, as Tautulli reports it. */ + date?: number; +} + +export interface TautulliHomeStat { + statId: string; + rows: Array>; +} + +export class TautulliClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The API key is read from MESH_TAUTULLI_APIKEY + * (Tautulli mints it in Settings → Web Interface); the base URL defaults to the local container. + * Throws when no key is configured — the module then contributes nothing rather than failing. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { + const url = env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`; + const apiKey = env.MESH_TAUTULLI_APIKEY; + if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY"); + return new TautulliClient(url, apiKey); + } + + /** Call one Tautulli command and return its unwrapped data, throwing on a non-success result. */ + private async cmd(command: string, params: Record = {}): Promise { + const q = new URLSearchParams({ apikey: this.apiKey, cmd: command, ...params }); + const res = await fetch(`${this.baseUrl}/api/v2?${q.toString()}`); + if (!res.ok) throw new Error(`Tautulli ${command}: ${res.status} ${await res.text()}`); + const body = (await res.json()).response ?? {}; + if (body.result !== "success") throw new Error(`Tautulli ${command}: ${body.message ?? "error"}`); + return body.data; + } + + async getActivity(): Promise<{ streamCount: number; sessions: TautulliSession[] }> { + const data = await this.cmd("get_activity"); + const sessions = ((data?.sessions ?? []) as any[]).map((s) => ({ + user: s.friendly_name ?? s.user ?? "unknown", + title: s.full_title ?? s.title ?? "unknown", + mediaType: s.media_type ?? "unknown", + state: s.state ?? "unknown", + progressPercent: Number(s.progress_percent ?? 0), + player: s.player ?? "unknown", + })); + return { streamCount: Number(data?.stream_count ?? sessions.length), sessions }; + } + + async getHistory(length = 25): Promise { + const data = await this.cmd("get_history", { length: String(length), order_column: "date", order_dir: "desc" }); + return ((data?.data ?? []) as any[]).map((r) => ({ + id: Number(r.row_id ?? r.id ?? r.reference_id ?? 0), + user: r.friendly_name ?? r.user ?? "unknown", + title: r.full_title ?? r.title ?? "unknown", + mediaType: r.media_type ?? "unknown", + watchedStatus: String(r.watched_status ?? ""), + percentComplete: Number(r.percent_complete ?? 0), + date: r.date != null ? Number(r.date) : undefined, + })); + } + + /** The home-page statistics blocks — most-watched shows, most-active users, and so on. */ + async getHomeStats(): Promise { + const data = (await this.cmd("get_home_stats")) as any[]; + return (data ?? []).map((s) => ({ statId: s.stat_id, rows: s.rows ?? [] })); + } +} diff --git a/modules/tautulli/index.ts b/modules/tautulli/index.ts new file mode 100644 index 0000000..faa6c77 --- /dev/null +++ b/modules/tautulli/index.ts @@ -0,0 +1,48 @@ +// tautulli's events. The tool runtime imports this once the broker is bound. It watches Tautulli's +// history and announces each newly recorded watch. +// +// Emits (novox/hq ADR 0046/0047): +// module.tautulli.watch.recorded — a play appeared in Tautulli's history +// +// Diffed on the history row id and primed silently on the first look, so a restart does not +// re-announce the whole existing history as freshly watched. + +import { emit } from "@novox/mesh-sdk/events"; +import { TautulliClient, type TautulliWatch } from "./client.js"; + +// Constructed lazily so an unconfigured node (no API key) loads this entrypoint without crashing +// the events host — it simply watches nothing. +let tautulli: TautulliClient | undefined; +try { + tautulli = TautulliClient.fromEnv(); +} catch (err) { + console.log(`[tautulli] not configured, not watching history: ${err}`); +} + +const seen = new Set(); +let primed = false; + +async function pollHistory(client: TautulliClient): Promise { + const history = await client.getHistory(25); + for (const w of history) { + if (w.id === 0 || seen.has(w.id)) continue; + if (primed) await emitWatch(w); + seen.add(w.id); + } + primed = true; +} + +async function emitWatch(w: TautulliWatch): Promise { + await emit("module.tautulli.watch.recorded", { + title: w.title, user: w.user, mediaType: w.mediaType, + watchedStatus: w.watchedStatus, percentComplete: w.percentComplete, at: w.date, + }); +} + +if (tautulli) { + const client = tautulli; + const run = (): void => void pollHistory(client).catch((err) => console.error(`[tautulli] ${err}`)); + setInterval(run, 60_000); + run(); + console.log("[tautulli] watching watch history"); +} diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index da05783..d3d3483 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -1,6 +1,12 @@ { "module": "tautulli", "version": "1", + "emits": [ + "module.tautulli.watch.recorded" + ], + "own-secrets": { + "broker": "/var/lib/tautulli/broker" + }, "capabilities": [ "container-runtime" ], diff --git a/modules/tautulli/package.json b/modules/tautulli/package.json new file mode 100644 index 0000000..9cd4335 --- /dev/null +++ b/modules/tautulli/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-tautulli", + "version": "0.1.0", + "description": "tautulli — Plex watch statistics. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/tautulli/tools/index.ts b/modules/tautulli/tools/index.ts new file mode 100644 index 0000000..b5697d6 --- /dev/null +++ b/modules/tautulli/tools/index.ts @@ -0,0 +1,44 @@ +// tautulli's tools — importing tautulli's own client (novox/hq ADR 0044). They return structured +// data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { TautulliClient } from "../client.js"; + +export function getTautulliTools(tautulli: TautulliClient): ToolDefinition[] { + return [ + { + name: "tautulli_activity", + description: "Current Plex activity as Tautulli sees it — who is streaming what, and progress.", + input: {}, + run: async () => tautulli.getActivity(), + }, + { + name: "tautulli_history", + description: "Recent Plex watch history — who watched what, and whether they finished.", + input: { length: { type: "number", description: "how many rows (default 25)" } }, + run: async (args) => { + const history = await tautulli.getHistory(args.length ? Number(args.length) : 25); + return { count: history.length, history }; + }, + }, + { + name: "tautulli_stats", + description: "Tautulli home statistics — most-watched media, most-active users, and platforms.", + input: {}, + run: async () => { + const stats = await tautulli.getHomeStats(); + return { count: stats.length, stats }; + }, + }, + ]; +} + +// The tools exist only when an API key can be resolved; without one, tautulli contributes none +// rather than failing the whole tool runtime. +registerModuleTools("tautulli", (env) => { + try { + return getTautulliTools(TautulliClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/tautulli/tsconfig.json b/modules/tautulli/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/tautulli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From aca237b2e06fe0b61e767cc294d9b365af124984 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:44:17 +0200 Subject: [PATCH 14/28] home-assistant, influxdb: full nox modules (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit home-assistant: states/call-service/config tools, emits state.changed bounded to actuator/contact domains (not attribute ticks), overridable via a watch allowlist. influxdb: health/buckets/flux-query tools — tools-only, since a time-series DB has no lifecycle event to emit here. Typecheck; manifests parse. --- modules/home-assistant/client.ts | 78 +++++++++++++++++++ modules/home-assistant/index.ts | 66 ++++++++++++++++ modules/home-assistant/module.json | 6 ++ modules/home-assistant/package.json | 14 ++++ modules/home-assistant/tools/index.ts | 76 ++++++++++++++++++ modules/home-assistant/tsconfig.json | 12 +++ modules/influxdb/client.ts | 106 ++++++++++++++++++++++++++ modules/influxdb/package.json | 14 ++++ modules/influxdb/tools/index.ts | 46 +++++++++++ modules/influxdb/tsconfig.json | 12 +++ 10 files changed, 430 insertions(+) create mode 100644 modules/home-assistant/client.ts create mode 100644 modules/home-assistant/index.ts create mode 100644 modules/home-assistant/package.json create mode 100644 modules/home-assistant/tools/index.ts create mode 100644 modules/home-assistant/tsconfig.json create mode 100644 modules/influxdb/client.ts create mode 100644 modules/influxdb/package.json create mode 100644 modules/influxdb/tools/index.ts create mode 100644 modules/influxdb/tsconfig.json diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts new file mode 100644 index 0000000..d7aefa9 --- /dev/null +++ b/modules/home-assistant/client.ts @@ -0,0 +1,78 @@ +// The Home Assistant API client — home-assistant's own code, living in the module (novox/hq +// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside +// home-assistant does. Talks to the HA REST API (/api) with a long-lived access token. + +export interface HAEntityState { + entity_id: string; + state: string; + attributes: Record; + last_changed?: string; + last_updated?: string; +} + +export interface HAConfig { + location_name?: string; + version?: string; + components?: string[]; + time_zone?: string; + state?: string; +} + +export class HomeAssistantClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The URL defaults to the local server (HA runs on + * the node); the token is the long-lived access token minted in HA's profile — required, since + * every API call is Bearer-authenticated and there is nowhere to discover it from. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { + const url = env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; + const token = env.MESH_HOMEASSISTANT_TOKEN; + if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN"); + return new HomeAssistantClient(url, token); + } + + private async request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + Accept: "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`Home Assistant API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + async getConfig(): Promise { + return (await this.request("/api/config")) as HAConfig; + } + + /** All entity states, or one entity when an id is given. */ + async getStates(): Promise { + return (await this.request("/api/states")) as HAEntityState[]; + } + + async getState(entityId: string): Promise { + return (await this.request(`/api/states/${encodeURIComponent(entityId)}`)) as HAEntityState; + } + + /** Call a service (e.g. switch.turn_on) — how "turn the light on" reaches HA. Returns the states + * the call changed. */ + async callService(domain: string, service: string, data: Record = {}): Promise { + return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, { + method: "POST", + body: JSON.stringify(data), + })) as HAEntityState[]; + } +} diff --git a/modules/home-assistant/index.ts b/modules/home-assistant/index.ts new file mode 100644 index 0000000..0c9f275 --- /dev/null +++ b/modules/home-assistant/index.ts @@ -0,0 +1,66 @@ +// home-assistant's events. The tool runtime imports this once the broker is bound. It watches the +// entities whose state changing is a real signal — a door opening, a lock turning, a switch +// flipping — and emits when one does. +// +// Emits (novox/hq ADR 0046/0047): +// module.home-assistant.state.changed — a watched entity's state value changed +// +// Bounded on purpose. Home Assistant has hundreds of entities and many (temperature, humidity, +// power draw) tick constantly; emitting every tick would be noise, not signal. So the watch is +// limited to actuator/contact domains where a change is an event a human would care about, and +// only the discrete `state` value is diffed — not the attribute bag. The set is overridable with +// MESH_HOMEASSISTANT_WATCH (comma-separated entity ids) for a node that wants a specific few. + +import { emit } from "@novox/mesh-sdk/events"; +import { HomeAssistantClient, type HAEntityState } from "./client.js"; + +const ha = HomeAssistantClient.fromEnv(); + +// Domains whose state changing is meaningful rather than a continuous reading. +const WATCH_DOMAINS = new Set(["binary_sensor", "lock", "cover", "switch", "input_boolean", "light", "alarm_control_panel"]); + +// An explicit allowlist of entity ids, if the node set one; otherwise fall back to the domain filter. +const watchList = (process.env.MESH_HOMEASSISTANT_WATCH ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +const watchSet = watchList.length ? new Set(watchList) : null; + +function isWatched(s: HAEntityState): boolean { + if (watchSet) return watchSet.has(s.entity_id); + return WATCH_DOMAINS.has(s.entity_id.split(".")[0] ?? ""); +} + +const nameOf = (s: HAEntityState): string | undefined => + typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined; + +// Last seen state per watched entity. Primed silently on the first poll so a restart does not +// re-announce the current state of everything as a fresh change. +const lastState = new Map(); +let primed = false; + +async function pollStates(): Promise { + const states = (await ha.getStates()).filter(isWatched); + for (const s of states) { + const prev = lastState.get(s.entity_id); + if (primed && prev !== undefined && prev !== s.state) { + await emit("module.home-assistant.state.changed", { + entity: s.entity_id, + name: nameOf(s), + from: prev, + to: s.state, + }); + } + lastState.set(s.entity_id, s.state); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[home-assistant] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollStates, 15_000); + +console.log("[home-assistant] watching entity states, emitting on change"); diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 6003229..2fefc03 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.home-assistant.state.changed" + ], + "own-secrets": { + "broker": "/var/lib/home-assistant/broker" + }, "listens": [ { "port": 8123, diff --git a/modules/home-assistant/package.json b/modules/home-assistant/package.json new file mode 100644 index 0000000..d5a8523 --- /dev/null +++ b/modules/home-assistant/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-home-assistant", + "version": "0.1.0", + "description": "home-assistant — home automation platform. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/home-assistant/tools/index.ts b/modules/home-assistant/tools/index.ts new file mode 100644 index 0000000..4325658 --- /dev/null +++ b/modules/home-assistant/tools/index.ts @@ -0,0 +1,76 @@ +// home-assistant's tools — its own code (novox/hq ADR 0044), importing its own client. They return +// structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { HomeAssistantClient, type HAEntityState } from "../client.js"; + +/** Trim an entity to the fields worth returning — the full attribute bag is large and mostly noise. */ +function summarize(s: HAEntityState): { entity_id: string; state: string; name?: string; last_changed?: string } { + return { + entity_id: s.entity_id, + state: s.state, + name: typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined, + last_changed: s.last_changed, + }; +} + +export function getHomeAssistantTools(ha: HomeAssistantClient): ToolDefinition[] { + return [ + { + name: "homeassistant_states", + description: + "Entity states from Home Assistant. With no argument, lists every entity; with `entity` (e.g. light.kitchen), returns just that one with its full attributes.", + input: { entity: { type: "string", description: "an entity id to fetch one entity; omitted lists all" } }, + run: async (args) => { + if (args.entity) { + const s = await ha.getState(String(args.entity)); + return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed }; + } + const states = await ha.getStates(); + return { count: states.length, entities: states.map(summarize) }; + }, + }, + { + name: "homeassistant_call_service", + description: + "Call a Home Assistant service — turn a switch/light on or off, lock a door, etc. Give the domain (e.g. switch), the service (e.g. turn_on), and optionally a target entity and extra data.", + input: { + domain: { type: "string", description: "the service domain, e.g. light, switch, lock" }, + service: { type: "string", description: "the service, e.g. turn_on, turn_off, toggle" }, + entity: { type: "string", description: "the entity id to target (optional)" }, + data: { type: "object", description: "extra service data merged into the call (optional)" }, + }, + run: async (args) => { + const data: Record = { ...(args.data as Record | undefined) }; + if (args.entity) data.entity_id = String(args.entity); + const changed = await ha.callService(String(args.domain), String(args.service), data); + return { changed: changed.map(summarize) }; + }, + }, + { + name: "homeassistant_config", + description: "Home Assistant instance config: version, location, timezone, loaded components.", + input: {}, + run: async () => { + const c = await ha.getConfig(); + return { + location: c.location_name, + version: c.version, + time_zone: c.time_zone, + state: c.state, + components: c.components?.length ?? 0, + }; + }, + }, + ]; +} + +// The tools exist only when a token is configured; without one, home-assistant contributes none +// rather than failing the whole runtime. +registerModuleTools("home-assistant", (env) => { + try { + return getHomeAssistantTools(HomeAssistantClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/home-assistant/tsconfig.json b/modules/home-assistant/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/home-assistant/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts new file mode 100644 index 0000000..12a81eb --- /dev/null +++ b/modules/influxdb/client.ts @@ -0,0 +1,106 @@ +// The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only +// this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token. + +export interface InfluxHealth { + name?: string; + status?: string; + message?: string; + version?: string; +} + +export interface InfluxBucket { + id: string; + name: string; + orgID?: string; + retentionSeconds?: number; +} + +export class InfluxDBClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + private readonly org: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. The token is the InfluxDB API token (the admin + * token the server was initialised with, or a scoped one) — required, since every /api/v2 call + * is token-authenticated. The org scopes bucket listing and queries. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient { + const url = env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; + const token = env.MESH_INFLUXDB_TOKEN; + if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN"); + const org = env.MESH_INFLUXDB_ORG ?? "mesh"; + return new InfluxDBClient(url, token, org); + } + + private async request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Token ${this.token}`, + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`); + return res; + } + + /** Server health — the one endpoint that needs no token, but we send it anyway. */ + async health(): Promise { + return (await (await this.request("/health")).json()) as InfluxHealth; + } + + async listBuckets(): Promise { + const body = (await (await this.request("/api/v2/buckets")).json()) as { buckets?: unknown[] }; + return (body.buckets ?? []).map((b) => { + const bucket = b as Record; + const rules = (bucket.retentionRules as { everySeconds?: number }[] | undefined) ?? []; + return { + id: String(bucket.id), + name: String(bucket.name), + orgID: bucket.orgID ? String(bucket.orgID) : undefined, + retentionSeconds: rules[0]?.everySeconds, + }; + }); + } + + /** + * Run a read-only Flux query and return the raw CSV InfluxDB answers with, plus a light parse + * into rows. Read-only: Flux has no write verb, and the token's own permissions bound the rest — + * this client never calls the write endpoint. + */ + async query(flux: string): Promise<{ csv: string; rows: Record[] }> { + const res = await this.request(`/api/v2/query?org=${encodeURIComponent(this.org)}`, { + method: "POST", + headers: { + "Content-Type": "application/vnd.flux", + Accept: "application/csv", + }, + body: flux, + }); + const csv = await res.text(); + return { csv, rows: parseAnnotatedCsv(csv) }; + } +} + +/** Parse InfluxDB's annotated CSV into rows keyed by column header. Annotation lines (starting + * with #) and blanks are skipped; the first non-annotation line is the header. */ +function parseAnnotatedCsv(csv: string): Record[] { + const lines = csv.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + if (lines.length < 2) return []; + const header = lines[0].split(","); + return lines.slice(1).map((line) => { + const cells = line.split(","); + const row: Record = {}; + header.forEach((h, i) => { + if (h) row[h] = cells[i] ?? ""; + }); + return row; + }); +} diff --git a/modules/influxdb/package.json b/modules/influxdb/package.json new file mode 100644 index 0000000..ad7b949 --- /dev/null +++ b/modules/influxdb/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-influxdb", + "version": "0.1.0", + "description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/influxdb/tools/index.ts b/modules/influxdb/tools/index.ts new file mode 100644 index 0000000..a2e13ce --- /dev/null +++ b/modules/influxdb/tools/index.ts @@ -0,0 +1,46 @@ +// influxdb's tools — its own code (novox/hq ADR 0044), importing its own client. They return +// structured data; the mesh serves them through the sdk's tool harness. Read-only: health, bucket +// listing, and Flux queries — no write path is exposed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { InfluxDBClient } from "../client.js"; + +export function getInfluxDBTools(influx: InfluxDBClient): ToolDefinition[] { + return [ + { + name: "influxdb_health", + description: "InfluxDB server health and version.", + input: {}, + run: async () => influx.health(), + }, + { + name: "influxdb_list_buckets", + description: "List InfluxDB buckets in the org, with their retention.", + input: {}, + run: async () => { + const buckets = await influx.listBuckets(); + return { count: buckets.length, buckets }; + }, + }, + { + name: "influxdb_query", + description: + "Run a read-only Flux query against InfluxDB and return the parsed rows (plus raw CSV). The query is Flux, e.g. from(bucket:\"default\") |> range(start:-1h).", + input: { flux: { type: "string", description: "the Flux query to run" } }, + run: async (args) => { + const { csv, rows } = await influx.query(String(args.flux)); + return { rowCount: rows.length, rows, csv }; + }, + }, + ]; +} + +// The tools exist only when a token is configured; without one, influxdb contributes none rather +// than failing the whole runtime. +registerModuleTools("influxdb", (env) => { + try { + return getInfluxDBTools(InfluxDBClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/influxdb/tsconfig.json b/modules/influxdb/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/influxdb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} From 81dc74734b8041d752a3fcd7551ab60b7b4af722 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:45:42 +0200 Subject: [PATCH 15/28] registry, verdaccio, portainer: full nox modules (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry (docker v2): catalog/tags/delete tools, emits image.pushed (a build's image is now pullable). verdaccio (npm): list/info tools, emits package.published. portainer: endpoints/stacks/containers tools — tools-only, since its only events are the underlying containers' lifecycle, which the host owns. Typecheck; manifests parse. --- modules/portainer/client.ts | 97 ++++++++++++++++++++++++++++++++ modules/portainer/package.json | 14 +++++ modules/portainer/tools/index.ts | 50 ++++++++++++++++ modules/portainer/tsconfig.json | 12 ++++ modules/registry/client.ts | 81 ++++++++++++++++++++++++++ modules/registry/index.ts | 51 +++++++++++++++++ modules/registry/module.json | 6 ++ modules/registry/package.json | 14 +++++ modules/registry/tools/index.ts | 59 +++++++++++++++++++ modules/registry/tsconfig.json | 12 ++++ modules/verdaccio/client.ts | 81 ++++++++++++++++++++++++++ modules/verdaccio/index.ts | 45 +++++++++++++++ modules/verdaccio/module.json | 6 ++ modules/verdaccio/package.json | 14 +++++ modules/verdaccio/tools/index.ts | 35 ++++++++++++ modules/verdaccio/tsconfig.json | 12 ++++ 16 files changed, 589 insertions(+) create mode 100644 modules/portainer/client.ts create mode 100644 modules/portainer/package.json create mode 100644 modules/portainer/tools/index.ts create mode 100644 modules/portainer/tsconfig.json create mode 100644 modules/registry/client.ts create mode 100644 modules/registry/index.ts create mode 100644 modules/registry/package.json create mode 100644 modules/registry/tools/index.ts create mode 100644 modules/registry/tsconfig.json create mode 100644 modules/verdaccio/client.ts create mode 100644 modules/verdaccio/index.ts create mode 100644 modules/verdaccio/package.json create mode 100644 modules/verdaccio/tools/index.ts create mode 100644 modules/verdaccio/tsconfig.json diff --git a/modules/portainer/client.ts b/modules/portainer/client.ts new file mode 100644 index 0000000..79eaa29 --- /dev/null +++ b/modules/portainer/client.ts @@ -0,0 +1,97 @@ +// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0044). +// portainer is tools-only: its "events" would really be the underlying containers' lifecycle, +// which the host owns and emits — so this module reads Portainer's own resources (endpoints, +// stacks, containers) and exposes them, and stops there. + +export interface PortainerEndpoint { + id: number; + name: string; + type: number; + url: string; + status: number; +} + +export interface PortainerStack { + id: number; + name: string; + type: number; + endpointId: number; + status: number; +} + +export interface PortainerContainer { + id: string; + names: string[]; + image: string; + state: string; + status: string; +} + +export class PortainerClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local + * dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in + * Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module + * exposes nothing rather than calling Portainer unauthenticated. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { + const url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; + const token = env.MESH_PORTAINER_TOKEN; + if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN"); + return new PortainerClient(url, token); + } + + private async get(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } }); + if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`); + return res.json() as Promise; + } + + /** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */ + async listEndpoints(): Promise { + const raw = await this.get("/api/endpoints"); + return (raw ?? []).map((e) => ({ + id: e.Id, + name: e.Name, + type: e.Type, + url: e.URL, + status: e.Status, + })); + } + + /** The stacks (compose/swarm deployments) Portainer knows about. */ + async listStacks(): Promise { + const raw = await this.get("/api/stacks"); + return (raw ?? []).map((s) => ({ + id: s.Id, + name: s.Name, + type: s.Type, + endpointId: s.EndpointId, + status: s.Status, + })); + } + + /** + * The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped + * containers, so the caller sees the whole picture rather than only what is running. + */ + async listContainers(endpointId: number): Promise { + const raw = await this.get(`/api/endpoints/${endpointId}/docker/containers/json?all=1`); + return (raw ?? []).map((c) => ({ + id: c.Id, + names: c.Names ?? [], + image: c.Image, + state: c.State, + status: c.Status, + })); + } +} diff --git a/modules/portainer/package.json b/modules/portainer/package.json new file mode 100644 index 0000000..77ee328 --- /dev/null +++ b/modules/portainer/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-portainer", + "version": "0.1.0", + "description": "portainer — container management UI. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/portainer/tools/index.ts b/modules/portainer/tools/index.ts new file mode 100644 index 0000000..192d830 --- /dev/null +++ b/modules/portainer/tools/index.ts @@ -0,0 +1,50 @@ +// portainer's tools — its own code (novox/hq ADR 0044), importing portainer's own client. They +// return structured data; the mesh serves them through the sdk's tool harness. portainer is +// tools-only (no events entrypoint): a container starting or stopping is the host's signal to emit, +// not Portainer's to re-announce. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PortainerClient } from "../client.js"; + +export function getPortainerTools(portainer: PortainerClient): ToolDefinition[] { + return [ + { + name: "portainer_endpoints", + description: "List the environments (endpoints) Portainer manages — each a Docker host or cluster.", + input: {}, + run: async () => { + const endpoints = await portainer.listEndpoints(); + return { count: endpoints.length, endpoints }; + }, + }, + { + name: "portainer_stacks", + description: "List the stacks (compose/swarm deployments) Portainer knows about.", + input: {}, + run: async () => { + const stacks = await portainer.listStacks(); + return { count: stacks.length, stacks }; + }, + }, + { + name: "portainer_containers", + description: "List the containers on one Portainer endpoint, including stopped ones.", + input: { endpoint: { type: "number", description: "the endpoint id (see portainer_endpoints)" } }, + run: async (args) => { + const endpointId = Number(args.endpoint); + const containers = await portainer.listContainers(endpointId); + return { endpointId, count: containers.length, containers }; + }, + }, + ]; +} + +// The tools exist only when a token is configured; without one, portainer contributes none rather +// than failing the whole runtime. +registerModuleTools("portainer", (env) => { + try { + return getPortainerTools(PortainerClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/portainer/tsconfig.json b/modules/portainer/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/portainer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/registry/client.ts b/modules/registry/client.ts new file mode 100644 index 0000000..d78d61f --- /dev/null +++ b/modules/registry/client.ts @@ -0,0 +1,81 @@ +// The Docker Registry v2 client — registry's own code, living in the module (novox/hq ADR 0044). +// Ported from the shared hal sdk, where a change to the registry API rebuilt everything; here it +// rebuilds only registry. Both this module's tools and its events entrypoint import it. + +export interface RegistryImage { + repo: string; + tag: string; + digest: string; +} + +export class RegistryClient { + readonly baseUrl: string; + + // Auth is optional: a mesh-internal registry often runs open on the node, so a Basic header is + // sent only when credentials were configured — an empty one would look like a failed login. + constructor( + url: string, + private readonly authHeader?: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. The URL is MESH_REGISTRY_URL (or the local + * registry port), and credentials — if the registry requires them — are MESH_REGISTRY_USER and + * MESH_REGISTRY_PASSWORD. Throws when no URL is configured, so a misconfigured module exposes + * nothing rather than talking to the wrong place. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): RegistryClient { + const url = env.MESH_REGISTRY_URL ?? `http://127.0.0.1:${env.REGISTRY_PORT ?? "5000"}`; + if (!url) throw new Error("no registry URL — set MESH_REGISTRY_URL"); + const user = env.MESH_REGISTRY_USER; + const password = env.MESH_REGISTRY_PASSWORD; + const authHeader = + user && password ? `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}` : undefined; + return new RegistryClient(url, authHeader); + } + + private headers(extra: Record = {}): Record { + return { ...(this.authHeader ? { Authorization: this.authHeader } : {}), ...extra }; + } + + private async getJson(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { headers: this.headers() }); + if (!res.ok) throw new Error(`Registry ${path}: ${res.status} ${await res.text()}`); + return res.json() as Promise; + } + + /** The catalog — every repository the registry holds. */ + async listRepositories(): Promise { + const data = await this.getJson<{ repositories: string[] | null }>("/v2/_catalog"); + return data.repositories ?? []; + } + + /** The tags of one repository. */ + async listTags(repo: string): Promise { + const data = await this.getJson<{ tags: string[] | null }>(`/v2/${repo}/tags/list`); + return data.tags ?? []; + } + + /** The content digest of a repo:tag — the stable identity a tag currently points at. */ + async getManifestDigest(repo: string, tag: string): Promise { + const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${tag}`, { + method: "HEAD", + headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }), + }); + if (!res.ok) throw new Error(`Registry manifest ${repo}:${tag}: ${res.status} ${await res.text()}`); + const digest = res.headers.get("docker-content-digest"); + if (!digest) throw new Error(`no Docker-Content-Digest for ${repo}:${tag}`); + return digest; + } + + /** Delete a manifest by digest. Garbage collection reclaims the storage later. */ + async deleteManifest(repo: string, digest: string): Promise { + const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${digest}`, { + method: "DELETE", + headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }), + }); + if (!res.ok) throw new Error(`Registry delete ${repo}@${digest}: ${res.status} ${await res.text()}`); + } +} diff --git a/modules/registry/index.ts b/modules/registry/index.ts new file mode 100644 index 0000000..6bdd54f --- /dev/null +++ b/modules/registry/index.ts @@ -0,0 +1,51 @@ +// registry's events. The tool runtime imports this once the broker is bound. +// +// Emits (novox/hq ADR 0046/0047): +// module.registry.image.pushed — a new image (repo:tag) was published to the registry +// +// This is a genuinely useful signal: a build finished and its image is now pullable, so anything +// on the mesh that redeploys, mirrors or announces releases can react without polling the registry +// itself. It is discovered by diffing the catalog and each repo's tags — the registry has no push +// webhook of its own, so the module watches for it. +// +// The polling is deliberately unhurried: a new image a minute late is still the event, whereas +// hammering the registry's catalog for immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { RegistryClient } from "./client.js"; + +const registry = RegistryClient.fromEnv(); + +// Every repo:tag we have already accounted for. Primed silently on the first look so a registry +// that was already full when this started does not announce its whole history as freshly pushed. +const seen = new Set(); +let primed = false; + +async function pollCatalog(): Promise { + const repos = await registry.listRepositories(); + for (const repo of repos) { + let tags: string[]; + try { + tags = await registry.listTags(repo); + } catch { + continue; // a repo can vanish between catalog and tag read — skip it, catch it next tick + } + for (const tag of tags) { + const id = `${repo}:${tag}`; + if (!seen.has(id)) { + if (primed) await emit("module.registry.image.pushed", { repo, tag }); + seen.add(id); + } + } + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[registry] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollCatalog, 60_000); + +console.log("[registry] watching the catalog for newly pushed images"); diff --git a/modules/registry/module.json b/modules/registry/module.json index 6fe2ba0..0aa752e 100644 --- a/modules/registry/module.json +++ b/modules/registry/module.json @@ -16,6 +16,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.registry.image.pushed" + ], + "own-secrets": { + "broker": "/var/lib/registry/broker" + }, "serves": { "artifact-store": { "port": 5000 diff --git a/modules/registry/package.json b/modules/registry/package.json new file mode 100644 index 0000000..74a4955 --- /dev/null +++ b/modules/registry/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-registry", + "version": "0.1.0", + "description": "registry — private Docker image registry. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/registry/tools/index.ts b/modules/registry/tools/index.ts new file mode 100644 index 0000000..e354baf --- /dev/null +++ b/modules/registry/tools/index.ts @@ -0,0 +1,59 @@ +// registry's tools — moved here from the shared sdk (novox/hq ADR 0044), importing registry's own +// client. They return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { RegistryClient } from "../client.js"; + +export function getRegistryTools(registry: RegistryClient): ToolDefinition[] { + return [ + { + name: "registry_list", + description: "List every repository in the Docker registry (the catalog).", + input: {}, + run: async () => { + const repositories = await registry.listRepositories(); + return { count: repositories.length, repositories }; + }, + }, + { + name: "registry_tags", + description: "List the tags of one repository in the Docker registry.", + input: { repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" } }, + run: async (args) => { + const repo = String(args.repo); + const tags = await registry.listTags(repo); + return { repo, count: tags.length, tags }; + }, + }, + { + name: "registry_delete_image", + description: + "Delete an image tag from the registry (DESTRUCTIVE). Removes the manifest; storage is reclaimed by garbage collection later. Requires confirm: true.", + input: { + repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" }, + tag: { type: "string", description: "the tag to delete, e.g. 'latest'" }, + confirm: { type: "boolean", description: "must be true to actually delete" }, + }, + run: async (args) => { + const repo = String(args.repo); + const tag = String(args.tag); + if (args.confirm !== true) { + return { deleted: false, reason: "confirm must be true to delete an image" }; + } + const digest = await registry.getManifestDigest(repo, tag); + await registry.deleteManifest(repo, digest); + return { deleted: true, repo, tag, digest, note: "run registry garbage collection to reclaim storage" }; + }, + }, + ]; +} + +// The tools exist only when a registry URL is configured; otherwise registry contributes none +// rather than failing the whole runtime. +registerModuleTools("registry", (env) => { + try { + return getRegistryTools(RegistryClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/registry/tsconfig.json b/modules/registry/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/registry/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/verdaccio/client.ts b/modules/verdaccio/client.ts new file mode 100644 index 0000000..9948fdf --- /dev/null +++ b/modules/verdaccio/client.ts @@ -0,0 +1,81 @@ +// The Verdaccio (npm registry) client — verdaccio's own code, living in the module (novox/hq +// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside +// verdaccio does. + +export interface VerdaccioPackage { + name: string; + version?: string; + description?: string; + time?: string; +} + +export interface PackageInfo { + name: string; + latest?: string; + versions: string[]; + description?: string; + modified?: string; +} + +export class VerdaccioClient { + readonly baseUrl: string; + + // A bearer token is optional: package listing and reading are public on most registries, so the + // token is sent only when configured, for a registry that gates reads behind auth. + constructor( + url: string, + private readonly token?: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. The URL is MESH_VERDACCIO_URL (or the local + * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient { + const url = env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`; + if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL"); + return new VerdaccioClient(url, env.MESH_VERDACCIO_TOKEN); + } + + private async getJson(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + headers: { + Accept: "application/json", + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + }, + }); + if (!res.ok) throw new Error(`Verdaccio ${path}: ${res.status} ${await res.text()}`); + return res.json() as Promise; + } + + /** + * Every package the registry hosts, from Verdaccio's own web API — the same list its UI shows. + * Each entry carries the latest version and the time it was last published. + */ + async listPackages(): Promise { + const raw = await this.getJson("/-/verdaccio/data/packages"); + return (raw ?? []).map((p) => ({ + name: p.name, + version: p.version ?? p["dist-tags"]?.latest, + description: p.description, + time: p.time?.modified ?? p.time, + })); + } + + /** + * The full detail of one package — its dist-tags, every published version, and timestamps — + * from the standard npm packument endpoint (`GET /`). + */ + async getPackageInfo(name: string): Promise { + const doc = await this.getJson(`/${encodeURIComponent(name).replace(/%2F/g, "/")}`); + return { + name: doc.name ?? name, + latest: doc["dist-tags"]?.latest, + versions: Object.keys(doc.versions ?? {}), + description: doc.description, + modified: doc.time?.modified, + }; + } +} diff --git a/modules/verdaccio/index.ts b/modules/verdaccio/index.ts new file mode 100644 index 0000000..6eeb889 --- /dev/null +++ b/modules/verdaccio/index.ts @@ -0,0 +1,45 @@ +// verdaccio's events. The tool runtime imports this once the broker is bound. +// +// Emits (novox/hq ADR 0046/0047): +// module.verdaccio.package.published — a new package version was published to the registry +// +// A genuinely useful signal: a package was just published, so anything on the mesh that pins, +// mirrors or announces dependency releases can react without polling the registry. Verdaccio has +// no publish webhook, so the module discovers it by diffing the package list's latest versions. +// +// The polling is deliberately unhurried: a publish a minute late is still the event, whereas +// hammering the registry for immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { VerdaccioClient } from "./client.js"; + +const verdaccio = VerdaccioClient.fromEnv(); + +// The latest version we have seen per package name. Primed silently on the first look so a registry +// that was already populated when this started does not announce its whole catalog as freshly +// published. +const latest = new Map(); +let primed = false; + +async function pollPackages(): Promise { + const packages = await verdaccio.listPackages(); + for (const pkg of packages) { + if (!pkg.version) continue; + const known = latest.get(pkg.name); + if (known !== pkg.version) { + // A name we have not seen, or a name whose latest version moved — both are a publish. + if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version }); + latest.set(pkg.name, pkg.version); + } + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[verdaccio] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollPackages, 60_000); + +console.log("[verdaccio] watching the registry for newly published packages"); diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index 34196f2..f8887d7 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.verdaccio.package.published" + ], + "own-secrets": { + "broker": "/var/lib/verdaccio/broker" + }, "listens": [ { "port": 4873, diff --git a/modules/verdaccio/package.json b/modules/verdaccio/package.json new file mode 100644 index 0000000..644ef27 --- /dev/null +++ b/modules/verdaccio/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-verdaccio", + "version": "0.1.0", + "description": "verdaccio — private npm registry. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/verdaccio/tools/index.ts b/modules/verdaccio/tools/index.ts new file mode 100644 index 0000000..055745d --- /dev/null +++ b/modules/verdaccio/tools/index.ts @@ -0,0 +1,35 @@ +// verdaccio's tools — its own code (novox/hq ADR 0044), importing verdaccio's own client. They +// return structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { VerdaccioClient } from "../client.js"; + +export function getVerdaccioTools(verdaccio: VerdaccioClient): ToolDefinition[] { + return [ + { + name: "verdaccio_list_packages", + description: "List every package hosted on the private npm registry, with each one's latest version.", + input: {}, + run: async () => { + const packages = await verdaccio.listPackages(); + return { count: packages.length, packages }; + }, + }, + { + name: "verdaccio_package_info", + description: "Details of one package on the registry: its latest tag, all published versions, and description.", + input: { name: { type: "string", description: "the package name, e.g. '@novox/mesh-sdk'" } }, + run: async (args) => verdaccio.getPackageInfo(String(args.name)), + }, + ]; +} + +// The tools exist only when a registry URL is configured; otherwise verdaccio contributes none +// rather than failing the whole runtime. +registerModuleTools("verdaccio", (env) => { + try { + return getVerdaccioTools(VerdaccioClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/verdaccio/tsconfig.json b/modules/verdaccio/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/verdaccio/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From 8f8e0153b171d2ed4d7daa383726e78b4f968fee Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:46:24 +0200 Subject: [PATCH 16/28] searxng, icecast, photos: full nox modules (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searxng: a search tool — tools-only, a stateless search has nothing to observe. icecast: status tool, emits stream.started/.stopped by diffing live mountpoints. photos: status/albums/recent tools, emits item.added (immich-shaped API, coded defensively since the stub ships a placeholder image — flagged in the code). Typecheck; manifests parse. --- modules/icecast/client.ts | 87 ++++++++++++++++++++++++++++ modules/icecast/index.ts | 49 ++++++++++++++++ modules/icecast/module.json | 7 ++- modules/icecast/package.json | 14 +++++ modules/icecast/tools/index.ts | 26 +++++++++ modules/icecast/tsconfig.json | 12 ++++ modules/photos/client.ts | 100 +++++++++++++++++++++++++++++++++ modules/photos/index.ts | 40 +++++++++++++ modules/photos/module.json | 6 ++ modules/photos/package.json | 14 +++++ modules/photos/tools/index.ts | 43 ++++++++++++++ modules/photos/tsconfig.json | 12 ++++ modules/searxng/client.ts | 76 +++++++++++++++++++++++++ modules/searxng/package.json | 14 +++++ modules/searxng/tools/index.ts | 37 ++++++++++++ modules/searxng/tsconfig.json | 12 ++++ 16 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 modules/icecast/client.ts create mode 100644 modules/icecast/index.ts create mode 100644 modules/icecast/package.json create mode 100644 modules/icecast/tools/index.ts create mode 100644 modules/icecast/tsconfig.json create mode 100644 modules/photos/client.ts create mode 100644 modules/photos/index.ts create mode 100644 modules/photos/package.json create mode 100644 modules/photos/tools/index.ts create mode 100644 modules/photos/tsconfig.json create mode 100644 modules/searxng/client.ts create mode 100644 modules/searxng/package.json create mode 100644 modules/searxng/tools/index.ts create mode 100644 modules/searxng/tsconfig.json diff --git a/modules/icecast/client.ts b/modules/icecast/client.ts new file mode 100644 index 0000000..d074b55 --- /dev/null +++ b/modules/icecast/client.ts @@ -0,0 +1,87 @@ +// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0044). Icecast is an +// audio streaming server: sources push mountpoints in, listeners pull them out. Its `/status-json.xsl` +// endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and +// the basis for both the status tool and the stream started/stopped events. + +export interface IcecastMount { + /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ + mount: string; + listeners: number; + name?: string; + description?: string; + streamStart?: string; + bitrate?: number; + serverType?: string; +} + +export interface IcecastStatus { + mounts: IcecastMount[]; + totalListeners: number; + mountCount: number; +} + +// The raw shape of one in status-json.xsl. `source` is absent with no mounts, a lone object +// with one, and an array with several — normalised below. +interface RawSource { + listenurl?: string; + listeners?: number; + server_name?: string; + server_description?: string; + stream_start_iso8601?: string; + stream_start?: string; + bitrate?: number; + server_type?: string; +} + +export class IcecastClient { + readonly baseUrl: string; + private readonly authHeader?: string; + + constructor(url: string, adminUser?: string, adminPassword?: string) { + this.baseUrl = url.replace(/\/$/, ""); + // status-json.xsl is public on most instances; basic auth is used only where admin locked it down. + if (adminUser && adminPassword) { + this.authHeader = "Basic " + Buffer.from(`${adminUser}:${adminPassword}`).toString("base64"); + } + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { + const url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`; + return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, env.MESH_ICECAST_ADMIN_PASSWORD); + } + + async getStatus(): Promise { + const headers: Record = { Accept: "application/json" }; + if (this.authHeader) headers.Authorization = this.authHeader; + const res = await fetch(`${this.baseUrl}/status-json.xsl`, { headers }); + if (!res.ok) throw new Error(`Icecast status: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { icestats?: { source?: RawSource | RawSource[] } }; + + const raw = data.icestats?.source; + const sources: RawSource[] = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; + const mounts = sources.map((s) => ({ + mount: this.mountFromUrl(s.listenurl), + listeners: s.listeners ?? 0, + name: s.server_name, + description: s.server_description, + streamStart: s.stream_start_iso8601 ?? s.stream_start, + bitrate: s.bitrate, + serverType: s.server_type, + })); + return { + mounts, + totalListeners: mounts.reduce((n, m) => n + m.listeners, 0), + mountCount: mounts.length, + }; + } + + /** Icecast names the mount only inside the listen URL's path; pull it back out (fall back to raw). */ + private mountFromUrl(listenurl?: string): string { + if (!listenurl) return "unknown"; + try { + return new URL(listenurl).pathname; + } catch { + return listenurl; + } + } +} diff --git a/modules/icecast/index.ts b/modules/icecast/index.ts new file mode 100644 index 0000000..38c2080 --- /dev/null +++ b/modules/icecast/index.ts @@ -0,0 +1,49 @@ +// icecast's events. The tool runtime imports this once the broker is bound. It watches the streaming +// server and announces when a mountpoint goes live or drops. +// +// Emits (novox/hq ADR 0046/0047): +// module.icecast.stream.started / .stopped — a mountpoint appeared or disappeared +// +// A mountpoint exists only while a source is connected, so the set of mounts diffed over time is +// exactly the set of live streams. Primed silently on the first look, so streams already running when +// this starts are not announced as freshly begun. Polling is unhurried — a stream a few seconds late +// is still the event, and hammering the status endpoint buys immediacy nobody asked for. + +import { emit } from "@novox/mesh-sdk/events"; +import { IcecastClient, type IcecastMount } from "./client.js"; + +const icecast = IcecastClient.fromEnv(); + +const live = new Map(); +let primed = false; + +async function pollMounts(): Promise { + const { mounts } = await icecast.getStatus(); + const now = new Map(mounts.map((m) => [m.mount, m])); + if (primed) { + for (const [mount, m] of now) { + if (!live.has(mount)) { + await emit("module.icecast.stream.started", { + mount, + name: m.name, + description: m.description, + bitrate: m.bitrate, + }); + } + } + for (const [mount, m] of live) { + if (!now.has(mount)) { + await emit("module.icecast.stream.stopped", { mount, name: m.name }); + } + } + } + live.clear(); + for (const [mount, m] of now) live.set(mount, m); + primed = true; +} + +const run = (): void => void pollMounts().catch((err) => console.error(`[icecast] ${err}`)); +setInterval(run, 15_000); +run(); + +console.log("[icecast] watching mountpoints for streams starting and stopping"); diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 77d208e..017a6e8 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -4,10 +4,15 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.icecast.stream.started", + "module.icecast.stream.stopped" + ], "own-secrets": { "source": "/var/lib/icecast-module/source.secret", "admin": "/var/lib/icecast-module/admin.secret", - "relay": "/var/lib/icecast-module/relay.secret" + "relay": "/var/lib/icecast-module/relay.secret", + "broker": "/var/lib/icecast-module/broker" }, "listens": [ { diff --git a/modules/icecast/package.json b/modules/icecast/package.json new file mode 100644 index 0000000..fa2b925 --- /dev/null +++ b/modules/icecast/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-icecast", + "version": "0.1.0", + "description": "icecast — audio streaming server. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/icecast/tools/index.ts b/modules/icecast/tools/index.ts new file mode 100644 index 0000000..86b5828 --- /dev/null +++ b/modules/icecast/tools/index.ts @@ -0,0 +1,26 @@ +// icecast's tools (novox/hq ADR 0044), importing icecast's own client. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { IcecastClient } from "../client.js"; + +export function getIcecastTools(icecast: IcecastClient): ToolDefinition[] { + return [ + { + name: "icecast_status", + description: "Icecast streaming status: live mountpoints, each with its listener count, plus the total.", + input: {}, + run: async () => { + const status = await icecast.getStatus(); + return status; + }, + }, + ]; +} + +registerModuleTools("icecast", (env) => { + try { + return getIcecastTools(IcecastClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/icecast/tsconfig.json b/modules/icecast/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/icecast/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/photos/client.ts b/modules/photos/client.ts new file mode 100644 index 0000000..d0a8b66 --- /dev/null +++ b/modules/photos/client.ts @@ -0,0 +1,100 @@ +// The photo app's API client — photos' own code, living in the module (novox/hq ADR 0044). The +// module packages a self-hosted photo library (immich-shaped: a REST API under `/api`, authenticated +// by an API key sent as the `x-api-key` header). The client speaks only what the tools and the +// item-added event need: server version and statistics, albums, and recent assets. + +export interface PhotosServerInfo { + version: string; + photos?: number; + videos?: number; + usageBytes?: number; +} + +export interface PhotosAlbum { + id: string; + name: string; + assetCount: number; + shared: boolean; +} + +export interface PhotosAsset { + id: string; + fileName?: string; + type?: string; + createdAt?: string; +} + +export class PhotosClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's environment. Unlike an open service, a photo library holds private data: + * the API key is required, and without it the module contributes nothing rather than reaching an + * unauthenticated endpoint. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { + const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; + const key = env.MESH_PHOTOS_API_KEY; + if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); + return new PhotosClient(url, key); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Accept: "application/json", + "x-api-key": this.apiKey, + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`photos API ${path}: ${res.status} ${await res.text()}`); + return (await res.json()) as T; + } + + async getServerInfo(): Promise { + const version = await this.request<{ major: number; minor: number; patch: number }>("/api/server/version"); + const info: PhotosServerInfo = { version: `${version.major}.${version.minor}.${version.patch}` }; + // Statistics needs an admin key; a scoped key still gives version, so treat stats as best-effort. + try { + const stats = await this.request<{ photos: number; videos: number; usage: number }>("/api/server/statistics"); + info.photos = stats.photos; + info.videos = stats.videos; + info.usageBytes = stats.usage; + } catch { + // leave the counts unset + } + return info; + } + + async getAlbums(): Promise { + const albums = await this.request< + { id: string; albumName: string; assetCount: number; shared: boolean }[] + >("/api/albums"); + return albums.map((a) => ({ id: a.id, name: a.albumName, assetCount: a.assetCount, shared: a.shared })); + } + + /** Recent assets, newest first — via the metadata search, which is how this API returns a bounded, + * ordered slice of the library. The poll that emits item.added builds on this. */ + async getRecentAssets(limit = 20): Promise { + const data = await this.request<{ + assets?: { items?: { id: string; originalFileName?: string; type?: string; fileCreatedAt?: string }[] }; + }>("/api/search/metadata", { + method: "POST", + body: JSON.stringify({ size: limit, order: "desc" }), + }); + const items = data.assets?.items ?? []; + return items.map((a) => ({ + id: a.id, + fileName: a.originalFileName, + type: a.type, + createdAt: a.fileCreatedAt, + })); + } +} diff --git a/modules/photos/index.ts b/modules/photos/index.ts new file mode 100644 index 0000000..45cf390 --- /dev/null +++ b/modules/photos/index.ts @@ -0,0 +1,40 @@ +// photos' events. The tool runtime imports this once the broker is bound. It watches the library and +// announces newly added assets. +// +// Emits (novox/hq ADR 0046/0047): +// module.photos.item.added — a new asset appeared in the library +// +// New assets are found by diffing the recent-assets slice by asset id. Primed silently on the first +// look, so a restart does not re-announce the whole recent list as freshly added. + +import { emit } from "@novox/mesh-sdk/events"; +import { PhotosClient } from "./client.js"; + +const photos = PhotosClient.fromEnv(); + +const seen = new Set(); +let primed = false; + +async function pollRecent(): Promise { + const items = await photos.getRecentAssets(50); + for (const asset of items) { + if (!seen.has(asset.id)) { + if (primed) { + await emit("module.photos.item.added", { + id: asset.id, + fileName: asset.fileName, + kind: asset.type, + createdAt: asset.createdAt, + }); + } + seen.add(asset.id); + } + } + primed = true; +} + +const run = (): void => void pollRecent().catch((err) => console.error(`[photos] ${err}`)); +setInterval(run, 60_000); +run(); + +console.log("[photos] watching for newly added assets"); diff --git a/modules/photos/module.json b/modules/photos/module.json index e3bac87..72e6cf6 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -15,6 +15,12 @@ "secrets": { "s3-bucket": "/etc/photos/store.secret" }, + "emits": [ + "module.photos.item.added" + ], + "own-secrets": { + "broker": "/etc/photos/broker" + }, "resources": [ { "id": "config", diff --git a/modules/photos/package.json b/modules/photos/package.json new file mode 100644 index 0000000..0bb719b --- /dev/null +++ b/modules/photos/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-photos", + "version": "0.1.0", + "description": "photos — self-hosted photo library. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/photos/tools/index.ts b/modules/photos/tools/index.ts new file mode 100644 index 0000000..b8c9619 --- /dev/null +++ b/modules/photos/tools/index.ts @@ -0,0 +1,43 @@ +// photos' tools (novox/hq ADR 0044), importing photos' own client. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PhotosClient } from "../client.js"; + +export function getPhotosTools(photos: PhotosClient): ToolDefinition[] { + return [ + { + name: "photos_status", + description: "Photo library status: server version and, where the key allows, photo/video counts and storage used.", + input: {}, + run: async () => { + const [server, albums] = await Promise.all([photos.getServerInfo(), photos.getAlbums()]); + return { server, albumCount: albums.length }; + }, + }, + { + name: "photos_albums", + description: "List the albums in the photo library, each with its asset count and whether it is shared.", + input: {}, + run: async () => { + const albums = await photos.getAlbums(); + return { count: albums.length, albums }; + }, + }, + { + name: "photos_recent", + description: "Most recently added assets in the photo library, newest first.", + input: { limit: { type: "number", description: "how many assets (default 20)" } }, + run: async (args) => ({ items: await photos.getRecentAssets(args.limit ? Number(args.limit) : 20) }), + }, + ]; +} + +// The tools exist only when an API key is configured; without one, photos contributes none rather +// than reaching an unauthenticated endpoint or failing the whole runtime. +registerModuleTools("photos", (env) => { + try { + return getPhotosTools(PhotosClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/photos/tsconfig.json b/modules/photos/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/photos/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/searxng/client.ts b/modules/searxng/client.ts new file mode 100644 index 0000000..bfeb7a9 --- /dev/null +++ b/modules/searxng/client.ts @@ -0,0 +1,76 @@ +// SearXNG's API client — searxng's own code, living in the module (novox/hq ADR 0044). SearXNG is a +// privacy-respecting metasearch engine: it forwards a query to many upstream engines and returns the +// merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool +// useful; the client speaks only that. No credential — the instance is reached inside the mesh. + +export interface SearxResult { + title: string; + url: string; + content?: string; + engine?: string; + category?: string; + score?: number; +} + +export interface SearxSearch { + query: string; + numberOfResults: number; + results: SearxResult[]; + suggestions: string[]; + answers: string[]; +} + +export interface SearxOptions { + categories?: string; + language?: string; + pageno?: number; +} + +export class SearxngClient { + readonly baseUrl: string; + + constructor(url: string) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL + * is all it takes — defaulting to the container's own listen port. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient { + const url = env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`; + return new SearxngClient(url); + } + + async search(query: string, opts: SearxOptions = {}): Promise { + const params = new URLSearchParams({ q: query, format: "json" }); + if (opts.categories) params.set("categories", opts.categories); + if (opts.language) params.set("language", opts.language); + if (opts.pageno) params.set("pageno", String(opts.pageno)); + + const res = await fetch(`${this.baseUrl}/search?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`SearXNG search: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { + results?: SearxResult[]; + suggestions?: string[]; + answers?: string[]; + number_of_results?: number; + }; + const results = data.results ?? []; + return { + query, + // SearXNG's own count is often 0 even with results; fall back to what we actually got. + numberOfResults: data.number_of_results || results.length, + results: results.map((r) => ({ + title: r.title, + url: r.url, + content: r.content, + engine: r.engine, + category: r.category, + score: r.score, + })), + suggestions: data.suggestions ?? [], + answers: data.answers ?? [], + }; + } +} diff --git a/modules/searxng/package.json b/modules/searxng/package.json new file mode 100644 index 0000000..45c1fbd --- /dev/null +++ b/modules/searxng/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-searxng", + "version": "0.1.0", + "description": "searxng — privacy-respecting metasearch. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/searxng/tools/index.ts b/modules/searxng/tools/index.ts new file mode 100644 index 0000000..f30c8c2 --- /dev/null +++ b/modules/searxng/tools/index.ts @@ -0,0 +1,37 @@ +// searxng's tools (novox/hq ADR 0044), importing searxng's own client. A stateless metasearch: one +// query in, merged results out — genuinely useful, and with nothing to observe over time it stays +// tools-only (no events, no broker). + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { SearxngClient } from "../client.js"; + +export function getSearxngTools(searxng: SearxngClient): ToolDefinition[] { + return [ + { + name: "searxng_search", + description: "Search the web through SearXNG's privacy-respecting metasearch — merged results from many engines.", + input: { + query: { type: "string", description: "the search query" }, + categories: { type: "string", description: "comma-separated categories, e.g. 'general', 'news', 'images'" }, + language: { type: "string", description: "language code, e.g. 'en' or 'en-US'" }, + }, + run: async (args) => { + const found = await searxng.search(String(args.query), { + categories: args.categories ? String(args.categories) : undefined, + language: args.language ? String(args.language) : undefined, + }); + return found; + }, + }, + ]; +} + +// A URL always resolves (it defaults), so this normally contributes its tool; the try/catch keeps a +// misconfiguration from taking the whole runtime down. +registerModuleTools("searxng", (env) => { + try { + return getSearxngTools(SearxngClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/searxng/tsconfig.json b/modules/searxng/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/searxng/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} From d59649de0ac0bcdccd2af37914a931436fccf308 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 10:24:03 +0200 Subject: [PATCH 17/28] dnsmasq: a resolver tool and event (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh's resolver is more than config after all. Tools: dnsmasq_names (what this node answers, from the generated wildcard file) and dnsmasq_resolve (resolve a name through this node's own resolver — the check that wildcard-resolution actually answers). Event: it watches the wildcard file and emits module.dnsmasq.name.added/.removed as machines' names become resolvable here — DNS having propagated to this node, distinct from the mesh's node.* events. Typechecks; manifest parses. --- modules/dnsmasq/client.ts | 59 ++++++++++++++++++++++++++++++++++ modules/dnsmasq/index.ts | 38 ++++++++++++++++++++++ modules/dnsmasq/module.json | 7 ++++ modules/dnsmasq/package.json | 9 ++++++ modules/dnsmasq/tools/index.ts | 31 ++++++++++++++++++ modules/dnsmasq/tsconfig.json | 12 +++++++ 6 files changed, 156 insertions(+) create mode 100644 modules/dnsmasq/client.ts create mode 100644 modules/dnsmasq/index.ts create mode 100644 modules/dnsmasq/package.json create mode 100644 modules/dnsmasq/tools/index.ts create mode 100644 modules/dnsmasq/tsconfig.json diff --git a/modules/dnsmasq/client.ts b/modules/dnsmasq/client.ts new file mode 100644 index 0000000..ae3540f --- /dev/null +++ b/modules/dnsmasq/client.ts @@ -0,0 +1,59 @@ +// dnsmasq's own code, living in the module (novox/hq ADR 0044). dnsmasq here is a pure resolver: it +// answers the mesh's generated wildcard names (..) and forwards nothing. So +// its code reads what it was told to answer, and can resolve through itself to prove that it does. + +import { readFile } from "node:fs/promises"; +import { Resolver } from "node:dns/promises"; + +export interface AnsweredName { + /** A machine's internal name, e.g. "anchor.internal" — it and everything under it resolve here. */ + name: string; + address: string; +} + +export class DnsmasqClient { + constructor( + private readonly resolverPath: string, + private readonly address: string, + ) {} + + /** + * Build from the environment. Both values are node-local facts with mesh-chosen defaults — the + * wildcard file the module is sent, and the loopback address its config listens on — so there is + * nothing to be unconfigured about; it never throws. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): DnsmasqClient { + return new DnsmasqClient( + env.MESH_DNSMASQ_RESOLVER_PATH ?? "/etc/mesh-resolver/nodes.conf", + env.MESH_DNSMASQ_ADDRESS ?? "127.0.0.55", + ); + } + + /** The names this resolver answers, read from the mesh-generated wildcard file. */ + async answeredNames(): Promise { + let text: string; + try { + text = await readFile(this.resolverPath, "utf8"); + } catch { + return []; // not yet on the network, or the file has not been written — no names, not an error + } + const names: AnsweredName[] = []; + for (const line of text.split("\n")) { + // dnsmasq wildcard syntax the mesh writes: address=/./
+ const match = line.match(/^address=\/([^/]+)\/(.+)$/); + if (match) names.push({ name: match[1], address: match[2] }); + } + return names; + } + + /** Resolve a name through this node's own resolver — the check that wildcard-resolution answers. */ + async resolve(name: string): Promise { + const resolver = new Resolver(); + resolver.setServers([this.address]); + try { + return await resolver.resolve4(name); + } catch { + return await resolver.resolve6(name); + } + } +} diff --git a/modules/dnsmasq/index.ts b/modules/dnsmasq/index.ts new file mode 100644 index 0000000..15a50b9 --- /dev/null +++ b/modules/dnsmasq/index.ts @@ -0,0 +1,38 @@ +// dnsmasq's events. The resolver's answered set changes whenever the mesh rewrites the wildcard file +// and restarts it — a machine joined the private network or left it. This watches that file and +// announces, from the resolver's own vantage, that a name became (or stopped being) resolvable on +// this node: DNS having actually propagated here, distinct from the mesh's own node.* events. +// +// Emits (novox/hq ADR 0046/0047): +// module.dnsmasq.name.added — this node's resolver now answers a machine's name +// module.dnsmasq.name.removed — it no longer does + +import { emit } from "@novox/mesh-sdk/events"; +import { DnsmasqClient } from "./client.js"; + +const dnsmasq = DnsmasqClient.fromEnv(); + +// name -> address, primed silently so a restart does not re-announce every name it already answered. +const known = new Map(); +let primed = false; + +async function poll(): Promise { + const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); + if (primed) { + for (const [name, address] of now) { + if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address }); + } + for (const [name] of known) { + if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name }); + } + } + known.clear(); + for (const [name, address] of now) known.set(name, address); + primed = true; +} + +const run = (): void => void poll().catch((err) => console.error(`[dnsmasq] ${err}`)); +setInterval(run, 30_000); +run(); + +console.log("[dnsmasq] watching the resolver's answered names"); diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 79d311d..9fcd34f 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -7,6 +7,13 @@ "provides": [ "wildcard-resolution" ], + "emits": [ + "module.dnsmasq.name.added", + "module.dnsmasq.name.removed" + ], + "own-secrets": { + "broker": "/var/lib/dnsmasq/broker" + }, "claims": [ { "name": "the-dns-port", diff --git a/modules/dnsmasq/package.json b/modules/dnsmasq/package.json new file mode 100644 index 0000000..4e18a47 --- /dev/null +++ b/modules/dnsmasq/package.json @@ -0,0 +1,9 @@ +{ + "name": "@novox/module-dnsmasq", + "version": "0.1.0", + "description": "dnsmasq — the mesh's resolver: answers wildcard node names. Its tools and events live here.", + "type": "module", + "private": true, + "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, + "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" } +} diff --git a/modules/dnsmasq/tools/index.ts b/modules/dnsmasq/tools/index.ts new file mode 100644 index 0000000..472d322 --- /dev/null +++ b/modules/dnsmasq/tools/index.ts @@ -0,0 +1,31 @@ +// dnsmasq's tools — a resolver's two useful questions: what does it answer, and does it answer a +// given name. Moved into the module (novox/hq ADR 0044); the mesh serves them through the sdk. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { DnsmasqClient } from "../client.js"; + +export function getDnsmasqTools(dnsmasq: DnsmasqClient): ToolDefinition[] { + return [ + { + name: "dnsmasq_names", + description: "The mesh names this node's resolver answers — each a machine and everything under it.", + input: {}, + run: async () => ({ names: await dnsmasq.answeredNames() }), + }, + { + name: "dnsmasq_resolve", + description: "Resolve a mesh name through this node's own resolver — the check that wildcard-resolution answers.", + input: { name: { type: "string", description: "a name to resolve, e.g. plex.anchor.internal" } }, + run: async (args) => { + const name = String(args.name); + try { + return { name, addresses: await dnsmasq.resolve(name) }; + } catch (err) { + return { name, addresses: [], error: err instanceof Error ? err.message : String(err) }; + } + }, + }, + ]; +} + +registerModuleTools("dnsmasq", (env) => getDnsmasqTools(DnsmasqClient.fromEnv(env))); diff --git a/modules/dnsmasq/tsconfig.json b/modules/dnsmasq/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/dnsmasq/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} From 0e102dd350e0cc33d07a9628a2b3327318143259 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 20:52:26 +0200 Subject: [PATCH 18/28] firewall: the module that applies the mesh-computed packet filter (ADR 0050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The missing applier. mesh-control already derives a node's whole nftables rule set from the union of its modules' listens and writes it to /etc/nftables.conf; this module declares filtering:{into} to receive it and loads it — the nftables service, reloaded on 'filtering' whenever the rules change. A firewall_rules tool reads the live table so a declared scope can be checked against what is really enforced. Closes the loop from listens.from to a packet actually dropped. Manifest parses; tool typechecks. --- modules/firewall/client.ts | 21 +++++++++++++++++++++ modules/firewall/module.json | 33 +++++++++++++++++++++++++++++++++ modules/firewall/package.json | 14 ++++++++++++++ modules/firewall/tools/index.ts | 19 +++++++++++++++++++ modules/firewall/tsconfig.json | 15 +++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 modules/firewall/client.ts create mode 100644 modules/firewall/module.json create mode 100644 modules/firewall/package.json create mode 100644 modules/firewall/tools/index.ts create mode 100644 modules/firewall/tsconfig.json diff --git a/modules/firewall/client.ts b/modules/firewall/client.ts new file mode 100644 index 0000000..5b71df0 --- /dev/null +++ b/modules/firewall/client.ts @@ -0,0 +1,21 @@ +// The firewall's own code, in the module (novox/hq ADR 0044). The mesh computes this node's whole +// rule set from every module's `listens` and writes it to /etc/nftables.conf (novox/hq ADR 0050); +// the module loads it (the nftables service, reloaded whenever the rules change). This code exists +// only to read back what is actually enforced — the enforcement itself is declarative. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +export class FirewallClient { + static fromEnv(_env: NodeJS.ProcessEnv = process.env): FirewallClient { + return new FirewallClient(); + } + + /** The mesh's live table — exactly what is dropping and accepting on this node right now. */ + async ruleset(): Promise { + const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]); + return stdout; + } +} diff --git a/modules/firewall/module.json b/modules/firewall/module.json new file mode 100644 index 0000000..e8331a2 --- /dev/null +++ b/modules/firewall/module.json @@ -0,0 +1,33 @@ +{ + "module": "firewall", + "version": "1", + "capabilities": [ + "firewall" + ], + "claims": [ + { + "name": "the-packet-filter", + "scope": "node" + } + ], + "filtering": { + "into": "/etc/nftables.conf" + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "nftables" + }, + { + "id": "load", + "type": "service", + "unit": "nftables.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "filtering" + ] + } + ] +} diff --git a/modules/firewall/package.json b/modules/firewall/package.json new file mode 100644 index 0000000..c80274f --- /dev/null +++ b/modules/firewall/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-firewall", + "version": "0.1.0", + "description": "firewall — applies the mesh-computed packet filter (ADR 0050). Its diagnostic tool lives here. + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/firewall/tools/index.ts b/modules/firewall/tools/index.ts new file mode 100644 index 0000000..198e5de --- /dev/null +++ b/modules/firewall/tools/index.ts @@ -0,0 +1,19 @@ +// firewall's tools — one, and the useful one: what is actually enforced. The rules are the mesh's, +// computed from every module's listens; this reads the live table so a declared scope can be checked +// against what the packet filter is really doing. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { FirewallClient } from "../client.js"; + +export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] { + return [ + { + name: "firewall_rules", + description: "The mesh's live nftables rules on this node — what is actually accepting and dropping.", + input: {}, + run: async () => ({ ruleset: await firewall.ruleset() }), + }, + ]; +} + +registerModuleTools("firewall", () => getFirewallTools(FirewallClient.fromEnv())); diff --git a/modules/firewall/tsconfig.json b/modules/firewall/tsconfig.json new file mode 100644 index 0000000..91d5b91 --- /dev/null +++ b/modules/firewall/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts" + ] +} \ No newline at end of file From d2d20a76b642930f8ef7ba2b85fcfcba7632f950 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 20:55:36 +0200 Subject: [PATCH 19/28] cloudflare-dns: a public-dns provider (ADR 0049) The first registrar behind the neutral public-dns interface. Provider shape like minio: provides public-dns, a provisioner that registers a consumer's public name at Cloudflare pointing it at the mesh's ingress, and removes it on withdrawal. The name is derived from the consumer identity under the mesh's domain (so stateless teardown recomputes it); the returned {fqdn,target,ttl} is public, the Cloudflare token the only secret and it never leaves. Emits record.created/.removed (best-effort). A cloudflare_dns_records diagnostic tool. Config (zone, domain, ingress) is left to settings, so it fails closed until a mesh provides them. Typechecks; manifest parses. --- modules/cloudflare-dns/client.ts | 105 ++++++++++++++++++++ modules/cloudflare-dns/module.json | 61 ++++++++++++ modules/cloudflare-dns/package.json | 14 +++ modules/cloudflare-dns/provisioner/index.ts | 43 ++++++++ modules/cloudflare-dns/tools/index.ts | 23 +++++ modules/cloudflare-dns/tsconfig.json | 16 +++ 6 files changed, 262 insertions(+) create mode 100644 modules/cloudflare-dns/client.ts create mode 100644 modules/cloudflare-dns/module.json create mode 100644 modules/cloudflare-dns/package.json create mode 100644 modules/cloudflare-dns/provisioner/index.ts create mode 100644 modules/cloudflare-dns/tools/index.ts create mode 100644 modules/cloudflare-dns/tsconfig.json diff --git a/modules/cloudflare-dns/client.ts b/modules/cloudflare-dns/client.ts new file mode 100644 index 0000000..3c1d270 --- /dev/null +++ b/modules/cloudflare-dns/client.ts @@ -0,0 +1,105 @@ +// cloudflare-dns's own code (novox/hq ADR 0044). It provides the mesh `public-dns` interface +// (ADR 0049): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar +// behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is +// the only place Cloudflare's API appears, and swapping registrars swaps only this module. + +import { readFileSync } from "node:fs"; + +export interface PublicRecord { + id: string; + name: string; + type: string; + content: string; +} + +export class CloudflareClient { + constructor( + private readonly token: string, + private readonly zoneId: string, + /** The zone this registers under, e.g. "example.com". */ + readonly domain: string, + /** What every public name points at — the mesh's public ingress (the reverse proxy). */ + readonly ingress: string, + ) {} + + static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient { + const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE); + const zoneId = env.MESH_CLOUDFLARE_ZONE_ID; + const domain = env.MESH_PUBLIC_DOMAIN; + const ingress = env.MESH_PUBLIC_INGRESS; + if (!token || !zoneId || !domain || !ingress) { + throw new Error( + "cloudflare-dns needs MESH_CLOUDFLARE_TOKEN (or _FILE), MESH_CLOUDFLARE_ZONE_ID, " + + "MESH_PUBLIC_DOMAIN and MESH_PUBLIC_INGRESS — it cannot register a name without them", + ); + } + return new CloudflareClient(token, zoneId, domain, ingress); + } + + /** + * The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not + * contributed, for the same reason minio derives a bucket name — the harness hands `remove` only + * the identity, so teardown must recompute exactly what creation made. + */ + nameFor(consumer: string): string { + return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`; + } + + /** An IP points at itself (A/AAAA); a hostname points through a CNAME. */ + private recordType(): "A" | "AAAA" | "CNAME" { + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A"; + if (this.ingress.includes(":")) return "AAAA"; + return "CNAME"; + } + + private async api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + method, + headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown }; + if (!res.ok || json.success === false) { + throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`); + } + return json.result as T; + } + + async findRecord(name: string): Promise { + const records = await this.api( + "GET", + `/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`, + ); + return records[0]; + } + + /** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */ + async upsert(name: string): Promise { + const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false }; + const existing = await this.findRecord(name); + if (existing) { + return this.api("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body); + } + return this.api("POST", `/zones/${this.zoneId}/dns_records`, body); + } + + /** Remove a public name, idempotently — a record already gone is not an error on reconcile. */ + async remove(name: string): Promise { + const existing = await this.findRecord(name); + if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`); + } + + /** Every record in the zone, for the diagnostic tool. */ + async records(): Promise { + return this.api("GET", `/zones/${this.zoneId}/dns_records`); + } +} + +function readSecret(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json new file mode 100644 index 0000000..58e4806 --- /dev/null +++ b/modules/cloudflare-dns/module.json @@ -0,0 +1,61 @@ +{ + "module": "cloudflare-dns", + "version": "1", + "provides": [ + { + "name": "public-dns", + "scope": "mesh" + } + ], + "serves": { + "public-dns": {} + }, + "grants": { + "public-dns": "/var/lib/cloudflare-dns/grants" + }, + "receives": { + "public-dns": "/var/lib/cloudflare-dns/grants/mesh.json" + }, + "own-secrets": { + "token": "/var/lib/cloudflare-dns/token", + "broker": "/var/lib/cloudflare-dns/broker" + }, + "emits": [ + "module.cloudflare-dns.record.created", + "module.cloudflare-dns.record.removed" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/cloudflare-dns", + "mode": "0700" + }, + { + "id": "grants", + "type": "directory", + "path": "/var/lib/cloudflare-dns/grants", + "mode": "0700" + }, + { + "id": "provisioner", + "type": "container", + "name": "mesh-provision-cloudflare-dns", + "image": "mesh-provision-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "env": { + "GRANTS": "/grants", + "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_CLOUDFLARE_ZONE_ID": "", + "MESH_PUBLIC_DOMAIN": "", + "MESH_PUBLIC_INGRESS": "" + }, + "volumes": [ + "/var/lib/cloudflare-dns/grants:/grants", + "/var/lib/cloudflare-dns/token:/run/secrets/token:ro", + "/var/lib/cloudflare-dns/broker:/run/secrets/broker:ro" + ] + } + ] +} diff --git a/modules/cloudflare-dns/package.json b/modules/cloudflare-dns/package.json new file mode 100644 index 0000000..01e230a --- /dev/null +++ b/modules/cloudflare-dns/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-cloudflare-dns", + "version": "0.1.0", + "description": "cloudflare-dns — a public-dns provider (ADR 0049): registers public names at Cloudflare. + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/cloudflare-dns/provisioner/index.ts b/modules/cloudflare-dns/provisioner/index.ts new file mode 100644 index 0000000..c9cfac3 --- /dev/null +++ b/modules/cloudflare-dns/provisioner/index.ts @@ -0,0 +1,43 @@ +// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface +// (novox/hq ADR 0049). The reconcile loop, sealing and grant-file handling are the sdk harness's; +// this writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing +// it at the mesh's ingress, and remove it when the grant is withdrawn. +// +// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly +// and what it resolves to. It is not a secret (a DNS record is public), so nothing is sealed beyond +// what the harness seals; the only secret is this module's own Cloudflare token, which never leaves. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { CloudflareClient } from "../client.js"; + +const cloudflare = CloudflareClient.fromEnv(); + +runProvisioner("public-dns", { + async create(grant: Grant): Promise { + const fqdn = cloudflare.nameFor(grant.consumer); + await cloudflare.upsert(fqdn); + await announce("module.cloudflare-dns.record.created", { + name: fqdn, + target: cloudflare.ingress, + consumer: grant.consumer, + node: grant.node, + }); + return { fields: { fqdn, target: cloudflare.ingress, ttl: "300" } }; + }, + + async remove(grant: Grant): Promise { + const fqdn = cloudflare.nameFor(grant.consumer); + await cloudflare.remove(fqdn); + await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: grant.consumer, node: grant.node }); + }, +}); + +/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */ +async function announce(type: string, body: unknown): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[cloudflare-dns] could not emit ${type}: ${err}`); + } +} diff --git a/modules/cloudflare-dns/tools/index.ts b/modules/cloudflare-dns/tools/index.ts new file mode 100644 index 0000000..529e7fd --- /dev/null +++ b/modules/cloudflare-dns/tools/index.ts @@ -0,0 +1,23 @@ +// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { CloudflareClient } from "../client.js"; + +export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] { + return [ + { + name: "cloudflare_dns_records", + description: "The public DNS records in the mesh's zone — the names it currently publishes.", + input: {}, + run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }), + }, + ]; +} + +registerModuleTools("cloudflare-dns", (env) => { + try { + return getCloudflareDnsTools(CloudflareClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/cloudflare-dns/tsconfig.json b/modules/cloudflare-dns/tsconfig.json new file mode 100644 index 0000000..c2a8df0 --- /dev/null +++ b/modules/cloudflare-dns/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts", + "provisioner/index.ts" + ] +} \ No newline at end of file From 121b7f196b80fde009cdbdbd7f45df8621fcdc97 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:07:51 +0200 Subject: [PATCH 20/28] cloudflare-dns: config from settings, not static manifest env (ADR 0051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which zone, domain and ingress are a mesh's facts, not the module's — so they are settings merged into a config file the mesh manages, read by fromEnv, rather than the empty env placeholders I wrongly baked in. The token stays the one own-secret. The module now describes a Cloudflare registrar; which zone is a setting, so the same description serves every mesh. Typechecks; manifest parses. --- modules/cloudflare-dns/client.ts | 32 +++++++++++++++++++++++++----- modules/cloudflare-dns/module.json | 13 +++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/modules/cloudflare-dns/client.ts b/modules/cloudflare-dns/client.ts index 3c1d270..725e042 100644 --- a/modules/cloudflare-dns/client.ts +++ b/modules/cloudflare-dns/client.ts @@ -23,14 +23,19 @@ export class CloudflareClient { ) {} static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient { + // Which zone, domain and ingress are a mesh's own facts, not this module's — so they are + // settings, merged into a config file the mesh manages (novox/hq ADR 0051), read here. The + // token is the one secret and stays an own-secret. Env is honoured as a fallback for a + // hand-run instance, but the deployed path is the config file settings fill. + const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE); const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE); - const zoneId = env.MESH_CLOUDFLARE_ZONE_ID; - const domain = env.MESH_PUBLIC_DOMAIN; - const ingress = env.MESH_PUBLIC_INGRESS; + const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID; + const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN; + const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS; if (!token || !zoneId || !domain || !ingress) { throw new Error( - "cloudflare-dns needs MESH_CLOUDFLARE_TOKEN (or _FILE), MESH_CLOUDFLARE_ZONE_ID, " + - "MESH_PUBLIC_DOMAIN and MESH_PUBLIC_INGRESS — it cannot register a name without them", + "cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " + + "token as its own-secret); until then it registers nothing", ); } return new CloudflareClient(token, zoneId, domain, ingress); @@ -103,3 +108,20 @@ function readSecret(path: string | undefined): string | undefined { return undefined; } } + +interface Config { + zone?: string; + domain?: string; + ingress?: string; +} + +/** The settings-managed config file (a JSON document the mesh merges settings into). Absent or + * unparseable yields an empty config, which fromEnv then reports as unconfigured. */ +function readConfig(path: string | undefined): Config { + if (!path) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as Config; + } catch { + return {}; + } +} diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json index 58e4806..afaaaf1 100644 --- a/modules/cloudflare-dns/module.json +++ b/modules/cloudflare-dns/module.json @@ -37,6 +37,14 @@ "path": "/var/lib/cloudflare-dns/grants", "mode": "0700" }, + { + "id": "config", + "type": "file", + "path": "/var/lib/cloudflare-dns/config.json", + "merge": "json", + "content": "{}", + "mode": "0600" + }, { "id": "provisioner", "type": "container", @@ -47,11 +55,10 @@ "GRANTS": "/grants", "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_CLOUDFLARE_ZONE_ID": "", - "MESH_PUBLIC_DOMAIN": "", - "MESH_PUBLIC_INGRESS": "" + "MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json" }, "volumes": [ + "/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro", "/var/lib/cloudflare-dns/grants:/grants", "/var/lib/cloudflare-dns/token:/run/secrets/token:ro", "/var/lib/cloudflare-dns/broker:/run/secrets/broker:ro" From 5fd78a77ad2e866409092fa6c7a8cefb69be7336 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:25:42 +0200 Subject: [PATCH 21/28] minio: provisioner emits best-effort, so a missing broker can't fail provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found minio's provisioner emitting with a bare await, which throws when no broker is bound (a provisioner is not yet a runtime — ADR 0052) and so fails create/remove. Wrapped like the other providers: the event is logged and dropped, the bucket still made. The real fix — the provisioner carrying a broker credential — is ADR 0052. --- modules/minio/provisioner/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index fc999a1..a69a37d 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -28,7 +28,7 @@ runProvisioner("s3-bucket", { try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } const key = await minio.createAccessKey(bucket, accessKeyId); - await emit("module.minio.bucket.created", { + await announce("module.minio.bucket.created", { bucket, consumer: grant.consumer, node: grant.node, @@ -61,6 +61,16 @@ runProvisioner("s3-bucket", { console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); } - await emit("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node }); + await announce("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node }); }, }); + +/** Emit best-effort: with no broker bound (a provisioner is not yet a runtime — novox/hq ADR 0052) + * the event is logged and dropped, never allowed to throw back and fail a bucket that was made. */ +async function announce(type: string, body: unknown): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[minio] could not emit ${type}: ${err}`); + } +} From 67d8d0dcbdc95c9ff2bd9e2a12c5643f629cd8e2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:28:55 +0200 Subject: [PATCH 22/28] =?UTF-8?q?catalogue:=20one=20mesh-state=20namespace?= =?UTF-8?q?=20=E2=80=94=20/var/lib/mesh//=20(consistency)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found broker own-secret paths drifting: mostly /var/lib// broker, but grafana/redis/icecast/nextcloud carried a '-module' suffix to dodge a collision with the service's own /var/lib/ data, and photos sat under /etc. Normalized to one collision-free namespace a service never owns: /var/lib/mesh//broker, with a mesh-state directory resource for the parent, across 24 modules. audit-logger is grandfathered (lab-proven, referenced by the assigned test, and it has no service to collide with). All 33 manifests parse; no client hardcoded a path, so nothing in code moved. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/bazarr/module.json | 8 +++++++- modules/cloudflare-dns/module.json | 10 ++++++++-- modules/dnsmasq/module.json | 8 +++++++- modules/gitea/module.json | 8 +++++++- modules/grafana/module.json | 8 +++++++- modules/home-assistant/module.json | 8 +++++++- modules/icecast/module.json | 8 +++++++- modules/keycloak/module.json | 8 +++++++- modules/mailu/module.json | 8 +++++++- modules/minio/module.json | 10 ++++++++-- modules/nextcloud/module.json | 8 +++++++- modules/nodered/module.json | 8 +++++++- modules/nzbget/module.json | 8 +++++++- modules/ombi/module.json | 8 +++++++- modules/photos/module.json | 8 +++++++- modules/plex/module.json | 8 +++++++- modules/postgres/module.json | 8 +++++++- modules/qbittorrent/module.json | 8 +++++++- modules/radarr/module.json | 8 +++++++- modules/redis/module.json | 8 +++++++- modules/registry/module.json | 2 +- modules/sonarr/module.json | 8 +++++++- modules/tautulli/module.json | 8 +++++++- modules/verdaccio/module.json | 8 +++++++- 24 files changed, 164 insertions(+), 26 deletions(-) diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index 050700b..b90dd88 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -8,7 +8,7 @@ "module.bazarr.subtitle.downloaded" ], "own-secrets": { - "broker": "/var/lib/bazarr/broker" + "broker": "/var/lib/mesh/bazarr/broker" }, "listens": [ { @@ -19,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/bazarr", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json index afaaaf1..49292e9 100644 --- a/modules/cloudflare-dns/module.json +++ b/modules/cloudflare-dns/module.json @@ -18,13 +18,19 @@ }, "own-secrets": { "token": "/var/lib/cloudflare-dns/token", - "broker": "/var/lib/cloudflare-dns/broker" + "broker": "/var/lib/mesh/cloudflare-dns/broker" }, "emits": [ "module.cloudflare-dns.record.created", "module.cloudflare-dns.record.removed" ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/cloudflare-dns", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -61,7 +67,7 @@ "/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro", "/var/lib/cloudflare-dns/grants:/grants", "/var/lib/cloudflare-dns/token:/run/secrets/token:ro", - "/var/lib/cloudflare-dns/broker:/run/secrets/broker:ro" + "/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro" ] } ] diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 9fcd34f..7b2c0cd 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -12,7 +12,7 @@ "module.dnsmasq.name.removed" ], "own-secrets": { - "broker": "/var/lib/dnsmasq/broker" + "broker": "/var/lib/mesh/dnsmasq/broker" }, "claims": [ { @@ -30,6 +30,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/dnsmasq", + "mode": "0700" + }, { "id": "package", "type": "package", diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 83133c8..b6af10e 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -39,9 +39,15 @@ ], "own-secrets": { "internal-token": "/var/lib/gitea/internal-token.secret", - "broker": "/var/lib/gitea/broker" + "broker": "/var/lib/mesh/gitea/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/gitea", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/grafana/module.json b/modules/grafana/module.json index e9ae8fa..3835b69 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -6,7 +6,7 @@ ], "own-secrets": { "admin": "/var/lib/grafana-module/admin.secret", - "broker": "/var/lib/grafana-module/broker" + "broker": "/var/lib/mesh/grafana/broker" }, "capabilities": [ "container-runtime" @@ -20,6 +20,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/grafana", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 2fefc03..3e63d64 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -8,7 +8,7 @@ "module.home-assistant.state.changed" ], "own-secrets": { - "broker": "/var/lib/home-assistant/broker" + "broker": "/var/lib/mesh/home-assistant/broker" }, "listens": [ { @@ -19,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/home-assistant", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 017a6e8..b60778d 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -12,7 +12,7 @@ "source": "/var/lib/icecast-module/source.secret", "admin": "/var/lib/icecast-module/admin.secret", "relay": "/var/lib/icecast-module/relay.secret", - "broker": "/var/lib/icecast-module/broker" + "broker": "/var/lib/mesh/icecast/broker" }, "listens": [ { @@ -23,6 +23,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/icecast", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 404cce3..0bc6c51 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -36,9 +36,15 @@ ], "own-secrets": { "admin": "/var/lib/keycloak/admin.secret", - "broker": "/var/lib/keycloak/broker" + "broker": "/var/lib/mesh/keycloak/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/keycloak", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 33aafe4..bf64c8a 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -50,9 +50,15 @@ "secret-key": "/var/lib/mailu/secret-key.secret", "database": "/var/lib/mailu/database.secret", "admin": "/var/lib/mailu/admin.secret", - "broker": "/var/lib/mailu/broker" + "broker": "/var/lib/mesh/mailu/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/mailu", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/minio/module.json b/modules/minio/module.json index 98b5cfa..7794739 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -36,9 +36,15 @@ }, "own-secrets": { "root": "/var/lib/minio/root.secret", - "broker": "/var/lib/minio/broker" + "broker": "/var/lib/mesh/minio/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/minio", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -109,4 +115,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 8d56fee..a84d447 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -27,7 +27,7 @@ ], "own-secrets": { "admin": "/var/lib/nextcloud-module/admin.secret", - "broker": "/var/lib/nextcloud-module/broker" + "broker": "/var/lib/mesh/nextcloud/broker" }, "capabilities": [ "container-runtime" @@ -41,6 +41,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/nextcloud", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 1bca4dd..9a48039 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -5,7 +5,7 @@ "module.nodered.flows.deployed" ], "own-secrets": { - "broker": "/var/lib/nodered/broker" + "broker": "/var/lib/mesh/nodered/broker" }, "capabilities": [ "container-runtime" @@ -19,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/nodered", + "mode": "0700" + }, { "id": "data", "type": "directory", diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index c6dabae..184a1b3 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -10,7 +10,7 @@ ], "consumes": [], "own-secrets": { - "broker": "/var/lib/nzbget/broker" + "broker": "/var/lib/mesh/nzbget/broker" }, "listens": [ { @@ -21,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/nzbget", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/ombi/module.json b/modules/ombi/module.json index edc4fb4..8a7a5cb 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -9,7 +9,7 @@ "module.ombi.request.approved" ], "own-secrets": { - "broker": "/var/lib/ombi/broker" + "broker": "/var/lib/mesh/ombi/broker" }, "listens": [ { @@ -20,6 +20,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/ombi", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/photos/module.json b/modules/photos/module.json index 72e6cf6..e24f528 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -19,9 +19,15 @@ "module.photos.item.added" ], "own-secrets": { - "broker": "/etc/photos/broker" + "broker": "/var/lib/mesh/photos/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/photos", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/plex/module.json b/modules/plex/module.json index 91445c0..b120077 100644 --- a/modules/plex/module.json +++ b/modules/plex/module.json @@ -13,7 +13,7 @@ "module.*.download.completed" ], "own-secrets": { - "broker": "/var/lib/plex/broker" + "broker": "/var/lib/mesh/plex/broker" }, "listens": [ { @@ -24,6 +24,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/plex", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/postgres/module.json b/modules/postgres/module.json index ec68156..a11feaf 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -33,9 +33,15 @@ }, "own-secrets": { "superuser": "/var/lib/postgres/superuser.secret", - "broker": "/var/lib/postgres/broker" + "broker": "/var/lib/mesh/postgres/broker" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/postgres", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 97690ab..4cf1739 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -10,7 +10,7 @@ ], "consumes": [], "own-secrets": { - "broker": "/var/lib/qbittorrent/broker" + "broker": "/var/lib/mesh/qbittorrent/broker" }, "listens": [ { @@ -21,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/qbittorrent", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/radarr/module.json b/modules/radarr/module.json index e05b77d..a66fc63 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -10,7 +10,7 @@ ], "consumes": [], "own-secrets": { - "broker": "/var/lib/radarr/broker" + "broker": "/var/lib/mesh/radarr/broker" }, "listens": [ { @@ -21,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/radarr", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/redis/module.json b/modules/redis/module.json index 3acb6c9..80d9f30 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -25,7 +25,7 @@ }, "own-secrets": { "default": "/var/lib/redis-module/default.secret", - "broker": "/var/lib/redis-module/broker" + "broker": "/var/lib/mesh/redis/broker" }, "listens": [ { @@ -36,6 +36,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/redis", + "mode": "0700" + }, { "id": "state", "type": "directory", diff --git a/modules/registry/module.json b/modules/registry/module.json index 0aa752e..08cb45f 100644 --- a/modules/registry/module.json +++ b/modules/registry/module.json @@ -20,7 +20,7 @@ "module.registry.image.pushed" ], "own-secrets": { - "broker": "/var/lib/registry/broker" + "broker": "/var/lib/mesh/registry/broker" }, "serves": { "artifact-store": { diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index aa0a983..645da0b 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -10,7 +10,7 @@ ], "consumes": [], "own-secrets": { - "broker": "/var/lib/sonarr/broker" + "broker": "/var/lib/mesh/sonarr/broker" }, "listens": [ { @@ -21,6 +21,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/sonarr", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index d3d3483..27bfc8f 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -5,7 +5,7 @@ "module.tautulli.watch.recorded" ], "own-secrets": { - "broker": "/var/lib/tautulli/broker" + "broker": "/var/lib/mesh/tautulli/broker" }, "capabilities": [ "container-runtime" @@ -19,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/tautulli", + "mode": "0700" + }, { "id": "config", "type": "directory", diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index f8887d7..f9d93c0 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -8,7 +8,7 @@ "module.verdaccio.package.published" ], "own-secrets": { - "broker": "/var/lib/verdaccio/broker" + "broker": "/var/lib/mesh/verdaccio/broker" }, "listens": [ { @@ -19,6 +19,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/verdaccio", + "mode": "0700" + }, { "id": "conf", "type": "directory", From 6615b5e3b359a98c69e4bdee1647d5ef12b7a0ae Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 22:29:41 +0200 Subject: [PATCH 23/28] plex, redis: module runtime containers + declare self-consumed events (ADR 0052/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plex gains a broker-bound tools/events runtime container (mesh-runtime-plex) alongside its server, and a never-throwing plex_reachable health probe. redis and postgres subscribe to their own lifecycle events in index.ts but declared no consumes — so the substrate never made the queue the runtime binds and it crashed on start (404). Declare the consume, as ADR 0046 requires. Proven end-to-end in the mesh-lab: assigned-plex and assigned-redis both green. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/plex/client.ts | 14 ++++++++++++++ modules/plex/module.json | 16 ++++++++++++++++ modules/plex/tools/index.ts | 6 ++++++ modules/postgres/index.ts | 25 +++++++++++++++++++++++++ modules/postgres/module.json | 4 ++++ modules/redis/index.ts | 25 +++++++++++++++++++++++++ modules/redis/module.json | 4 ++++ 7 files changed, 94 insertions(+) create mode 100644 modules/postgres/index.ts create mode 100644 modules/redis/index.ts diff --git a/modules/plex/client.ts b/modules/plex/client.ts index a987767..78c2288 100644 --- a/modules/plex/client.ts +++ b/modules/plex/client.ts @@ -128,4 +128,18 @@ export class PlexClient { async refreshAll(): Promise { for (const library of await this.getLibraries()) await this.refreshLibrary(library.key); } + + /** + * A health probe that never throws: report whether the Plex server this client is pointed at + * answers, and identify it when it does. Every other call assumes the server is up; this is the + * one that tells the mesh whether it is, so a diagnosis does not start from a stack trace. + */ + async reachable(): Promise<{ reachable: boolean; url: string; server?: { name: string; version: string }; error?: string }> { + try { + const info = await this.getServerInfo(); + return { reachable: true, url: this.baseUrl, server: { name: info.name, version: info.version } }; + } catch (err) { + return { reachable: false, url: this.baseUrl, error: err instanceof Error ? err.message : String(err) }; + } + } } diff --git a/modules/plex/module.json b/modules/plex/module.json index b120077..bb9dc37 100644 --- a/modules/plex/module.json +++ b/modules/plex/module.json @@ -99,6 +99,22 @@ "/services/media/music:/music", "/services/media/audiobooks:/audiobooks" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-plex", + "image": "mesh-runtime-plex@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/plex/broker:/run/secrets/broker:ro", + "/services/plex/config:/var/lib/plex/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PLEX_URL": "http://127.0.0.1:32400", + "MESH_PLEX_DATA_DIR": "/var/lib/plex" + } } ] } diff --git a/modules/plex/tools/index.ts b/modules/plex/tools/index.ts index f175a7d..7a2115b 100644 --- a/modules/plex/tools/index.ts +++ b/modules/plex/tools/index.ts @@ -20,6 +20,12 @@ export function getPlexTools(plex: PlexClient): ToolDefinition[] { return { server, libraries, sessions, recentlyAdded: recent }; }, }, + { + name: "plex_reachable", + description: "Health probe: whether the Plex server answers, and which server it is. Never fails.", + input: {}, + run: async () => plex.reachable(), + }, { name: "plex_search", description: "Search across all Plex libraries — movies, shows, episodes, music.", diff --git a/modules/postgres/index.ts b/modules/postgres/index.ts new file mode 100644 index 0000000..a7bbe12 --- /dev/null +++ b/modules/postgres/index.ts @@ -0,0 +1,25 @@ +// postgres's events entrypoint, loaded by the per-node tool host (the provisioner container runs +// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where +// the lifecycle actually happens (novox/hq ADR 0046/0047): +// module.postgres.database.provisioned — a consumer's database + owning role was created +// module.postgres.database.deprovisioned — that database was removed +// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// database and who lost one — observability the provider itself is best placed to log. + +import { on } from "@novox/mesh-sdk/events"; + +interface DatabaseEvent { + consumer: string; + database: string; + user?: string; +} + +await on("module.postgres.database.provisioned", async (e) => { + console.log(`[postgres] database provisioned for ${e.body.consumer} (db ${e.body.database})`); +}); + +await on("module.postgres.database.deprovisioned", async (e) => { + console.log(`[postgres] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`); +}); + +console.log("[postgres] auditing database lifecycle events"); diff --git a/modules/postgres/module.json b/modules/postgres/module.json index a11feaf..5fe0d69 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -14,6 +14,10 @@ "module.postgres.database.provisioned", "module.postgres.database.deprovisioned" ], + "consumes": [ + "module.postgres.database.provisioned", + "module.postgres.database.deprovisioned" + ], "listens": [ { "port": 5432, diff --git a/modules/redis/index.ts b/modules/redis/index.ts new file mode 100644 index 0000000..870c886 --- /dev/null +++ b/modules/redis/index.ts @@ -0,0 +1,25 @@ +// redis's events entrypoint, loaded by the per-node tool host (the provisioner container runs +// ./provisioner separately). The cache lifecycle events are EMITTED from the provisioner, where the +// lifecycle actually happens (novox/hq ADR 0046/0047): +// module.redis.cache.provisioned — a consumer's ACL user + keyspace was created +// module.redis.cache.deprovisioned — that user was removed +// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// cache and who lost one — observability the provider itself is best placed to log. + +import { on } from "@novox/mesh-sdk/events"; + +interface CacheEvent { + consumer: string; + username: string; + keyspacePrefix?: string; +} + +await on("module.redis.cache.provisioned", async (e) => { + console.log(`[redis] cache provisioned for ${e.body.consumer} (user ${e.body.username})`); +}); + +await on("module.redis.cache.deprovisioned", async (e) => { + console.log(`[redis] cache deprovisioned for ${e.body.consumer} (user ${e.body.username})`); +}); + +console.log("[redis] auditing cache lifecycle events"); diff --git a/modules/redis/module.json b/modules/redis/module.json index 80d9f30..77b648d 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -14,6 +14,10 @@ "module.redis.cache.provisioned", "module.redis.cache.deprovisioned" ], + "consumes": [ + "module.redis.cache.provisioned", + "module.redis.cache.deprovisioned" + ], "serves": { "redis-cache": {} }, From 7b55e834e912dd9130229441608ca5a3311083f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 22:54:53 +0200 Subject: [PATCH 24/28] sonarr, radarr: tool runtime container + self-detect API key from config.xml (ADR 0052) Mirrors plex: a broker-bound runtime that serves the module's tools, discovering the app's API key from its own config.xml under a read-only config-dir mount, URL defaulting to the server on the node. Proven in the mesh-lab: assigned-sonarr green (key detected, tools served under the scoped account, no live Sonarr needed). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/radarr/client.ts | 31 ++++++++++++++++++++++++------- modules/radarr/module.json | 16 ++++++++++++++++ modules/sonarr/client.ts | 33 +++++++++++++++++++++++++-------- modules/sonarr/module.json | 16 ++++++++++++++++ 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts index 74a4bff..6a659eb 100644 --- a/modules/radarr/client.ts +++ b/modules/radarr/client.ts @@ -3,6 +3,9 @@ // change to Radarr's API rebuilds only radarr and nothing else. Both this module's tools and its // events entrypoint import it, and nothing outside radarr does. +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + // Radarr speaks the v3 API; its content is "movie". const API_VERSION = "v3"; const CONTENT_ENDPOINT = "movie"; @@ -42,20 +45,34 @@ export class RadarrClient { } /** - * Build from the module's resolved environment. URL and key are read from MESH_RADARR_URL and - * MESH_RADARR_API_KEY; both must be present — an unconfigured Radarr throws rather than pretend to - * be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes + * Build from the module's resolved environment. The URL defaults to the server on this node (the + * runtime shares its network), and the API key is read from MESH_RADARR_API_KEY or, failing that, + * discovered from the server's own config.xml under MESH_RADARR_CONFIG_DIR — the same file Radarr + * writes it to, so a running server needs nothing configured by hand. Throws when no key can be + * found, so the tools/events simply do not load (the harness treats the throw as "exposes * nothing"). */ static fromEnv(env: NodeJS.ProcessEnv = process.env): RadarrClient { - const url = env.MESH_RADARR_URL; - const apiKey = env.MESH_RADARR_API_KEY; - if (!url || !apiKey) { - throw new Error("Radarr not configured — set MESH_RADARR_URL and MESH_RADARR_API_KEY"); + const url = env.MESH_RADARR_URL ?? `http://127.0.0.1:${env.MESH_RADARR_PORT ?? "7878"}`; + const configDir = env.MESH_RADARR_CONFIG_DIR ?? "/config"; + const apiKey = env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir); + if (!apiKey) { + throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable"); } return new RadarrClient(url, apiKey); } + /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app + * writes into config.xml at the root of its config directory. */ + static detectApiKey(configDir: string): string | null { + const config = join(configDir, "config.xml"); + if (existsSync(config)) { + const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); + if (match) return match[1]; + } + return null; + } + private async get(endpoint: string, params?: Record): Promise { const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); if (params) { diff --git a/modules/radarr/module.json b/modules/radarr/module.json index a66fc63..607972f 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -66,6 +66,22 @@ "/services/media/movies:/movies", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-radarr", + "image": "mesh-runtime-radarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/radarr/broker:/run/secrets/broker:ro", + "/services/radarr/config:/var/lib/radarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RADARR_URL": "http://127.0.0.1:7878", + "MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config" + } } ] } diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts index d5f9c89..fbf7ec2 100644 --- a/modules/sonarr/client.ts +++ b/modules/sonarr/client.ts @@ -3,6 +3,9 @@ // change to Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its // events entrypoint import it, and nothing outside sonarr does. +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + // Sonarr speaks the v3 API; its content is "series". const API_VERSION = "v3"; const CONTENT_ENDPOINT = "series"; @@ -42,20 +45,34 @@ export class SonarrClient { } /** - * Build from the module's resolved environment. URL and key are read from MESH_SONARR_URL and - * MESH_SONARR_API_KEY; both must be present — an unconfigured Sonarr throws rather than pretend to - * be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes - * nothing"). + * Build from the module's resolved environment. The URL defaults to the server on this node + * (the runtime shares its network), and the API key is read from MESH_SONARR_API_KEY or, failing + * that, discovered from the server's own config.xml under MESH_SONARR_CONFIG_DIR — the same file + * Sonarr writes it to, so a running server needs nothing configured by hand (as plex does with + * its token). Throws when no key can be found, so the tools/events simply do not load (the harness + * treats the throw as "exposes nothing"). */ static fromEnv(env: NodeJS.ProcessEnv = process.env): SonarrClient { - const url = env.MESH_SONARR_URL; - const apiKey = env.MESH_SONARR_API_KEY; - if (!url || !apiKey) { - throw new Error("Sonarr not configured — set MESH_SONARR_URL and MESH_SONARR_API_KEY"); + const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`; + const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config"; + const apiKey = env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir); + if (!apiKey) { + throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable"); } return new SonarrClient(url, apiKey); } + /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app + * writes into config.xml at the root of its config directory. */ + static detectApiKey(configDir: string): string | null { + const config = join(configDir, "config.xml"); + if (existsSync(config)) { + const match = readFileSync(config, "utf8").match(/([^<]+)<\/ApiKey>/); + if (match) return match[1]; + } + return null; + } + private async get(endpoint: string, params?: Record): Promise { const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); if (params) { diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index 645da0b..9e19164 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -74,6 +74,22 @@ "/services/media/anime:/anime", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-sonarr", + "image": "mesh-runtime-sonarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro", + "/services/sonarr/config:/var/lib/sonarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_SONARR_URL": "http://127.0.0.1:8989", + "MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config" + } } ] } From 9e156a5b9ee0a5db16f99e58637fc3bcf5a7343c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 23:08:40 +0200 Subject: [PATCH 25/28] Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH__CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/bazarr/client.ts | 14 ++++++++++-- modules/bazarr/module.json | 18 +++++++++++++++ modules/gitea/client.ts | 14 ++++++++++-- modules/gitea/module.json | 24 ++++++++++++++++++++ modules/grafana/client.ts | 18 +++++++++++---- modules/grafana/module.json | 24 ++++++++++++++++++++ modules/home-assistant/client.ts | 14 ++++++++++-- modules/home-assistant/module.json | 18 +++++++++++++++ modules/icecast/client.ts | 14 ++++++++++-- modules/icecast/module.json | 24 ++++++++++++++++++++ modules/influxdb/client.ts | 16 +++++++++++--- modules/influxdb/module.json | 27 ++++++++++++++++++++++- modules/jackett/client.ts | 14 ++++++++++-- modules/jackett/module.json | 29 ++++++++++++++++++++++++- modules/keycloak/client.ts | 18 +++++++++++---- modules/keycloak/module.json | 24 ++++++++++++++++++++ modules/mailu/client.ts | 15 ++++++++++--- modules/mailu/module.json | 25 +++++++++++++++++++++ modules/nextcloud/client.ts | 17 +++++++++++---- modules/nextcloud/module.json | 25 +++++++++++++++++++++ modules/nodered/client.ts | 14 ++++++++++-- modules/nodered/module.json | 24 ++++++++++++++++++++ modules/nzbget/client.ts | 16 +++++++++++--- modules/nzbget/module.json | 18 +++++++++++++++ modules/ombi/client.ts | 14 ++++++++++-- modules/ombi/module.json | 18 +++++++++++++++ modules/photos/client.ts | 14 ++++++++++-- modules/photos/module.json | 19 ++++++++++++++++ modules/portainer/client.ts | 14 ++++++++++-- modules/portainer/module.json | 35 +++++++++++++++++++++++++++++- modules/qbittorrent/client.ts | 16 +++++++++++--- modules/qbittorrent/module.json | 18 +++++++++++++++ modules/searxng/client.ts | 12 +++++++++- modules/searxng/module.json | 33 +++++++++++++++++++++++++++- modules/tautulli/client.ts | 14 ++++++++++-- modules/tautulli/module.json | 18 +++++++++++++++ modules/verdaccio/client.ts | 14 ++++++++++-- modules/verdaccio/module.json | 16 ++++++++++++++ 38 files changed, 668 insertions(+), 51 deletions(-) diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts index b84f6c5..9d6acce 100644 --- a/modules/bazarr/client.ts +++ b/modules/bazarr/client.ts @@ -3,6 +3,8 @@ // missing subtitles, searches providers for them, and records what it downloaded. This client // talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it. +import { readFileSync } from "node:fs"; + export interface WantedSubtitle { kind: "episode" | "movie"; title: string; // series + episode, or movie title @@ -34,6 +36,13 @@ export interface HistoryEntry { description?: 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 BazarrClient { readonly baseUrl: string; @@ -47,8 +56,9 @@ export class BazarrClient { /** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key * there is nothing to talk to, so this throws rather than run half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { - const url = env.MESH_BAZARR_URL; - const apiKey = env.MESH_BAZARR_API_KEY; + const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE); + const url = cfg.url ?? env.MESH_BAZARR_URL; + const apiKey = cfg.apiKey ?? env.MESH_BAZARR_API_KEY; if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL"); if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY"); return new BazarrClient(url, apiKey); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index b90dd88..d5018a2 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -80,6 +80,24 @@ "/services/media/anime:/anime", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-bazarr", + "image": "mesh-runtime-bazarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro", + "/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro", + "/services/bazarr/config:/var/lib/bazarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_BAZARR_URL": "http://127.0.0.1:6767", + "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", + "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" + } } ] } diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index c17b84a..5fe7182 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -3,6 +3,8 @@ // gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea // does. +import { readFileSync } from "node:fs"; + /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { full_name: string; @@ -42,6 +44,13 @@ export interface GiteaLabel { name: 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 GiteaClient { readonly baseUrl: string; private cachedUsername: string | null = null; @@ -60,8 +69,9 @@ export class GiteaClient { * call to make, so this throws rather than hand back a client that fails on first use. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { - const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; - const token = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; + const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); return new GiteaClient(url, token); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index b6af10e..b8ec92d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -88,6 +88,30 @@ "volumes": [ "/services/gitea/gitea:/data" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/gitea/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-gitea", + "image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", + "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_GITEA_URL": "http://127.0.0.1:3000", + "MESH_GITEA_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/grafana/client.ts b/modules/grafana/client.ts index 41395ae..00916c3 100644 --- a/modules/grafana/client.ts +++ b/modules/grafana/client.ts @@ -2,6 +2,8 @@ // the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both // this module's tools and its events entrypoint import it, and nothing outside grafana does. +import { readFileSync } from "node:fs"; + export interface GrafanaHealth { database: string; version: string; @@ -35,6 +37,13 @@ export interface GrafanaAlert { activeAt?: 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 GrafanaClient { readonly baseUrl: string; private readonly authHeader: string; @@ -51,12 +60,13 @@ export class GrafanaClient { * Throws when neither is configured — the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { - const url = env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; - const token = env.MESH_GRAFANA_TOKEN; + const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; + const token = cfg.token ?? env.MESH_GRAFANA_TOKEN; if (token) return new GrafanaClient(url, `Bearer ${token}`); - const password = env.MESH_GRAFANA_PASSWORD; + const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD; if (password) { - const user = env.MESH_GRAFANA_USER ?? "admin"; + const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin"; return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); } throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 3835b69..5d9626e 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -60,6 +60,30 @@ "volumes": [ "/services/grafana/data:/var/lib/grafana" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/grafana/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-grafana", + "image": "mesh-runtime-grafana@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/grafana/broker:/run/secrets/broker:ro", + "/var/lib/mesh/grafana/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_GRAFANA_URL": "http://127.0.0.1:3000", + "MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts index d7aefa9..d3900ba 100644 --- a/modules/home-assistant/client.ts +++ b/modules/home-assistant/client.ts @@ -2,6 +2,8 @@ // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // home-assistant does. Talks to the HA REST API (/api) with a long-lived access token. +import { readFileSync } from "node:fs"; + export interface HAEntityState { entity_id: string; state: string; @@ -18,6 +20,13 @@ export interface HAConfig { state?: 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 HomeAssistantClient { readonly baseUrl: string; @@ -34,8 +43,9 @@ export class HomeAssistantClient { * every API call is Bearer-authenticated and there is nowhere to discover it from. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { - const url = env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; - const token = env.MESH_HOMEASSISTANT_TOKEN; + const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; + const token = cfg.token ?? env.MESH_HOMEASSISTANT_TOKEN; if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN"); return new HomeAssistantClient(url, token); } diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 3e63d64..d6cee10 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -44,6 +44,24 @@ "volumes": [ "/services/home-assistant/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-home-assistant", + "image": "mesh-runtime-home-assistant@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro", + "/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro", + "/services/home-assistant/config:/var/lib/home-assistant/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123", + "MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json", + "MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config" + } } ] } diff --git a/modules/icecast/client.ts b/modules/icecast/client.ts index d074b55..7035c3d 100644 --- a/modules/icecast/client.ts +++ b/modules/icecast/client.ts @@ -3,6 +3,8 @@ // endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and // the basis for both the status tool and the stream started/stopped events. +import { readFileSync } from "node:fs"; + export interface IcecastMount { /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ mount: string; @@ -33,6 +35,13 @@ interface RawSource { server_type?: 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 IcecastClient { readonly baseUrl: string; private readonly authHeader?: string; @@ -46,8 +55,9 @@ export class IcecastClient { } static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { - const url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`; - return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, env.MESH_ICECAST_ADMIN_PASSWORD); + const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`); + return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD); } async getStatus(): Promise { diff --git a/modules/icecast/module.json b/modules/icecast/module.json index b60778d..5a703eb 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -53,6 +53,30 @@ "ports": [ "8000" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/icecast/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-icecast", + "image": "mesh-runtime-icecast@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/icecast/broker:/run/secrets/broker:ro", + "/var/lib/mesh/icecast/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_ICECAST_URL": "http://127.0.0.1:8000", + "MESH_ICECAST_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts index 12a81eb..ab0fc73 100644 --- a/modules/influxdb/client.ts +++ b/modules/influxdb/client.ts @@ -1,6 +1,8 @@ // The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only // this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token. +import { readFileSync } from "node:fs"; + export interface InfluxHealth { name?: string; status?: string; @@ -15,6 +17,13 @@ export interface InfluxBucket { retentionSeconds?: number; } +/** 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 InfluxDBClient { readonly baseUrl: string; @@ -32,10 +41,11 @@ export class InfluxDBClient { * is token-authenticated. The org scopes bucket listing and queries. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient { - const url = env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; - const token = env.MESH_INFLUXDB_TOKEN; + const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE); + const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; + const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN; if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN"); - const org = env.MESH_INFLUXDB_ORG ?? "mesh"; + const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh"; return new InfluxDBClient(url, token, org); } diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index ad64e0c..bf95774 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -6,7 +6,8 @@ ], "own-secrets": { "admin": "/var/lib/influxdb-module/admin.secret", - "admin-token": "/var/lib/influxdb-module/admin-token.secret" + "admin-token": "/var/lib/influxdb-module/admin-token.secret", + "broker": "/var/lib/mesh/influxdb/broker" }, "listens": [ { @@ -17,6 +18,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/influxdb", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -59,6 +66,24 @@ "/services/influxdb/data:/var/lib/influxdb2", "/services/influxdb/config:/etc/influxdb2" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-influxdb", + "image": "mesh-runtime-influxdb@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro", + "/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro", + "/services/influxdb/config:/var/lib/influxdb/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_INFLUXDB_URL": "http://127.0.0.1:8086", + "MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json", + "MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config" + } } ] } diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts index bf52da9..1e76be3 100644 --- a/modules/jackett/client.ts +++ b/modules/jackett/client.ts @@ -2,6 +2,8 @@ // an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client // talks its /api/v2.0 REST API, and only jackett's tools import it. +import { readFileSync } from "node:fs"; + export interface JackettIndexer { id: string; name: string; @@ -22,6 +24,13 @@ export interface JackettResult { link?: 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 JackettClient { readonly baseUrl: string; @@ -38,8 +47,9 @@ export class JackettClient { * module contributes no tools rather than failing half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { - const url = env.MESH_JACKETT_URL; - const apiKey = env.MESH_JACKETT_API_KEY; + const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_JACKETT_URL; + const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY; if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY"); return new JackettClient(url, apiKey); diff --git a/modules/jackett/module.json b/modules/jackett/module.json index f1e4a74..6cbb4a0 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/jackett", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -36,6 +42,27 @@ "volumes": [ "/services/jackett/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-jackett", + "image": "mesh-runtime-jackett@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/jackett/broker:/run/secrets/broker:ro", + "/var/lib/mesh/jackett/config.json:/run/config/config.json:ro", + "/services/jackett/config:/var/lib/jackett/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_JACKETT_URL": "http://127.0.0.1:9117", + "MESH_JACKETT_CONFIG_FILE": "/run/config/config.json", + "MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config" + } } - ] + ], + "own-secrets": { + "broker": "/var/lib/mesh/jackett/broker" + } } diff --git a/modules/keycloak/client.ts b/modules/keycloak/client.ts index 431d331..830418c 100644 --- a/modules/keycloak/client.ts +++ b/modules/keycloak/client.ts @@ -3,6 +3,15 @@ // it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and // nothing outside keycloak does. +import { readFileSync } from "node:fs"; + +/** 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 KeycloakClient { readonly baseUrl: string; readonly defaultRealm: string; @@ -28,11 +37,12 @@ export class KeycloakClient { * here lets the tool runtime expose no keycloak tools rather than tools that always error. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { - const url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; - const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; - const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; + const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE); + const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; + const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; + const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD"); - const realm = env.MESH_KEYCLOAK_REALM ?? "master"; + const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master"; return new KeycloakClient(url, adminUser, adminPass, realm); } diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 0bc6c51..7314428 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -91,6 +91,30 @@ "ports": [ "8080" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/keycloak/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-keycloak", + "image": "mesh-runtime-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro", + "/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_KEYCLOAK_URL": "http://127.0.0.1:8080", + "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index fef227f..32053cb 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -9,6 +9,7 @@ // falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve. import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; import { promisify } from "node:util"; const run = promisify(execFile); @@ -44,6 +45,13 @@ export interface MailMessage { // The fields we ask doveadm for, once — kept together so read and search stay identical in shape. const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet"; +/** 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 MailuClient { readonly baseUrl: string; @@ -62,12 +70,13 @@ export class MailuClient { * client with neither would only fail later, one call at a time, so it fails here instead. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient { - const url = env.MESH_MAILU_URL; - const apiKey = env.MESH_MAILU_API_KEY; + const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE); + const url = cfg.url ?? env.MESH_MAILU_URL; + const apiKey = cfg.apiKey ?? env.MESH_MAILU_API_KEY; if (!url || !apiKey) { throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY"); } - const imapContainer = env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; + const imapContainer = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; return new MailuClient(url, apiKey, imapContainer); } diff --git a/modules/mailu/module.json b/modules/mailu/module.json index bf64c8a..c8e902e 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -282,6 +282,31 @@ "/services/mailu/data/certs:/certs", "/services/mailu/data/overrides/nginx:/overrides:ro" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/mailu/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-mailu", + "image": "mesh-runtime-mailu@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", + "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", + "/var/run/docker.sock:/var/run/docker.sock" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_MAILU_URL": "http://127.0.0.1:80", + "MESH_MAILU_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index 5418924..b860680 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -9,6 +9,7 @@ // because occ has no version-stable "list every share" across the releases we run. import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; export interface NextcloudUser { uid: string; @@ -24,6 +25,13 @@ export interface NextcloudShare { owner: 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 NextcloudClient { constructor( private readonly container: string, @@ -40,10 +48,11 @@ export class NextcloudClient { * throws without it, and the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { - const container = env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; - const ocsUrl = env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; - const adminUser = env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; - const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD; + const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); + const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; + const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; + const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD; if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD"); return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); } diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index a84d447..7653607 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -81,6 +81,31 @@ "volumes": [ "/services/nextcloud/html:/var/www/html" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/nextcloud/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nextcloud", + "image": "mesh-runtime-nextcloud@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro", + "/var/run/docker.sock:/var/run/docker.sock" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NEXTCLOUD_URL": "http://127.0.0.1:80", + "MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/nodered/client.ts b/modules/nodered/client.ts index 7b92f59..d760ca6 100644 --- a/modules/nodered/client.ts +++ b/modules/nodered/client.ts @@ -5,6 +5,8 @@ // 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; @@ -18,6 +20,13 @@ export interface NodeRedNodeModule { 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; @@ -35,9 +44,10 @@ export class NodeRedClient { * a default install needs none. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient { - const url = env.MESH_NODERED_URL; + 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, env.MESH_NODERED_TOKEN); + return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN); } private headers(extra: Record = {}): Record { diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 9a48039..b9d84b3 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -46,6 +46,30 @@ "volumes": [ "/services/nodered/data:/data" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/nodered/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nodered", + "image": "mesh-runtime-nodered@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nodered/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nodered/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NODERED_URL": "http://127.0.0.1:1880", + "MESH_NODERED_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts index 668a586..e217387 100644 --- a/modules/nzbget/client.ts +++ b/modules/nzbget/client.ts @@ -3,6 +3,8 @@ // nzbget and nothing else. Both this module's tools and its events entrypoint import it, and // nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. +import { readFileSync } from "node:fs"; + export interface NzbgetStatus { /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ speedBytesPerSec: number; @@ -39,6 +41,13 @@ export interface NzbgetHistoryItem { success: boolean; } +/** 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 NzbgetClient { readonly rpcUrl: string; private readonly auth: string; @@ -55,12 +64,13 @@ export class NzbgetClient { * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { - const url = env.MESH_NZBGET_URL; - const password = env.MESH_NZBGET_PASSWORD; + const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); + const url = cfg.url ?? env.MESH_NZBGET_URL; + const password = cfg.password ?? env.MESH_NZBGET_PASSWORD; if (!url || !password) { throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); } - const user = env.MESH_NZBGET_USER ?? "nzbget"; + const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget"; return new NzbgetClient(url, user, password); } diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 184a1b3..239f8a8 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -58,6 +58,24 @@ "/services/nzbget/config:/config", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nzbget", + "image": "mesh-runtime-nzbget@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nzbget/config.json:/run/config/config.json:ro", + "/services/nzbget/config:/var/lib/nzbget/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NZBGET_URL": "http://127.0.0.1:6789", + "MESH_NZBGET_CONFIG_FILE": "/run/config/config.json", + "MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config" + } } ] } diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts index 870e37e..8016ccb 100644 --- a/modules/ombi/client.ts +++ b/modules/ombi/client.ts @@ -2,6 +2,8 @@ // request front-end: viewers ask for movies and shows, and an operator approves them. This client // talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. +import { readFileSync } from "node:fs"; + export interface OmbiRequest { kind: "movie" | "tv"; id: number; @@ -20,6 +22,13 @@ export interface RequestCounts { available: number; } +/** 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 OmbiClient { readonly baseUrl: string; @@ -33,8 +42,9 @@ export class OmbiClient { /** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there * is nothing to talk to, so this throws rather than run half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { - const url = env.MESH_OMBI_URL; - const apiKey = env.MESH_OMBI_API_KEY; + const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE); + const url = cfg.url ?? env.MESH_OMBI_URL; + const apiKey = cfg.apiKey ?? env.MESH_OMBI_API_KEY; if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); return new OmbiClient(url, apiKey); diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 8a7a5cb..6e29b26 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -49,6 +49,24 @@ "volumes": [ "/services/ombi/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-ombi", + "image": "mesh-runtime-ombi@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/ombi/broker:/run/secrets/broker:ro", + "/var/lib/mesh/ombi/config.json:/run/config/config.json:ro", + "/services/ombi/config:/var/lib/ombi/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_OMBI_URL": "http://127.0.0.1:3579", + "MESH_OMBI_CONFIG_FILE": "/run/config/config.json", + "MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config" + } } ] } diff --git a/modules/photos/client.ts b/modules/photos/client.ts index d0a8b66..2305b1f 100644 --- a/modules/photos/client.ts +++ b/modules/photos/client.ts @@ -3,6 +3,8 @@ // by an API key sent as the `x-api-key` header). The client speaks only what the tools and the // item-added event need: server version and statistics, albums, and recent assets. +import { readFileSync } from "node:fs"; + export interface PhotosServerInfo { version: string; photos?: number; @@ -24,6 +26,13 @@ export interface PhotosAsset { createdAt?: 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 PhotosClient { readonly baseUrl: string; @@ -38,8 +47,9 @@ export class PhotosClient { * the API key is required, and without it the module contributes nothing rather than reaching an * unauthenticated endpoint. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { - const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; - const key = env.MESH_PHOTOS_API_KEY; + const cfg = meshConfig(env.MESH_PHOTOS_CONFIG_FILE); + const url = cfg.url ?? env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; + const key = cfg.apiKey ?? env.MESH_PHOTOS_API_KEY; if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); return new PhotosClient(url, key); } diff --git a/modules/photos/module.json b/modules/photos/module.json index e24f528..555a044 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -47,6 +47,25 @@ "/etc/photos/store.json:/etc/photos/store.json:ro", "/etc/photos/store.secret:/etc/photos/store.secret:ro" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-photos", + "image": "mesh-runtime-photos@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/photos/broker:/run/secrets/broker:ro", + "/var/lib/mesh/photos/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PHOTOS_URL": "http://127.0.0.1:2283", + "MESH_PHOTOS_CONFIG_FILE": "/run/config/config.json" + } } + ], + "capabilities": [ + "container-runtime" ] } diff --git a/modules/portainer/client.ts b/modules/portainer/client.ts index 79eaa29..a7ac3d1 100644 --- a/modules/portainer/client.ts +++ b/modules/portainer/client.ts @@ -3,6 +3,8 @@ // which the host owns and emits — so this module reads Portainer's own resources (endpoints, // stacks, containers) and exposes them, and stops there. +import { readFileSync } from "node:fs"; + export interface PortainerEndpoint { id: number; name: string; @@ -27,6 +29,13 @@ export interface PortainerContainer { status: 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 PortainerClient { readonly baseUrl: string; @@ -44,8 +53,9 @@ export class PortainerClient { * exposes nothing rather than calling Portainer unauthenticated. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { - const url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; - const token = env.MESH_PORTAINER_TOKEN; + const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE); + const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; + const token = cfg.token ?? env.MESH_PORTAINER_TOKEN; if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN"); return new PortainerClient(url, token); } diff --git a/modules/portainer/module.json b/modules/portainer/module.json index 0f0da7a..db02e54 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/portainer", + "mode": "0700" + }, { "id": "data", "type": "directory", @@ -31,6 +37,33 @@ "/services/portainer/data:/data", "/var/run/docker.sock:/var/run/docker.sock" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/portainer/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-portainer", + "image": "mesh-runtime-portainer@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/portainer/broker:/run/secrets/broker:ro", + "/var/lib/mesh/portainer/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PORTAINER_URL": "https://127.0.0.1:9443", + "MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json" + } } - ] + ], + "own-secrets": { + "broker": "/var/lib/mesh/portainer/broker" + } } diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts index 24cc65f..b59c171 100644 --- a/modules/qbittorrent/client.ts +++ b/modules/qbittorrent/client.ts @@ -7,6 +7,8 @@ // against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is // captured on login and carried by hand on every later call, with a single re-login on expiry. +import { readFileSync } from "node:fs"; + export interface QbTransferInfo { dlSpeedBytesPerSec: number; upSpeedBytesPerSec: number; @@ -30,6 +32,13 @@ export interface QbTorrent { savePath: 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 QbittorrentClient { readonly baseUrl: string; private sid: string | null = null; @@ -49,12 +58,13 @@ export class QbittorrentClient { * (the harness treats the throw as "exposes nothing"). The user defaults to "admin". */ static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { - const url = env.MESH_QBITTORRENT_URL; - const password = env.MESH_QBITTORRENT_PASSWORD; + const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_QBITTORRENT_URL; + const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD; if (!url || !password) { throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); } - const user = env.MESH_QBITTORRENT_USER ?? "admin"; + const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin"; return new QbittorrentClient(url, user, password); } diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 4cf1739..5cf65a8 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -58,6 +58,24 @@ "/services/qbittorrent/config:/config", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-qbittorrent", + "image": "mesh-runtime-qbittorrent@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro", + "/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro", + "/services/qbittorrent/config:/var/lib/qbittorrent/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", + "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", + "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" + } } ] } diff --git a/modules/searxng/client.ts b/modules/searxng/client.ts index bfeb7a9..8bddb1a 100644 --- a/modules/searxng/client.ts +++ b/modules/searxng/client.ts @@ -3,6 +3,8 @@ // merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool // useful; the client speaks only that. No credential — the instance is reached inside the mesh. +import { readFileSync } from "node:fs"; + export interface SearxResult { title: string; url: string; @@ -26,6 +28,13 @@ export interface SearxOptions { pageno?: number; } +/** 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 SearxngClient { readonly baseUrl: string; @@ -36,7 +45,8 @@ export class SearxngClient { /** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL * is all it takes — defaulting to the container's own listen port. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient { - const url = env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`; + const cfg = meshConfig(env.MESH_SEARXNG_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`); return new SearxngClient(url); } diff --git a/modules/searxng/module.json b/modules/searxng/module.json index 8262a4b..a19c064 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -5,7 +5,8 @@ "container-runtime" ], "own-secrets": { - "secret": "/var/lib/searxng-module/secret.secret" + "secret": "/var/lib/searxng-module/secret.secret", + "broker": "/var/lib/mesh/searxng/broker" }, "listens": [ { @@ -16,6 +17,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/searxng", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -64,6 +71,30 @@ "ports": [ "8080" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/searxng/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-searxng", + "image": "mesh-runtime-searxng@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/searxng/broker:/run/secrets/broker:ro", + "/var/lib/mesh/searxng/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_SEARXNG_URL": "http://127.0.0.1:8080", + "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts index 6c308c6..2096a07 100644 --- a/modules/tautulli/client.ts +++ b/modules/tautulli/client.ts @@ -5,6 +5,8 @@ // { response: { result: "success" | "error", message, data } }. This client unwraps that envelope // and hands back only the data. +import { readFileSync } from "node:fs"; + export interface TautulliSession { user: string; title: string; @@ -32,6 +34,13 @@ export interface TautulliHomeStat { rows: Array>; } +/** 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 TautulliClient { readonly baseUrl: string; @@ -48,8 +57,9 @@ export class TautulliClient { * Throws when no key is configured — the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { - const url = env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`; - const apiKey = env.MESH_TAUTULLI_APIKEY; + const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`); + const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY; if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY"); return new TautulliClient(url, apiKey); } diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index 27bfc8f..94ad122 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -48,6 +48,24 @@ "volumes": [ "/services/tautulli/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-tautulli", + "image": "mesh-runtime-tautulli@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro", + "/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro", + "/services/tautulli/config:/var/lib/tautulli/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_TAUTULLI_URL": "http://127.0.0.1:8181", + "MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json", + "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config" + } } ] } diff --git a/modules/verdaccio/client.ts b/modules/verdaccio/client.ts index 9948fdf..0a9acb0 100644 --- a/modules/verdaccio/client.ts +++ b/modules/verdaccio/client.ts @@ -2,6 +2,8 @@ // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // verdaccio does. +import { readFileSync } from "node:fs"; + export interface VerdaccioPackage { name: string; version?: string; @@ -17,6 +19,13 @@ export interface PackageInfo { modified?: 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 VerdaccioClient { readonly baseUrl: string; @@ -34,9 +43,10 @@ export class VerdaccioClient { * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient { - const url = env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`; + const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`); if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL"); - return new VerdaccioClient(url, env.MESH_VERDACCIO_TOKEN); + return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN); } private async getJson(path: string): Promise { diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index f9d93c0..1f35dcf 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -58,6 +58,22 @@ "/services/verdaccio/storage:/verdaccio/storage", "/services/verdaccio/conf:/verdaccio/conf" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-verdaccio", + "image": "mesh-runtime-verdaccio@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro", + "/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_VERDACCIO_URL": "http://127.0.0.1:4873", + "MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json" + } } ] } From 550393b84743762ef792bc364150003b21ea61b9 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 23:40:07 +0200 Subject: [PATCH 26/28] Runtime config: dedicated mergeable file + restart-on so settings take effect (issue 009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each settings-config runtime a mergeable config file with its own id (runtime-config) — the previous "config" collided with modules that already own a config directory, so ten runtimes mounted a config file no resource declared. Point each runtime's restart-on at it, so a settings change recreates the runtime and it re-reads the new value (needs the mesh-host container restart-on fix on issue/009-container-restart-on). Proven: runtime-restart-on-config e2e green. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/bazarr/module.json | 13 ++++++++++++- modules/gitea/module.json | 7 +++++-- modules/grafana/module.json | 7 +++++-- modules/home-assistant/module.json | 13 ++++++++++++- modules/icecast/module.json | 7 +++++-- modules/influxdb/module.json | 13 ++++++++++++- modules/jackett/module.json | 13 ++++++++++++- modules/keycloak/module.json | 7 +++++-- modules/mailu/module.json | 7 +++++-- modules/nextcloud/module.json | 7 +++++-- modules/nodered/module.json | 7 +++++-- modules/nzbget/module.json | 13 ++++++++++++- modules/ombi/module.json | 13 ++++++++++++- modules/photos/module.json | 13 ++++++++++++- modules/portainer/module.json | 7 +++++-- modules/qbittorrent/module.json | 13 ++++++++++++- modules/searxng/module.json | 7 +++++-- modules/tautulli/module.json | 13 ++++++++++++- modules/verdaccio/module.json | 13 ++++++++++++- 19 files changed, 165 insertions(+), 28 deletions(-) diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index d5018a2..724f0e3 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -81,6 +81,14 @@ "/services/media/downloads:/downloads" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/bazarr/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -97,7 +105,10 @@ "MESH_BAZARR_URL": "http://127.0.0.1:6767", "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index b8ec92d..f539bd9 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -90,7 +90,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/gitea/config.json", "mode": "0600", @@ -111,7 +111,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_GITEA_URL": "http://127.0.0.1:3000", "MESH_GITEA_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 5d9626e..8513f35 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -62,7 +62,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/grafana/config.json", "mode": "0600", @@ -83,7 +83,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_GRAFANA_URL": "http://127.0.0.1:3000", "MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index d6cee10..2f01f45 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -45,6 +45,14 @@ "/services/home-assistant/config:/config" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/home-assistant/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -61,7 +69,10 @@ "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123", "MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json", "MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 5a703eb..6d49425 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -55,7 +55,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/icecast/config.json", "mode": "0600", @@ -76,7 +76,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_ICECAST_URL": "http://127.0.0.1:8000", "MESH_ICECAST_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index bf95774..62c31dc 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -67,6 +67,14 @@ "/services/influxdb/config:/etc/influxdb2" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/influxdb/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -83,7 +91,10 @@ "MESH_INFLUXDB_URL": "http://127.0.0.1:8086", "MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json", "MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/jackett/module.json b/modules/jackett/module.json index 6cbb4a0..d4ed722 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -43,6 +43,14 @@ "/services/jackett/config:/config" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/jackett/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -59,7 +67,10 @@ "MESH_JACKETT_URL": "http://127.0.0.1:9117", "MESH_JACKETT_CONFIG_FILE": "/run/config/config.json", "MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ], "own-secrets": { diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 7314428..4d4c4b0 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -93,7 +93,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/keycloak/config.json", "mode": "0600", @@ -114,7 +114,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_KEYCLOAK_URL": "http://127.0.0.1:8080", "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/mailu/module.json b/modules/mailu/module.json index c8e902e..d73c409 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -284,7 +284,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/mailu/config.json", "mode": "0600", @@ -306,7 +306,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_MAILU_URL": "http://127.0.0.1:80", "MESH_MAILU_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 7653607..1544b10 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -83,7 +83,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/nextcloud/config.json", "mode": "0600", @@ -105,7 +105,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_NEXTCLOUD_URL": "http://127.0.0.1:80", "MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/nodered/module.json b/modules/nodered/module.json index b9d84b3..a512948 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -48,7 +48,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/nodered/config.json", "mode": "0600", @@ -69,7 +69,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_NODERED_URL": "http://127.0.0.1:1880", "MESH_NODERED_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 239f8a8..2c0b92d 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -59,6 +59,14 @@ "/services/media/downloads:/downloads" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/nzbget/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -75,7 +83,10 @@ "MESH_NZBGET_URL": "http://127.0.0.1:6789", "MESH_NZBGET_CONFIG_FILE": "/run/config/config.json", "MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 6e29b26..f16bea2 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -50,6 +50,14 @@ "/services/ombi/config:/config" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/ombi/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -66,7 +74,10 @@ "MESH_OMBI_URL": "http://127.0.0.1:3579", "MESH_OMBI_CONFIG_FILE": "/run/config/config.json", "MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/photos/module.json b/modules/photos/module.json index 555a044..b844b03 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -48,6 +48,14 @@ "/etc/photos/store.secret:/etc/photos/store.secret:ro" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/photos/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -62,7 +70,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_PHOTOS_URL": "http://127.0.0.1:2283", "MESH_PHOTOS_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ], "capabilities": [ diff --git a/modules/portainer/module.json b/modules/portainer/module.json index db02e54..09d6898 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -39,7 +39,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/portainer/config.json", "mode": "0600", @@ -60,7 +60,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_PORTAINER_URL": "https://127.0.0.1:9443", "MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ], "own-secrets": { diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 5cf65a8..daa6629 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -59,6 +59,14 @@ "/services/media/downloads:/downloads" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/qbittorrent/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -75,7 +83,10 @@ "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/searxng/module.json b/modules/searxng/module.json index a19c064..d1d48dc 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -73,7 +73,7 @@ ] }, { - "id": "config", + "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/searxng/config.json", "mode": "0600", @@ -94,7 +94,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_SEARXNG_URL": "http://127.0.0.1:8080", "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index 94ad122..1b63d19 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -49,6 +49,14 @@ "/services/tautulli/config:/config" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/tautulli/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -65,7 +73,10 @@ "MESH_TAUTULLI_URL": "http://127.0.0.1:8181", "MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json", "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index 1f35dcf..17f963a 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -59,6 +59,14 @@ "/services/verdaccio/conf:/verdaccio/conf" ] }, + { + "id": "runtime-config", + "type": "file", + "path": "/var/lib/mesh/verdaccio/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, { "id": "runtime", "type": "container", @@ -73,7 +81,10 @@ "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_VERDACCIO_URL": "http://127.0.0.1:4873", "MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json" - } + }, + "restart-on": [ + "runtime-config" + ] } ] } From 3b03fcd8f74dc9791b536eee07e1b48cee784835 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 00:27:37 +0200 Subject: [PATCH 27/28] Providers create the credential the mesh minted, sealing nothing (ADR 0053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redis, postgres and minio adapters drop generatePassword + the returned credential: each creates the resource under the login the mesh derived (`as`) with the password the mesh minted (`p.password`). minio's client gains a secret-key argument so it sets the mesh's secret rather than generating one. umami (analytics) is re-pointed at the new contract too; its siteId return is a data-provision concern ADR 0053 scopes out. Proven: mesh-lab provider-uses-mesh-credential green — redis creates the consumer's login with the mesh's password, the consumer authenticates (PONG), no seal key set. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/minio/client.ts | 15 +++--- modules/minio/provisioner/index.ts | 64 +++++++++++--------------- modules/postgres/provisioner/index.ts | 66 ++++++++++----------------- modules/redis/provisioner/index.ts | 59 ++++++++++-------------- modules/umami/provisioner/index.ts | 42 +++++++++-------- 5 files changed, 107 insertions(+), 139 deletions(-) diff --git a/modules/minio/client.ts b/modules/minio/client.ts index 5a0f29d..4086b58 100644 --- a/modules/minio/client.ts +++ b/modules/minio/client.ts @@ -10,7 +10,7 @@ // creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form. // This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env. -import { createHash, createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { execFile } from "node:child_process"; import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -205,14 +205,15 @@ export class MinioClient { // --- admin plane (mc CLI) ------------------------------------------------ /** - * Create a service account scoped to one bucket and return its credential. The MinIO admin REST - * API encrypts this request with a key derived (Argon2) from the root secret, which node built-ins - * cannot reproduce — so, as hal did, the module drives the `mc` CLI, which the provisioner image - * bundles. + * Create a service account scoped to one bucket, under a given access key and secret key, and + * return the pair. The secret key is the mesh's — the mesh mints one password per consumer and + * hands a copy to both ends (novox/hq ADR 0053), so minio sets that as the secret rather than + * generating one the consumer could never learn. The MinIO admin REST API encrypts this request + * with a key derived (Argon2) from the root secret, which node built-ins cannot reproduce — so, as + * hal did, the module drives the `mc` CLI, which the runtime image bundles. */ - async createAccessKey(bucket: string, accessKey: string): Promise { + async createAccessKey(bucket: string, accessKey: string, secretKey: string): Promise { await this.ensureAlias(); - const secretKey = randomBytes(20).toString("hex"); const policyPath = join(this.mcConfigDir, `policy-${accessKey}.json`); writeFileSync(policyPath, bucketPolicy(bucket), { mode: 0o600 }); try { diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index a69a37d..dd5957b 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -1,72 +1,62 @@ // minio's provisioner — the adapter that makes minio a provider of the mesh `s3-bucket` interface -// (the name in module.json's `provides`). The reconcile loop, sealing and grant-file handling are the -// sdk harness's; this writes only the per-service half: how minio creates and removes a consumer's -// bucket and its scoped access key (novox/hq ADR 0044/0045). +// (the name in module.json's `provides`). The reconcile loop, the contributions file, and reading +// the mesh's minted secret are the sdk harness's; this writes only the per-service half: how minio +// creates and removes a consumer's bucket and its scoped access key (novox/hq ADR 0044/0045/0053). // -// The `s3-bucket` interface: a consumer receives `{ endpoint, bucket, accessKey, secretKey, region }` -// — an S3 endpoint and a credential confined to its own bucket. It depends on `s3-bucket`, not on -// minio, so any S3-compatible provider could serve it. +// The `s3-bucket` interface: a consumer connects to an S3 endpoint with an access key confined to +// its own bucket. It depends on `s3-bucket`, not on minio, so any S3-compatible provider could serve +// it. // -// The bucket and access-key id are derived deterministically from the consumer's identity, because -// the harness hands `remove` only that identity (no stored values) — so teardown recomputes exactly -// what creation minted, with nothing to persist. The emits fire here, at the real provisioning -// points (novox/hq ADR 0046/0047); the module's events entrypoint (../index.ts) consumes them. +// **The access key and its secret are the mesh's, not the provisioner's (ADR 0053).** The mesh +// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio +// creates the service account under exactly that access key with exactly that secret — a credential +// the provisioner invented is one the consumer could never present. The bucket is derived from the +// login, so teardown recomputes it with nothing to persist. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; -import { MinioClient, accessKeyFor, bucketFor } from "../client.js"; +import { MinioClient, bucketFor } from "../client.js"; const minio = MinioClient.fromEnv(); runProvisioner("s3-bucket", { - async create(grant: Grant): Promise { - const bucket = bucketFor(grant.consumer); - const accessKeyId = accessKeyFor(grant.consumer); + async create(p: Provision): Promise { + const bucket = bucketFor(p.as); + const accessKeyId = p.as; if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); - // Re-mint the scoped key idempotently: drop any prior one under this id, then add fresh. + // Re-mint the scoped key idempotently: drop any prior one under this id, then add it back with + // the mesh's secret. try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } - const key = await minio.createAccessKey(bucket, accessKeyId); + await minio.createAccessKey(bucket, accessKeyId, p.password); await announce("module.minio.bucket.created", { bucket, - consumer: grant.consumer, - node: grant.node, - accessKey: key.accessKey, // the secret is never put on the bus — only the credential file carries it + consumer: p.consumer ?? "", + accessKey: accessKeyId, endpoint: minio.baseUrl, }); - - return { - fields: { - endpoint: minio.baseUrl, - bucket, - accessKey: key.accessKey, - secretKey: key.secretKey, - region: minio.region, - }, - }; }, - async remove(grant: Grant): Promise { - const bucket = bucketFor(grant.consumer); - const accessKeyId = accessKeyFor(grant.consumer); + async remove(p: { as: string }): Promise { + const bucket = bucketFor(p.as); // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if // empty; a bucket that still holds objects is left for an operator rather than erroring on every // reconcile tick — access is already gone, and silently deleting a consumer's data would be worse. - try { await minio.removeAccessKey(accessKeyId); } catch { /* already gone */ } + try { await minio.removeAccessKey(p.as); } catch { /* already gone */ } try { await minio.removeBucket(bucket); } catch (err) { console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); } - await announce("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node }); + await announce("module.minio.bucket.removed", { bucket, accessKey: p.as }); }, }); -/** Emit best-effort: with no broker bound (a provisioner is not yet a runtime — novox/hq ADR 0052) - * the event is logged and dropped, never allowed to throw back and fail a bucket that was made. */ +/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a + * bucket that was made. */ async function announce(type: string, body: unknown): Promise { try { await emit(type, body); diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 3368b38..b3a4303 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -1,33 +1,25 @@ // postgres's provisioner — the adapter that makes postgres a provider of the mesh -// `postgres-database` interface. The watching, sealing and grant-file handling are the sdk -// harness's; this writes only the per-service half: how postgres creates and removes a consumer's -// database + owning role (novox/hq ADR 0044/0045). +// `postgres-database` interface. The reconcile loop, the contributions file, and reading the mesh's +// minted password are the sdk harness's; this writes only the per-service half: how postgres creates +// and removes a consumer's database + owning role (novox/hq ADR 0044/0045/0053). // -// The `postgres-database` interface: a consumer receives `{ host, port, database, user, password }` -// and connects to a database only it owns. +// The `postgres-database` interface: a consumer connects to a database it alone owns, as `as` with +// the password the mesh minted. // -// Identity (the database and role names) is derived from `grant.consumer` alone — never from -// `grant.values` — because on removal the harness hands the adapter a grant carrying only the -// consumer. Deriving from the consumer keeps create and remove naming the same resource. +// **The role name and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives +// the login and hands it to both ends, and mints the password. postgres creates a role and a +// same-named database under exactly that login — a name the consumer cannot learn is a database it +// cannot reach. // -// The credential is composed here and returned; the DDL runs through PostgresClient.query(), which -// is the module's one pending boundary (see client.ts). Until that boundary is backed, create() -// surfaces the TODO honestly rather than sealing a credential for a database that was never made. +// The DDL runs through PostgresClient.query(), which is the module's one pending boundary (see +// client.ts). -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; -import { PostgresClient, generatePassword } from "../client.js"; +import { PostgresClient } from "../client.js"; const postgres = PostgresClient.fromEnv(); -/** A stable postgres identifier for a consumer: lowercase [a-z0-9_], never starting with a digit. */ -function identity(consumer: string): string { - let safe = consumer.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/^_+|_+$/g, ""); - if (safe === "" ) safe = "consumer"; - if (/^[0-9]/.test(safe)) safe = "_" + safe; - return safe.slice(0, 63); // postgres identifier limit -} - /** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ async function announce(type: string, body: Record): Promise { try { @@ -38,27 +30,19 @@ async function announce(type: string, body: Record): Promise { - const database = identity(grant.consumer); - const user = database; - const password = generatePassword(); - await postgres.createDatabaseAndRole(database, user, password); - await announce("module.postgres.database.provisioned", { consumer: grant.consumer, database, user }); - return { - fields: { - host: postgres.host, - port: String(postgres.port), - database, - user, - password, - }, - }; + async create(p: Provision): Promise { + // Database and owning role share the consumer's login, so the consumer owns exactly its own. + const database = p.as; + await postgres.createDatabaseAndRole(database, p.as, p.password); + await announce("module.postgres.database.provisioned", { + consumer: p.consumer ?? "", + database, + user: p.as, + }); }, - async remove(grant: Grant): Promise { - const database = identity(grant.consumer); - const user = database; - await postgres.dropDatabaseAndRole(database, user); - await announce("module.postgres.database.deprovisioned", { consumer: grant.consumer, database }); + async remove(p: { as: string }): Promise { + await postgres.dropDatabaseAndRole(p.as, p.as); + await announce("module.postgres.database.deprovisioned", { database: p.as }); }, }); diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index f8d7df7..0ad363b 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -1,27 +1,23 @@ // redis's provisioner — the adapter that makes redis a provider of the mesh `redis-cache` -// interface. The watching, sealing and grant-file handling are the sdk harness's; this writes only -// the per-service half: how redis creates and removes a per-consumer cache (novox/hq ADR 0044/0045). +// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are +// the sdk harness's; this writes only the per-service half: how redis creates and removes a +// per-consumer cache (novox/hq ADR 0044/0045/0053). // -// The `redis-cache` interface: a consumer receives `{ host, port, username, password, -// keyspacePrefix }` and stores its keys under `:*`, isolated from every other -// consumer by an ACL user scoped to exactly that prefix. +// The `redis-cache` interface: a consumer connects as `as` with the password the mesh minted, and +// stores its keys under `:*`, isolated from every other consumer by an ACL user scoped to +// exactly that prefix. // -// Identity (the ACL username and keyspace) is derived from `grant.consumer` alone — never from -// `grant.values` — because on removal the harness hands the adapter a grant carrying only the -// consumer. Deriving from the consumer keeps create and remove naming the same resource. +// **The login and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives the +// login and hands it to both ends so they agree, and mints the password and delivers a copy to each. +// redis creates exactly that login with exactly that password — a name or password the provisioner +// invented is one the consumer could never present. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; -import { RedisClient, generatePassword } from "../client.js"; +import { RedisClient } from "../client.js"; const redis = RedisClient.fromEnv(); -/** A stable, ACL-safe identity for a consumer: only [A-Za-z0-9_.-], never empty. */ -function identity(consumer: string): string { - const safe = consumer.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^_+|_+$/g, ""); - return safe || "consumer"; -} - /** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ async function announce(type: string, body: Record): Promise { try { @@ -32,26 +28,19 @@ async function announce(type: string, body: Record): Promise { - const username = identity(grant.consumer); - const keyspacePrefix = username; - const password = generatePassword(); - await redis.createAclUser(username, password, keyspacePrefix); - await announce("module.redis.cache.provisioned", { consumer: grant.consumer, username, keyspacePrefix }); - return { - fields: { - host: redis.host, - port: String(redis.port), - username, - password, - keyspacePrefix, - }, - }; + async create(p: Provision): Promise { + // The keyspace is scoped to the consumer's own login, so one cannot read another's keys. + const keyspacePrefix = p.as; + await redis.createAclUser(p.as, p.password, keyspacePrefix); + await announce("module.redis.cache.provisioned", { + consumer: p.consumer ?? "", + username: p.as, + keyspacePrefix, + }); }, - async remove(grant: Grant): Promise { - const username = identity(grant.consumer); - await redis.deleteAclUser(username); - await announce("module.redis.cache.deprovisioned", { consumer: grant.consumer, username }); + async remove(p: { as: string }): Promise { + await redis.deleteAclUser(p.as); + await announce("module.redis.cache.deprovisioned", { username: p.as }); }, }); diff --git a/modules/umami/provisioner/index.ts b/modules/umami/provisioner/index.ts index 33f6f5f..f9ba5c2 100644 --- a/modules/umami/provisioner/index.ts +++ b/modules/umami/provisioner/index.ts @@ -1,35 +1,39 @@ // umami's provisioner — the adapter that makes umami a provider of the mesh `analytics` interface. -// The watching, sealing and grant-file handling are the sdk harness's; this writes only the -// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045). +// The reconcile loop and the contributions file are the sdk harness's; this writes only the +// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045/0053). // // The `analytics` interface: a consumer contributes `{ domain }` (the site it wants tracked) and -// receives `{ siteId, snippet, dashboard }`. umami adapts its own API to that contract, so a -// consumer depends on `analytics`, not on umami. +// receives `{ siteId, snippet, dashboard }`. +// +// **A note on scope (ADR 0053).** ADR 0053 corrects *credential* provisions: the mesh mints a secret +// and the provider creates a login with it. Analytics is not that shape — it mints no secret the +// consumer authenticates with; what the consumer needs back is data umami *generates* (the siteId). +// The credential-provisioner contract returns nothing, so the siteId does not travel back to the +// consumer here. That return path — for a provider that generates data rather than being handed a +// secret — is a separate concern and is not solved by this decision. umami still reconciles its +// sites off the mesh's contributions (it keys on the login the mesh derived), which is what this +// keeps working. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { UmamiClient } from "../client.js"; const umami = UmamiClient.fromEnv(); runProvisioner("analytics", { - async create(grant: Grant): Promise { - const domain = grant.values.domain ?? grant.consumer; - const name = grant.values.name ?? domain; + async create(p: Provision): Promise { + const domain = String(p.values.domain ?? p.as); + const name = String(p.values.name ?? domain); const token = await umami.getToken(); - const site = (await umami.findWebsite(token, domain)) ?? (await umami.createWebsite(token, domain, name)); - return { - fields: { - siteId: site.id, - snippet: umami.snippet(site.id), - dashboard: umami.dashboard(site.id), - }, - }; + // Idempotent: only create the site if it is not already there. + if (!(await umami.findWebsite(token, domain))) { + await umami.createWebsite(token, domain, name); + } }, - async remove(grant: Grant): Promise { - const domain = grant.values.domain ?? grant.consumer; + async remove(p: { as: string }): Promise { const token = await umami.getToken(); - const site = await umami.findWebsite(token, domain); + // Keyed on the mesh-derived login, the one identity the harness carries into removal. + const site = await umami.findWebsite(token, p.as); if (site) await umami.deleteWebsite(token, site.id); }, }); From 08bdd0e456fa3c4f27b7dcc3c5a4e7246b0a6125 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 00:52:09 +0200 Subject: [PATCH 28/28] Providers: broker-bound runtime container replaces the old provisioner (ADR 0052/0053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each provider's separate provisioner container becomes a runtime container that serves the module's tools and runs its provisioner under the module's scoped broker account: mesh-runtime-, on the backend's own network (reaching the backend by name and the broker by NAT), with MESH_BROKER_FILE + MESH_RECEIVES replacing GRANTS. umami gains the broker own-secret it lacked. cloudflare-dns's adapter is re-pointed at the ADR 0053 contract (a data provision, like umami — its record return is the scoped-out concern). Proven: provider-on-backend-network green — redis's runtime, on the private redis network, binds the broker and provisions a consumer with the mesh's credential. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/cloudflare-dns/module.json | 23 ++++++++------- modules/cloudflare-dns/provisioner/index.ts | 29 +++++++++---------- modules/minio/module.json | 22 ++++++++------- modules/postgres/module.json | 20 +++++++------ modules/redis/module.json | 20 +++++++------ modules/umami/module.json | 31 +++++++++++++-------- 6 files changed, 81 insertions(+), 64 deletions(-) diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json index 49292e9..294ddd1 100644 --- a/modules/cloudflare-dns/module.json +++ b/modules/cloudflare-dns/module.json @@ -52,23 +52,26 @@ "mode": "0600" }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-cloudflare-dns", - "image": "mesh-provision-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-cloudflare-dns", + "image": "mesh-runtime-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "host", - "env": { - "GRANTS": "/grants", - "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json" - }, "volumes": [ "/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro", "/var/lib/cloudflare-dns/grants:/grants", "/var/lib/cloudflare-dns/token:/run/secrets/token:ro", "/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro" - ] + ], + "env": { + "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json", + "MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json" + } } + ], + "capabilities": [ + "container-runtime" ] } diff --git a/modules/cloudflare-dns/provisioner/index.ts b/modules/cloudflare-dns/provisioner/index.ts index c9cfac3..d214dab 100644 --- a/modules/cloudflare-dns/provisioner/index.ts +++ b/modules/cloudflare-dns/provisioner/index.ts @@ -1,35 +1,36 @@ // cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface -// (novox/hq ADR 0049). The reconcile loop, sealing and grant-file handling are the sdk harness's; -// this writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing -// it at the mesh's ingress, and remove it when the grant is withdrawn. +// (novox/hq ADR 0049). The reconcile loop and the contributions file are the sdk harness's; this +// writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing it +// at the mesh's ingress, and remove it when the consumer is withdrawn (ADR 0053). // // The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly -// and what it resolves to. It is not a secret (a DNS record is public), so nothing is sealed beyond -// what the harness seals; the only secret is this module's own Cloudflare token, which never leaves. +// and what it resolves to. Like umami's analytics it is a *data* provision, not a credential one: +// nothing the mesh mints is set here (a DNS record is public, and the only secret is this module's +// own Cloudflare token, which never leaves). So the password the harness carries is unused; the name +// is derived from the login the mesh gave the consumer, which the consumer can derive too. Delivering +// the record back to the consumer is the data-provision return path ADR 0053 leaves out of scope. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; import { CloudflareClient } from "../client.js"; const cloudflare = CloudflareClient.fromEnv(); runProvisioner("public-dns", { - async create(grant: Grant): Promise { - const fqdn = cloudflare.nameFor(grant.consumer); + async create(p: Provision): Promise { + const fqdn = cloudflare.nameFor(p.as); await cloudflare.upsert(fqdn); await announce("module.cloudflare-dns.record.created", { name: fqdn, target: cloudflare.ingress, - consumer: grant.consumer, - node: grant.node, + consumer: p.consumer ?? "", }); - return { fields: { fqdn, target: cloudflare.ingress, ttl: "300" } }; }, - async remove(grant: Grant): Promise { - const fqdn = cloudflare.nameFor(grant.consumer); + async remove(p: { as: string }): Promise { + const fqdn = cloudflare.nameFor(p.as); await cloudflare.remove(fqdn); - await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: grant.consumer, node: grant.node }); + await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as }); }, }); diff --git a/modules/minio/module.json b/modules/minio/module.json index 7794739..2b11d43 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -98,21 +98,23 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-objectstore", - "image": "mesh-provision-objectstore@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-minio", + "image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "minio", - "env": { - "GRANTS": "/var/lib/minio/grants", - "MESH_MINIO_ENDPOINT": "http://minio:9000", - "MESH_MINIO_ROOT_USER": "meshroot", - "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" - }, "volumes": [ + "/var/lib/mesh/minio/broker:/run/secrets/broker:ro", "/var/lib/minio/grants:/var/lib/minio/grants:ro", "/var/lib/minio/root.secret:/run/secrets/root:ro" - ] + ], + "env": { + "MESH_MINIO_ENDPOINT": "http://minio:9000", + "MESH_MINIO_ROOT_USER": "meshroot", + "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" + } } ] } diff --git a/modules/postgres/module.json b/modules/postgres/module.json index 5fe0d69..3818a2f 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -97,20 +97,22 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-postgres", - "image": "mesh-provision-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-postgres", + "image": "mesh-runtime-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "postgres", - "env": { - "GRANTS": "/var/lib/postgres/grants", - "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser" - }, "volumes": [ + "/var/lib/mesh/postgres/broker:/run/secrets/broker:ro", "/var/lib/postgres/grants:/var/lib/postgres/grants:ro", "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" - ] + ], + "env": { + "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable", + "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json" + } } ] } diff --git a/modules/redis/module.json b/modules/redis/module.json index 77b648d..5150f69 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -96,20 +96,22 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-redis", - "image": "mesh-provision-redis@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-redis", + "image": "mesh-runtime-redis@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "redis", - "env": { - "GRANTS": "/var/lib/redis-module/grants", - "MESH_PROVISION_REDIS": "redis:6379", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" - }, "volumes": [ + "/var/lib/mesh/redis/broker:/run/secrets/broker:ro", "/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro", "/var/lib/redis-module/default.secret:/run/secrets/default:ro" - ] + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/redis-module/grants/mesh.json", + "MESH_PROVISION_REDIS": "redis:6379", + "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" + } } ] } diff --git a/modules/umami/module.json b/modules/umami/module.json index b4b5d19..022da13 100644 --- a/modules/umami/module.json +++ b/modules/umami/module.json @@ -4,7 +4,6 @@ "capabilities": [ "container-runtime" ], - "requires": [ "postgres-database" ], @@ -19,7 +18,6 @@ "secrets": { "postgres-database": "/var/lib/umami/database.secret" }, - "provides": [ { "name": "analytics", @@ -35,12 +33,11 @@ "grants": { "analytics": "/var/lib/umami/grants" }, - "own-secrets": { "app-secret": "/var/lib/umami/app.secret", - "admin": "/var/lib/umami/admin.secret" + "admin": "/var/lib/umami/admin.secret", + "broker": "/var/lib/mesh/umami/broker" }, - "listens": [ { "port": 3000, @@ -49,8 +46,13 @@ "why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to — so the port itself must be reachable from anywhere" } ], - "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/umami", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -96,17 +98,22 @@ ] }, { - "id": "provisioner", + "id": "runtime", "type": "container", - "name": "mesh-provision-umami-analytics", - "image": "mesh-provision-umami-analytics@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-umami", + "image": "mesh-runtime-umami@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "umami", - "env-file": [ - "/var/lib/umami/provisioner.env" - ], "volumes": [ + "/var/lib/mesh/umami/broker:/run/secrets/broker:ro", "/var/lib/umami/grants:/var/lib/umami/grants", "/var/lib/umami/admin.secret:/run/secrets/admin:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/umami/grants/mesh.json" + }, + "env-file": [ + "/var/lib/umami/provisioner.env" ] } ]