Stand up mesh-sdk — the stable spine a module builds against

Per novox/hq ADR 0044/0045: the sdk holds only what rarely changes and
is shared across modules; per-module code (a client, tool impls, a
create-a-resource adapter) lives in the module.

Five areas, real and tested:
- contracts: the runtime shapes module code touches (grant, credential,
  a mesh Interface, tool + envelope types) — not the manifest schema,
  which the control plane owns.
- provisioner: the reconcile harness every provider shares (watch grants,
  create via the module's adapter, seal + write the credential, remove on
  withdrawal). A module writes only the adapter.
- tools: registerModuleTools + collectTools — the serving harness; tools
  and their client live in the module.
- messaging: the Broker/Envelope/event contract over the mesh broker; the
  concrete binding is provided by the hosting runtime.
- primitives: AES-256-GCM seal/unseal, semver, resolved-env access.

Compiles (tsc, NodeNext) and passes tests: sealing round-trip + wrong-key
rejection, semver, tool registration (a thrower is skipped not fatal), and
the provisioner creating then removing a sealed grant.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-03 23:01:59 +02:00
commit a19a2f5cf0
12 changed files with 639 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
// The runtime shapes a module's own code touches — NOT the manifest schema, which the control
// plane owns and parses (in Go). These are what a running module receives and returns: a grant
// and its credentials, the mesh interface a provider and consumer both conform to, and the tool
// and event types. They change rarely and deliberately (novox/hq ADR 0044).
/** A request for one instance of a provided resource, addressed to a provider. */
export interface Grant {
/** The mesh interface being provisioned, e.g. "analytics", "postgres-database". */
readonly resource: string;
/** Who asked — the consumer module, on which node. */
readonly consumer: string;
readonly node: string;
/** What the consumer contributed (per the interface's spec keys), e.g. `{ name: "umami" }`. */
readonly values: Readonly<Record<string, string>>;
}
/** What a provider hands back for a grant. Sealed by the harness before it leaves the machine. */
export interface Credential {
/** The fields the interface promises a consumer, e.g. `{ host, port, as, password }`. */
readonly fields: Readonly<Record<string, string>>;
}
/**
* A mesh interface: the provider-neutral contract for a capability (novox/hq ADR 0045). Both a
* provider (which adapts its software to it) and a consumer (which depends on it, never on a
* provider) conform. `spec` names what a consumer may contribute; `credential` names what it
* receives. The interface is drawn at the consumer's real coupling: neutral where thin
* (`analytics`), the protocol where the consumer speaks one (`postgres-database`).
*/
export interface Interface {
readonly name: string;
/** Keys a consumer may contribute when requesting it. */
readonly spec: readonly string[];
/** Fields a consumer receives in its credential. */
readonly credential: readonly string[];
}
/** A tool a module exposes through the mesh's command surface. */
export interface ToolDefinition {
readonly name: string;
readonly description: string;
/** JSON-schema-shaped input contract; kept opaque here so tools own their own shapes. */
readonly input: Readonly<Record<string, unknown>>;
readonly run: (args: Readonly<Record<string, unknown>>) => Promise<unknown>;
}
/** A message crossing the broker: a routing key and a JSON body, per-node addressed. */
export interface Envelope<T = unknown> {
readonly key: string;
readonly node: string;
readonly body: T;
}
+11
View File
@@ -0,0 +1,11 @@
// @novox/mesh-sdk — the stable spine a module's own code builds against (novox/hq ADR 0044).
//
// Import the area you need directly (`@novox/mesh-sdk/tools`, `/provisioner`, …) or the whole
// surface from here. Everything specific to one module — its API client, its tool implementations,
// its create-a-resource adapter — lives in the module, never here.
export * from "./contracts/index.js";
export * as tools from "./tools/index.js";
export * as provisioner from "./provisioner/index.js";
export * as messaging from "./messaging/index.js";
export * as primitives from "./primitives/index.js";
+48
View File
@@ -0,0 +1,48 @@
// The broker and event framework a module's code uses to talk to the mesh. The transport is the
// mesh's one broker (novox/hq ADR 0001); this is the typed surface over it. The concrete broker
// binding is provided by the runtime that hosts a module's code — the sdk defines the contract so
// module code, the tool runtime and provisioners all speak it the same way.
import type { Envelope } from "../contracts/index.js";
export type { Envelope };
/** A request/reply call and a publish/subscribe surface over the mesh broker. */
export interface Broker {
/** Ask one question and await one answer — the shape mesh-control's command API is reached by. */
request<Req, Res>(key: string, body: Req): Promise<Res>;
/** Emit an event onto the mesh. */
publish<T>(env: Envelope<T>): Promise<void>;
/** React to events matching a routing-key pattern. Returns an unsubscribe. */
subscribe<T>(pattern: string, handler: (env: Envelope<T>) => Promise<void>): Promise<() => void>;
close(): Promise<void>;
}
/**
* How a module obtains its broker. The hosting runtime sets this once; module code calls broker()
* without knowing the concrete binding. Keeping the binding out of the sdk is deliberate — the sdk
* carries the contract, not a specific AMQP client build.
*/
let binding: (() => Broker) | undefined;
export function useBroker(factory: () => Broker): void {
binding = factory;
}
export function broker(): Broker {
if (!binding) {
throw new Error(
"no broker is bound — the tool runtime or provisioner host must call useBroker() before " +
"module code reaches the mesh",
);
}
return binding();
}
/** A convenience for the common case: consume one event stream until stopped. */
export async function consume<T>(
pattern: string,
handler: (env: Envelope<T>) => Promise<void>,
): Promise<() => void> {
return broker().subscribe<T>(pattern, handler);
}
+87
View File
@@ -0,0 +1,87 @@
// Small, stable primitives every module's code may need. No behaviour here changes when a module
// changes; that is the whole point of it living in the sdk.
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
// --- sealing ---
//
// A secret is sealed to a key so a copy of it at rest is not a working credential. AES-256-GCM;
// the key is derived from a per-node passphrase the host holds. The host unseals on the machine;
// nothing else does (novox/hq ADR 0043's link is the boundary — the sdk only carries the mechanism).
const MAGIC = "msk1"; // versions the sealed format, so it can change without silent misreads
/** Seal plaintext to a passphrase. Returns `msk1:<salt>:<iv>:<tag>:<ciphertext>`, base64 parts. */
export function seal(plaintext: string, passphrase: string): string {
const salt = randomBytes(16);
const iv = randomBytes(12);
const key = scryptSync(passphrase, salt, 32);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [MAGIC, b64(salt), b64(iv), b64(tag), b64(enc)].join(":");
}
/** Unseal what seal produced. Throws — loudly — on any tamper or wrong key. */
export function unseal(sealed: string, passphrase: string): string {
const parts = sealed.split(":");
if (parts.length !== 5 || parts[0] !== MAGIC) {
throw new Error("not a sealed value this version understands");
}
const [, salt, iv, tag, enc] = parts.map((p, i) => (i === 0 ? Buffer.alloc(0) : ub64(p)));
const key = scryptSync(passphrase, salt, 32);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}
const b64 = (b: Buffer): string => b.toString("base64url");
const ub64 = (s: string): Buffer => Buffer.from(s, "base64url");
// --- semver ---
//
// Enough to compare and satisfy, no dependency spent on it. Pre-release and build metadata are
// ignored by design — the mesh pins by digest, and versions here order releases, nothing subtler.
export interface Version {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
export function parseVersion(v: string): Version {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
if (!m) throw new Error(`not a version: ${v}`);
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
}
/** -1, 0, 1 — a before b, equal, a after b. */
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const x = parseVersion(a);
const y = parseVersion(b);
for (const k of ["major", "minor", "patch"] as const) {
if (x[k] < y[k]) return -1;
if (x[k] > y[k]) return 1;
}
return 0;
}
// --- resolved env ---
//
// A module reads its own resolved environment through this, rather than reaching into process.env
// directly, so the one place that would change if resolution changes is here.
/** Read a resolved env var; throws if a required one is absent, which is the right failure. */
export function requireEnv(name: string, env: NodeJS.ProcessEnv = process.env): string {
const v = env[name];
if (v === undefined || v === "") {
throw new Error(`${name} is not set — the module was deployed without a value it requires`);
}
return v;
}
/** Read a resolved env var with a fallback. */
export function readEnv(name: string, fallback: string, env: NodeJS.ProcessEnv = process.env): string {
const v = env[name];
return v === undefined || v === "" ? fallback : v;
}
+139
View File
@@ -0,0 +1,139 @@
// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
// minio and umami: read the grants the control plane has written, bring each requested resource to
// existence through the provider's adapter, seal and hand back the credential, and remove what is
// no longer requested. A module writes ONLY the adapter — the per-service half — which is why this
// lives in the sdk and the adapter lives in the module (novox/hq ADR 0044).
import { readdir, readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import type { Grant, Credential } from "../contracts/index.js";
import { seal } from "../primitives/index.js";
/** What a provider implements — the only per-service code. It is handed a Grant and creates (or
* removes) the resource in its own software, returning the credential a consumer receives. */
export interface Adapter {
create(grant: Grant): Promise<Credential>;
remove(grant: Grant): Promise<void>;
}
export interface ProvisionerOptions {
/** Directory the control plane writes grant requests into and the harness writes credentials to.
* Defaults to $GRANTS. */
grants?: string;
/** Passphrase a credential is sealed to before it is written. Defaults to $MESH_SEAL_KEY. */
sealKey?: string;
/** Reconcile interval in ms. Defaults to 5000. */
everyMs?: number;
}
export type { Grant, Credential };
/**
* Run the reconcile loop for one provided resource. Returns a stop function. Never throws for a
* single bad grant — it logs and keeps converging, because one consumer's failure must not stop
* the others' provisioning.
*/
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): () => void {
const dir = opts.grants ?? envOrThrow("GRANTS");
const sealKey = opts.sealKey ?? envOrThrow("MESH_SEAL_KEY");
const everyMs = opts.everyMs ?? 5000;
const applied = new Map<string, string>(); // consumer -> hash of the grant last applied
let stopped = false;
async function reconcile(): Promise<void> {
const requested = await readGrants(dir, resource);
const wantById = new Map(requested.map((g) => [g.consumer, g]));
// Create or update anything requested whose grant has changed.
for (const grant of requested) {
const h = hash(grant);
if (applied.get(grant.consumer) === h) continue;
try {
const cred = await adapter.create(grant);
await writeSealedCredential(dir, grant, cred, sealKey);
applied.set(grant.consumer, h);
} catch (err) {
console.error(`[provisioner:${resource}] ${grant.consumer}: create failed, will retry: ${err}`);
}
}
// Remove anything applied that is no longer requested. Only the harness's own outputs are
// touched — the credential file it wrote — never anything it did not create.
for (const consumer of [...applied.keys()]) {
if (wantById.has(consumer)) continue;
const grant = lastGrant(dir, resource, consumer);
try {
await adapter.remove(grant);
await rm(credentialPath(dir, resource, consumer), { force: true });
applied.delete(consumer);
} catch (err) {
console.error(`[provisioner:${resource}] ${consumer}: remove failed, will retry: ${err}`);
}
}
}
const tick = async (): Promise<void> => {
if (stopped) return;
try {
await reconcile();
} catch (err) {
console.error(`[provisioner:${resource}] reconcile error: ${err}`);
}
if (!stopped) setTimeout(() => void tick(), everyMs);
};
void tick();
return () => {
stopped = true;
};
}
// --- grant/credential files ---
async function readGrants(dir: string, resource: string): Promise<Grant[]> {
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const out: Grant[] = [];
for (const name of names) {
if (!name.endsWith(".grant.json")) continue;
try {
const raw = await readFile(join(dir, name), "utf8");
const g = JSON.parse(raw) as Grant;
if (g.resource === resource) out.push(g);
} catch (err) {
console.error(`[provisioner:${resource}] unreadable grant ${name}: ${err}`);
}
}
return out;
}
function credentialPath(dir: string, resource: string, consumer: string): string {
return join(dir, `${consumer}.${resource}.credential`);
}
async function writeSealedCredential(dir: string, grant: Grant, cred: Credential, key: string): Promise<void> {
const body = JSON.stringify({ resource: grant.resource, consumer: grant.consumer, fields: cred.fields });
await writeFile(credentialPath(dir, grant.resource, grant.consumer), seal(body, key), { mode: 0o600 });
}
function lastGrant(dir: string, resource: string, consumer: string): Grant {
// For removal the harness needs a Grant to hand the adapter; the identity is enough to act on.
return { resource, consumer, node: "", values: {} };
}
// --- helpers ---
function hash(g: Grant): string {
return JSON.stringify([g.resource, g.consumer, g.node, g.values]);
}
function envOrThrow(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — the provisioner cannot run without it`);
return v;
}
+48
View File
@@ -0,0 +1,48 @@
// The tool-serving harness. A module declares its tools through registerModuleTools; a runtime
// (the mesh's per-node tool host) collects the registrations and serves them through the command
// surface. HOW a tool is declared and served is settled and lives here; the tools themselves, and
// the API client they call, live in the module (novox/hq ADR 0044).
import type { ToolDefinition } from "../contracts/index.js";
export type { ToolDefinition };
/** A module contributes its tools as a function of its resolved environment. Returning [] (e.g.
* when a token is absent) is normal — the module simply exposes nothing until it can. */
export type ToolContributor = (env: NodeJS.ProcessEnv) => ToolDefinition[];
interface Registration {
readonly module: string;
readonly contribute: ToolContributor;
}
const registrations: Registration[] = [];
/**
* Declare the tools a module exposes. Called once, at module-tool load time, from the module's
* tools/ entrypoint. The client and the tool implementations are imported from the module itself.
*/
export function registerModuleTools(module: string, contribute: ToolContributor): void {
registrations.push({ module, contribute });
}
/**
* Collect every registered module's tools against an environment. The tool runtime calls this
* after loading the assigned modules' tool entrypoints. A module whose contributor throws is
* skipped with its error surfaced, never taking the others down.
*/
export function collectTools(env: NodeJS.ProcessEnv = process.env): { module: string; tools: ToolDefinition[] }[] {
return registrations.map(({ module, contribute }) => {
try {
return { module, tools: contribute(env) };
} catch (err) {
console.error(`[tools] ${module}: contributor failed, exposing none: ${err}`);
return { module, tools: [] };
}
});
}
/** Testing/inspection: drop all registrations. */
export function resetTools(): void {
registrations.length = 0;
}