audit-logger: the assigned-module manifest (ADR 0048) #2

Merged
jschoubben merged 28 commits from events/audit-logger-assigned into main 2026-09-05 01:06:59 +00:00
174 changed files with 9465 additions and 78 deletions
+5 -2
View File
@@ -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<void> {
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(() => {});
+26 -4
View File
@@ -1,15 +1,37 @@
{
"module": "audit-logger",
"version": "1",
"consumes": [
"#"
],
"consumes": ["#"],
"own-secrets": {
"broker": "/var/lib/audit-logger/broker"
},
"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",
"image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"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"
}
}
]
}
+165
View File
@@ -0,0 +1,165 @@
// 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.
import { readFileSync } from "node:fs";
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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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);
}
private async request(method: string, path: string, params: Record<string, string> = {}): Promise<any> {
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<string, string>): Promise<any> {
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<WantedSubtitle[]> {
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<ProviderSubtitle[]> {
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<ProviderSubtitle[]> {
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<HistoryEntry[]> {
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];
}
}
+47
View File
@@ -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<string>();
let primed = false;
async function pollHistory(): Promise<void> {
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<void>, 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");
+41
View File
@@ -4,6 +4,12 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.bazarr.subtitle.downloaded"
],
"own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker"
},
"listens": [
{
"port": 6767,
@@ -13,6 +19,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/bazarr",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
@@ -68,6 +80,35 @@
"/services/media/anime:/anime",
"/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",
"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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+57
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+127
View File
@@ -0,0 +1,127 @@
// 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 {
// 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 = 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 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);
}
/**
* 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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<PublicRecord | undefined> {
const records = await this.api<PublicRecord[]>(
"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<PublicRecord> {
const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false };
const existing = await this.findRecord(name);
if (existing) {
return this.api<PublicRecord>("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body);
}
return this.api<PublicRecord>("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<void> {
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<PublicRecord[]> {
return this.api<PublicRecord[]>("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;
}
}
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 {};
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"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/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",
"path": "/var/lib/cloudflare-dns",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"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": "runtime",
"type": "container",
"name": "mesh-cloudflare-dns",
"image": "mesh-runtime-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"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"
]
}
+14
View File
@@ -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"
}
}
@@ -0,0 +1,44 @@
// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface
// (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. 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 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(p: Provision): Promise<void> {
const fqdn = cloudflare.nameFor(p.as);
await cloudflare.upsert(fqdn);
await announce("module.cloudflare-dns.record.created", {
name: fqdn,
target: cloudflare.ingress,
consumer: p.consumer ?? "",
});
},
async remove(p: { as: string }): Promise<void> {
const fqdn = cloudflare.nameFor(p.as);
await cloudflare.remove(fqdn);
await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as });
},
});
/** 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<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[cloudflare-dns] could not emit ${type}: ${err}`);
}
}
+23
View File
@@ -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 [];
}
});
+16
View File
@@ -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"
]
}
+59
View File
@@ -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 (<service>.<node>.<suffix>) 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<AnsweredName[]> {
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=/<node>.<suffix>/<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<string[]> {
const resolver = new Resolver();
resolver.setServers([this.address]);
try {
return await resolver.resolve4(name);
} catch {
return await resolver.resolve6(name);
}
}
}
+38
View File
@@ -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<string, string>();
let primed = false;
async function poll(): Promise<void> {
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");
+13
View File
@@ -7,6 +7,13 @@
"provides": [
"wildcard-resolution"
],
"emits": [
"module.dnsmasq.name.added",
"module.dnsmasq.name.removed"
],
"own-secrets": {
"broker": "/var/lib/mesh/dnsmasq/broker"
},
"claims": [
{
"name": "the-dns-port",
@@ -23,6 +30,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/dnsmasq",
"mode": "0700"
},
{
"id": "package",
"type": "package",
+9
View File
@@ -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" }
}
+31
View File
@@ -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)));
+12
View File
@@ -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"]
}
+21
View File
@@ -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<string> {
const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]);
return stdout;
}
}
+33
View File
@@ -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"
]
}
]
}
+14
View File
@@ -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"
}
}
+19
View File
@@ -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()));
+15
View File
@@ -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"
]
}
+251
View File
@@ -0,0 +1,251 @@
// 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.
import { readFileSync } from "node:fs";
/** 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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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);
}
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${this.token}`,
...(options.headers as Record<string, string> | 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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
return this.request<T>(path, options);
}
// ---- Repositories ----
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
return (repos ?? []).map(GiteaClient.mapRepo);
}
async createRepo(data: {
name: string;
description?: string;
private?: boolean;
auto_init?: boolean;
}): Promise<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>("/user/repos", { method: "POST", body: JSON.stringify(data) }));
}
async deleteRepo(owner: string, repo: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" });
}
// ---- Issues ----
async listIssues(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaIssue[]> {
const qs = new URLSearchParams({ type: "issues", ...params }).toString();
const issues = await this.request<any[]>(`/repos/${owner}/${repo}/issues?${qs}`);
return (issues ?? []).map(GiteaClient.mapIssue);
}
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
return GiteaClient.mapIssue(await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`));
}
async createIssue(
owner: string,
repo: string,
data: { title: string; body?: string; labels?: number[] },
): Promise<GiteaIssue> {
return GiteaClient.mapIssue(
await this.request<any>(`/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<GiteaIssue> {
return GiteaClient.mapIssue(
await this.request<any>(`/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<any>(`/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<GiteaLabel[]> {
const labels = await this.request<any[]>(`/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<GiteaLabel> {
const l = await this.request<any>(`/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<number> {
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<string, string> = {}): Promise<GiteaPull[]> {
const qs = new URLSearchParams(params).toString();
const prs = await this.request<any[]>(`/repos/${owner}/${repo}/pulls?${qs}`);
return (prs ?? []).map(GiteaClient.mapPull);
}
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
}
async createPullRequest(
owner: string,
repo: string,
data: { title: string; body?: string; head: string; base: string },
): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }),
);
}
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
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,
};
}
}
+59
View File
@@ -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<string>();
let primed = false;
async function pollRepos(client: GiteaClient): Promise<void> {
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<void>, 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");
}
+40 -1
View File
@@ -18,6 +18,11 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.gitea.repo.created",
"module.gitea.issue.opened",
"module.gitea.pull.merged"
],
"listens": [
{
"port": 3000,
@@ -33,9 +38,16 @@
}
],
"own-secrets": {
"internal-token": "/var/lib/gitea/internal-token.secret"
"internal-token": "/var/lib/gitea/internal-token.secret",
"broker": "/var/lib/mesh/gitea/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/gitea",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -76,6 +88,33 @@
"volumes": [
"/services/gitea/gitea:/data"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+306
View File
@@ -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<string, string> = {
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 [];
}
});
+12
View File
@@ -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"]
}
+120
View File
@@ -0,0 +1,120 @@
// 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.
import { readFileSync } from "node:fs";
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<string, string>;
activeAt?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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 = cfg.password ?? env.MESH_GRAFANA_PASSWORD;
if (password) {
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");
}
private async get(path: string): Promise<any> {
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<GrafanaHealth> {
const h = await this.get("/api/health");
return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" };
}
async listDatasources(): Promise<GrafanaDatasource[]> {
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<GrafanaDashboard[]> {
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<GrafanaAlert[]> {
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,
}));
}
}
+58
View File
@@ -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<string>();
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<void> {
const now = new Set<string>();
const byKey = new Map<string, GrafanaAlert>();
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");
}
+38 -1
View File
@@ -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/mesh/grafana/broker"
},
"capabilities": [
"container-runtime"
@@ -16,6 +20,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/grafana",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -50,6 +60,33 @@
"volumes": [
"/services/grafana/data:/var/lib/grafana"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+55
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+88
View File
@@ -0,0 +1,88 @@
// 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.
import { readFileSync } from "node:fs";
export interface HAEntityState {
entity_id: string;
state: string;
attributes: Record<string, unknown>;
last_changed?: string;
last_updated?: string;
}
export interface HAConfig {
location_name?: string;
version?: string;
components?: string[];
time_zone?: string;
state?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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);
}
private async request(path: string, init?: RequestInit): Promise<unknown> {
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<HAConfig> {
return (await this.request("/api/config")) as HAConfig;
}
/** All entity states, or one entity when an id is given. */
async getStates(): Promise<HAEntityState[]> {
return (await this.request("/api/states")) as HAEntityState[];
}
async getState(entityId: string): Promise<HAEntityState> {
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<string, unknown> = {}): Promise<HAEntityState[]> {
return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
method: "POST",
body: JSON.stringify(data),
})) as HAEntityState[];
}
}
+66
View File
@@ -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<string, string>();
let primed = false;
async function pollStates(): Promise<void> {
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<void>, 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");
+41
View File
@@ -4,6 +4,12 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.home-assistant.state.changed"
],
"own-secrets": {
"broker": "/var/lib/mesh/home-assistant/broker"
},
"listens": [
{
"port": 8123,
@@ -13,6 +19,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/home-assistant",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
@@ -32,6 +44,35 @@
"volumes": [
"/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",
"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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+76
View File
@@ -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<string, unknown> = { ...(args.data as Record<string, unknown> | 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 [];
}
});
+12
View File
@@ -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"]
}
+97
View File
@@ -0,0 +1,97 @@
// 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.
import { readFileSync } from "node:fs";
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 <source> 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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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<IcecastStatus> {
const headers: Record<string, string> = { 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;
}
}
}
+49
View File
@@ -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<string, IcecastMount>();
let primed = false;
async function pollMounts(): Promise<void> {
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");
+39 -1
View File
@@ -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/mesh/icecast/broker"
},
"listens": [
{
@@ -18,6 +23,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/icecast",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -42,6 +53,33 @@
"ports": [
"8000"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+26
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+116
View File
@@ -0,0 +1,116 @@
// 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;
message?: string;
version?: string;
}
export interface InfluxBucket {
id: string;
name: string;
orgID?: string;
retentionSeconds?: number;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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 = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org);
}
private async request(path: string, init?: RequestInit): Promise<Response> {
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<InfluxHealth> {
return (await (await this.request("/health")).json()) as InfluxHealth;
}
async listBuckets(): Promise<InfluxBucket[]> {
const body = (await (await this.request("/api/v2/buckets")).json()) as { buckets?: unknown[] };
return (body.buckets ?? []).map((b) => {
const bucket = b as Record<string, unknown>;
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<string, string>[] }> {
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<string, string>[] {
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<string, string> = {};
header.forEach((h, i) => {
if (h) row[h] = cells[i] ?? "";
});
return row;
});
}
+37 -1
View File
@@ -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,35 @@
"/services/influxdb/data:/var/lib/influxdb2",
"/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",
"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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+46
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+99
View File
@@ -0,0 +1,99 @@
// 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.
import { readFileSync } from "node:fs";
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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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);
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
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<JackettIndexer[]> {
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<JackettResult[]> {
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,
}));
}
}
+39 -1
View File
@@ -13,6 +13,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
@@ -36,6 +42,38 @@
"volumes": [
"/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",
"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"
},
"restart-on": [
"runtime-config"
]
}
]
],
"own-secrets": {
"broker": "/var/lib/mesh/jackett/broker"
}
}
+14
View File
@@ -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"
}
}
+48
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+229
View File
@@ -0,0 +1,229 @@
// 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.
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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
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 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 = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
return new KeycloakClient(url, adminUser, adminPass, realm);
}
private async getToken(): Promise<string> {
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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const doRequest = async (token: string): Promise<Response> =>
fetch(`${this.baseUrl}/admin/realms${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(options.headers as Record<string, string>),
},
});
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<T>;
}
// Realms
async listRealms(): Promise<Array<{ id: string; realm: string; displayName?: string; enabled: boolean }>> {
return this.request("/");
}
// Users
async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise<unknown[]> {
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<void> {
await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) });
}
async updateUser(realm: string, userId: string, data: Record<string, unknown>): Promise<void> {
await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) });
}
async deleteUser(realm: string, userId: string): Promise<void> {
await this.request(`/${realm}/users/${userId}`, { method: "DELETE" });
}
async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise<void> {
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<unknown[]> {
return this.request(`/${realm}/users/${userId}/sessions`);
}
// Clients
async listClients(realm: string): Promise<unknown[]> {
return this.request(`/${realm}/clients`);
}
async createClient(realm: string, data: {
clientId: string;
name?: string;
rootUrl?: string;
redirectUris?: string[];
publicClient?: boolean;
protocol?: string;
}): Promise<void> {
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<string> {
const clients = (await this.listClients(realm)) as Array<Record<string, unknown>>;
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<void> {
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
}
async getClientSecret(realm: string, clientId: string): Promise<string> {
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<string, string>;
}): Promise<void> {
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<Array<{ id: string; name: string; description?: string; composite: boolean }>> {
return this.request(`/${realm}/roles`);
}
async createRealmRole(realm: string, data: { name: string; description?: string }): Promise<void> {
await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) });
}
async getUserRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
return this.request(`/${realm}/users/${userId}/role-mappings/realm`);
}
async getAvailableRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`);
}
async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
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<void> {
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) });
}
// Groups
async listGroups(realm: string): Promise<Array<{ id: string; name: string; path: string; subGroupCount?: number }>> {
return this.request(`/${realm}/groups`);
}
async createGroup(realm: string, name: string): Promise<void> {
await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) });
}
async getUserGroups(realm: string, userId: string): Promise<Array<{ id: string; name: string; path: string }>> {
return this.request(`/${realm}/users/${userId}/groups`);
}
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" });
}
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" });
}
}
+44
View File
@@ -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<string, unknown>): Promise<void> {
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");
+43 -1
View File
@@ -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,9 +35,16 @@
}
],
"own-secrets": {
"admin": "/var/lib/keycloak/admin.secret"
"admin": "/var/lib/keycloak/admin.secret",
"broker": "/var/lib/mesh/keycloak/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/keycloak",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -76,6 +91,33 @@
"ports": [
"8080"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+378
View File
@@ -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<Record<string, unknown>>): 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<string, unknown> = {};
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<Record<string, unknown>>;
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 [];
}
});
+12
View File
@@ -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"]
}
+229
View File
@@ -0,0 +1,229 @@
// 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 { readFileSync } from "node:fs";
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";
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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 = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap";
return new MailuClient(url, apiKey, imapContainer);
}
// --- The admin REST API: users, aliases, domains. ---------------------------------------------
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
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<MailuUser[]> {
const users = await this.api<any[]>("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<void> {
await this.api("POST", "/user", { email, raw_password: password });
}
async changePassword(email: string, password: string): Promise<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
}
async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
}
async listAliases(): Promise<MailuAlias[]> {
const aliases = await this.api<any[]>("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<void> {
await this.api("POST", "/alias", { email, destination, wildcard });
}
async deleteAlias(email: string): Promise<void> {
await this.api("DELETE", `/alias/${encodeURIComponent(email)}`);
}
async listDomains(): Promise<MailuDomain[]> {
const domains = await this.api<any[]>("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<MailMessage[]> {
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<MailMessage[]> {
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<MailMessage[]> {
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<Record<string, string>> {
const messages: Array<Record<string, string>> = [];
let current: Record<string, string> = {};
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<string, string>): 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,
};
}
+61
View File
@@ -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<void> {
const known = new Set<string>();
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<void> {
await watchUsers((await mailu.listUsers()).map((u) => u.email));
}
async function pollAliases(): Promise<void> {
await watchAliases((await mailu.listAliases()).map((a) => a.email));
}
const tick = (fn: () => Promise<void>, 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");
+42 -1
View File
@@ -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,9 +49,16 @@
"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/mesh/mailu/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/mailu",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -269,6 +282,34 @@
"/services/mailu/data/certs:/certs",
"/services/mailu/data/overrides/nginx:/overrides:ro"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+154
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+352
View File
@@ -0,0 +1,352 @@
// 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 } 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<MinioBucket[]> {
const { status, text } = await this.request("GET", "/");
if (status !== 200) throw new Error(`minio listBuckets: ${status} ${text}`);
const out: MinioBucket[] = [];
const re = /<Bucket>\s*<Name>([^<]+)<\/Name>\s*<CreationDate>([^<]*)<\/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<boolean> {
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<void> {
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<void> {
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<MinioObject[]> {
const query: Record<string, string> = { "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("<Contents>").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(/&quot;|"/g, ""),
});
}
return out;
}
async statObject(bucket: string, object: string): Promise<MinioObjectStat> {
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<MinioBucketInfo> {
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<string, string> = {
"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, 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, secretKey: string): Promise<AccessKey> {
await this.ensureAlias();
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<void> {
await this.ensureAlias();
await this.mc("admin", "user", "svcacct", "rm", "mesh", accessKey);
}
private async ensureAlias(): Promise<void> {
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<string> {
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<string, string> = {},
): 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}>([^<]*)</${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, string>): 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;
}
+24 -11
View File
@@ -10,6 +10,10 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.minio.bucket.created",
"module.minio.bucket.removed"
],
"listens": [
{
"port": 9000,
@@ -31,9 +35,16 @@
"s3-bucket": "/var/lib/minio/grants"
},
"own-secrets": {
"root": "/var/lib/minio/root.secret"
"root": "/var/lib/minio/root.secret",
"broker": "/var/lib/mesh/minio/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/minio",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -87,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_OBJECTSTORE_URL": "http://minio:9000",
"MESH_OBJECTSTORE_ROOT_USER": "meshroot",
"MESH_OBJECTSTORE_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"
}
}
]
}
+14
View File
@@ -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"
}
}
+66
View File
@@ -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, 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 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 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 Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { MinioClient, bucketFor } from "../client.js";
const minio = MinioClient.fromEnv();
runProvisioner("s3-bucket", {
async create(p: Provision): Promise<void> {
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 it back with
// the mesh's secret.
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
await minio.createAccessKey(bucket, accessKeyId, p.password);
await announce("module.minio.bucket.created", {
bucket,
consumer: p.consumer ?? "",
accessKey: accessKeyId,
endpoint: minio.baseUrl,
});
},
async remove(p: { as: string }): Promise<void> {
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(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, accessKey: p.as });
},
});
/** 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<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[minio] could not emit ${type}: ${err}`);
}
}
+80
View File
@@ -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 [];
}
});
+16
View File
@@ -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"
]
}
+102
View File
@@ -0,0 +1,102 @@
// 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";
import { readFileSync } from "node:fs";
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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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);
}
/** 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<string, string>;
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<string, unknown>; disabled?: Record<string, unknown> };
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<NextcloudShare[]> {
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",
}));
}
}
+57
View File
@@ -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<string>();
let usersPrimed = false;
async function pollUsers(client: NextcloudClient): Promise<void> {
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<string>();
let sharesPrimed = false;
async function pollShares(client: NextcloudClient): Promise<void> {
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>): 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");
}
+40 -1
View File
@@ -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/mesh/nextcloud/broker"
},
"capabilities": [
"container-runtime"
@@ -36,6 +41,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/nextcloud",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
@@ -70,6 +81,34 @@
"volumes": [
"/services/nextcloud/html:/var/www/html"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+57
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+96
View File
@@ -0,0 +1,96 @@
// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0044). Only
// this module's tools import it; nodered has nothing to poll, so there is no events entrypoint.
//
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
// configuration, GET /nodes for installed node modules. A default install has no auth; when
// adminAuth is on, a bearer token (minted at /auth/token) is required.
import { readFileSync } from "node:fs";
export interface NodeRedFlow {
/** The tab (flow) node id. */
id: string;
label: string;
disabled: boolean;
}
export interface NodeRedNodeModule {
name: string;
version: string;
types: string[];
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class NodeRedClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token?: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the
* "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing
* rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on;
* a default install needs none.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient {
const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE);
const url = cfg.url ?? env.MESH_NODERED_URL;
if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL");
return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN);
}
private headers(extra: Record<string, string> = {}): Record<string, string> {
return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };
}
private async req(path: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(`${this.baseUrl}${path}`, init);
if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`);
return res.json();
}
/** The full flow configuration — the flat array of every node across every tab. */
async getConfig(): Promise<any[]> {
const body = await this.req("/flows", { headers: this.headers() });
// /flows answers a bare array by default, or { rev, flows } to a v2-aware client.
return Array.isArray(body) ? body : (body.flows ?? []);
}
/** The tabs (flows), each a node of type "tab" in the configuration. */
async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> {
const config = await this.getConfig();
const flows = config
.filter((n) => n.type === "tab")
.map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled }));
return { flows, nodeCount: config.length };
}
async listNodes(): Promise<NodeRedNodeModule[]> {
const modules = (await this.req("/nodes", { headers: this.headers() })) as any[];
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
}
/**
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
*/
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
const body = await this.req("/flows", {
method: "POST",
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
body: JSON.stringify(config),
});
return { rev: body?.rev, nodeCount: config.length };
}
}
+39
View File
@@ -1,6 +1,12 @@
{
"module": "nodered",
"version": "1",
"emits": [
"module.nodered.flows.deployed"
],
"own-secrets": {
"broker": "/var/lib/mesh/nodered/broker"
},
"capabilities": [
"container-runtime"
],
@@ -13,6 +19,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/nodered",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
@@ -34,6 +46,33 @@
"volumes": [
"/services/nodered/data:/data"
]
},
{
"id": "runtime-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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+64
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+170
View File
@@ -0,0 +1,170 @@
// 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.
import { readFileSync } from "node:fs";
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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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 = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget";
return new NzbgetClient(url, user, password);
}
private async rpc<T>(method: string, params: unknown[] = []): Promise<T> {
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<string> {
return this.rpc<string>("version");
}
async getStatus(): Promise<NzbgetStatus> {
const s = await this.rpc<Record<string, number | boolean>>("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<NzbgetQueueItem[]> {
const groups = await this.rpc<Record<string, any>[]>("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<NzbgetHistoryItem[]> {
const history = await this.rpc<Record<string, any>[]>("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<number> {
const id = await this.rpc<number>("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<void> {
await this.rpc("pausedownload");
}
async resumeAll(): Promise<void> {
await this.rpc("resumedownload");
}
async pauseItem(id: number): Promise<void> {
await this.rpc("editqueue", ["GroupPause", "", [id]]);
}
async resumeItem(id: number): Promise<void> {
await this.rpc("editqueue", ["GroupResume", "", [id]]);
}
/** Delete an item from the queue or from history. */
async delete(id: number, from: "queue" | "history" = "queue"): Promise<void> {
await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]);
}
}
+64
View File
@@ -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<number>();
let queuePrimed = false;
async function pollQueue(): Promise<void> {
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<number>();
let historyPrimed = false;
async function pollHistory(): Promise<void> {
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<void>, 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");
+43
View File
@@ -4,6 +4,14 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.nzbget.download.added",
"module.nzbget.download.completed"
],
"consumes": [],
"own-secrets": {
"broker": "/var/lib/mesh/nzbget/broker"
},
"listens": [
{
"port": 6789,
@@ -13,6 +21,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/nzbget",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
@@ -44,6 +58,35 @@
"/services/nzbget/config:/config",
"/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",
"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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+88
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}
+114
View File
@@ -0,0 +1,114 @@
// 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.
import { readFileSync } from "node:fs";
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;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class 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 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);
}
private async request(method: string, path: string, body?: unknown): Promise<any> {
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<OmbiRequest[]> {
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<RequestCounts> {
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<void> {
await this.request("POST", `/Request/${kind}/approve`, { id });
}
}
+58
View File
@@ -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<string, boolean>();
let primed = false;
const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`;
async function pollRequests(): Promise<void> {
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<void>, 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");
+42
View File
@@ -4,6 +4,13 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.ombi.request.created",
"module.ombi.request.approved"
],
"own-secrets": {
"broker": "/var/lib/mesh/ombi/broker"
},
"listens": [
{
"port": 3579,
@@ -13,6 +20,12 @@
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/ombi",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
@@ -36,6 +49,35 @@
"volumes": [
"/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",
"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"
},
"restart-on": [
"runtime-config"
]
}
]
}
+14
View File
@@ -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"
}
}
+46
View File
@@ -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 [];
}
});
+12
View File
@@ -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"]
}

Some files were not shown because too many files have changed in this diff Show More