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
118 lines
4.9 KiB
TypeScript
118 lines
4.9 KiB
TypeScript
// 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 {};
|
|
}
|
|
}
|