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:
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user