Convert confluence and jira into nox catalog modules

Port the HAL confluence and jira integrations to the tools-only,
outbound-only external-SaaS pattern proven by the merged gitlab module:
runtime-only containers (container-runtime capability), own-secret token +
broker, a settings-managed config.json for public config, and a
per-module runtime image.

Each module carries its own Atlassian API client and tools (ADR 0039),
translated from HAL's @hal/sdk zod-schema/MCP-content shape into
mesh-sdk's input/run-returns-data shape. Public config (ATLASSIAN_URL,
ATLASSIAN_EMAIL) lives in config.json; the API token is the one
own-secret. Clients are built lazily and never throw at registration, so
each runtime serves its full tool surface with no credentials (the
Servarr lesson) — confluence serves 3 tools, jira serves 8.

jira's periodic ticket-poller (update-tickets.service/.timer) is NOT
ported: the mesh has no scheduled-task primitive yet (a pending
decision). Only jira's tools are ported; a top-of-file note records the
deferral.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-06 01:49:22 +02:00
parent 2c3f241010
commit db8b60f4e4
10 changed files with 679 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
// confluence's own Confluence API client — its own code, living in the module (novox/hq ADR 0039).
// Ported from the hal sdk's shared ConfluenceClient, where a change to the Atlassian API rebuilt
// everything; here it rebuilds only confluence. This module's tools import it, and nothing outside
// confluence does.
//
// confluence is a tools-only, outbound-only integration with an external SaaS (Atlassian Cloud): it
// holds no service of its own, listens for nothing, and only ever calls out to a Confluence instance
// over its REST API, authenticated with HTTP Basic (email + API token) against the Atlassian site.
//
// The client is built lazily and NEVER throws at construction (the Servarr lesson): the runtime must
// come up and register every tool even with no valid credentials — the lab has no real Atlassian. A
// missing URL, email or token surfaces only when a tool is actually invoked, as a clear error from
// that one call, not as a runtime that refuses to serve.
import { readFileSync } from "node:fs";
export class ConfluenceClient {
constructor(
/** The Atlassian site URL, e.g. "https://acme.atlassian.net". A public setting (config file). */
private readonly url: string | undefined,
/** The Atlassian account email — a public setting (config file). */
private readonly email: string | undefined,
/** The Atlassian API token — confluence's one secret (own-secret). */
private readonly token: string | undefined,
) {}
static fromEnv(env: NodeJS.ProcessEnv = process.env): ConfluenceClient {
// The site URL and account email are a mesh's own facts, not this module's — settings merged into
// a config file the mesh manages (novox/hq ADR 0046) under ATLASSIAN_URL / ATLASSIAN_EMAIL, read
// here. The API token is the one secret and stays an own-secret, read from its file. Env is
// honoured as a fallback for a hand-run instance. No absence throws: the client still constructs,
// so every tool still registers and serves.
const config = readConfig(env.MESH_CONFLUENCE_CONFIG_FILE);
const url = config.ATLASSIAN_URL ?? env.MESH_CONFLUENCE_URL ?? env.ATLASSIAN_URL;
const email = config.ATLASSIAN_EMAIL ?? env.MESH_CONFLUENCE_EMAIL ?? env.ATLASSIAN_EMAIL;
const token = env.MESH_CONFLUENCE_TOKEN ?? readSecret(env.MESH_CONFLUENCE_TOKEN_FILE) ?? env.ATLASSIAN_TOKEN;
return new ConfluenceClient(url, email, token);
}
/** Whether the module is configured enough to make a call. */
configured(): boolean {
return Boolean(this.url && this.email && this.token);
}
private baseUrl(): string {
if (!this.url || !this.email || !this.token) {
throw new Error(
"confluence is not configured — set its site URL and account email in settings " +
"(ATLASSIAN_URL, ATLASSIAN_EMAIL) and its API token as its own-secret; until then it " +
"answers no calls",
);
}
return this.url.replace(/\/+$/, "");
}
private authHeader(): string {
return "Basic " + Buffer.from(`${this.email}:${this.token}`).toString("base64");
}
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl()}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authHeader(),
...(options.headers as Record<string, string>),
},
});
if (!res.ok) {
throw new Error(`Confluence API error ${res.status}: ${await res.text()}`);
}
if (res.status === 204) return null as T;
return res.json() as Promise<T>;
}
async search(cql: string, limit = 25): Promise<unknown> {
const params = new URLSearchParams({ cql, limit: String(limit) });
return this.request(`/wiki/rest/api/content/search?${params}`);
}
async getPage(pageId: string, expand?: string): Promise<unknown> {
const params = expand ? `?body-format=${encodeURIComponent(expand)}` : "";
return this.request(`/wiki/api/v2/pages/${pageId}${params}`);
}
async listSpaces(params: Record<string, string> = {}): Promise<unknown> {
const qs = new URLSearchParams(params).toString();
return this.request(`/wiki/api/v2/spaces?${qs}`);
}
}
function readSecret(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
interface Config {
ATLASSIAN_URL?: string;
ATLASSIAN_EMAIL?: string;
}
/** The settings-managed config file (a JSON document the mesh merges settings into), holding the
* public ATLASSIAN_URL and ATLASSIAN_EMAIL settings. Absent or unparseable yields an empty config —
* the module then answers no calls until its URL, email and token are set, but still registers and
* serves every tool. */
function readConfig(path: string | undefined): Config {
if (!path) return {};
try {
return JSON.parse(readFileSync(path, "utf8")) as Config;
} catch {
return {};
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"module": "confluence",
"version": "1",
"own-secrets": {
"token": "/var/lib/confluence/token",
"broker": "/var/lib/mesh/confluence/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/confluence",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/confluence",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "/var/lib/confluence/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"image": "mesh-runtime-confluence@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/confluence/config.json:/run/config/config.json:ro",
"/var/lib/confluence/token:/run/secrets/token:ro",
"/var/lib/mesh/confluence/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
}
}
],
"capabilities": [
"container-runtime"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-confluence",
"version": "0.1.0",
"description": "confluence — a tools-only, outbound-only Atlassian Confluence SaaS integration (novox/hq ADR 0039): its API client and tools live here.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
// confluence's tools — confluence's own code (novox/hq ADR 0039), importing confluence's own
// Confluence API client. They return structured data; the mesh serves them through the sdk's tool
// harness. confluence is tools-only and outbound-only: no service, no events, no listener — it
// reaches out to an Atlassian Confluence instance and exposes its content search, pages and spaces.
//
// Every tool is registered unconditionally, even with no credentials configured (the Servarr
// lesson): the client is built lazily and never throws, so the runtime always comes up and serves
// the full tool surface — a call made before the URL/email/token are set fails with a clear error,
// but the runtime does not refuse to serve. The install proof is the runtime logging
// `[mesh-tools] serving N tool(s)`.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { ConfluenceClient } from "../client.js";
function str(value: unknown): string {
return String(value ?? "");
}
function num(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function getConfluenceTools(confluence: ConfluenceClient): ToolDefinition[] {
return [
{
name: "confluence_search",
description: "Search Confluence content using CQL.",
input: {
cql: { type: "string", description: "CQL query string" },
limit: { type: "number", description: "max results (default 25)" },
},
run: async (args) => ({ results: await confluence.search(str(args.cql), num(args.limit, 25)) }),
},
{
name: "confluence_get_page",
description: "Get a Confluence page by ID.",
input: {
page_id: { type: "string", description: "Page ID" },
},
run: async (args) => ({ page: await confluence.getPage(str(args.page_id)) }),
},
{
name: "confluence_list_spaces",
description: "List Confluence spaces.",
input: {
page: { type: "number", description: "page number, 1-based (default 1)" },
limit: { type: "number", description: "results per page (default 25)" },
},
run: async (args) => {
const page = num(args.page, 1);
const limit = num(args.limit, 25);
const cursor = String((page - 1) * limit);
return { spaces: await confluence.listSpaces({ cursor, limit: String(limit) }) };
},
},
];
}
// confluence always registers its full tool surface: the client is built lazily and never throws, so
// the runtime comes up and serves every tool even before the URL/email/token are configured (the lab
// has no real Atlassian). A tool called before the module is configured fails with a clear error from
// that call.
registerModuleTools("confluence", (env) => {
try {
return getConfluenceTools(ConfluenceClient.fromEnv(env));
} catch {
return [];
}
});
+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"
]
}