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,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# mesh-sdk
|
||||||
|
|
||||||
|
The stable spine a Novox Mesh **module's own code builds against** — the tool it uses to plug
|
||||||
|
into the mesh, and nothing that belongs to a specific module or to another tier.
|
||||||
|
|
||||||
|
It earns its place by **rarely changing** (novox/hq [ADR 0044](https://git.novox.be/novox/hq)).
|
||||||
|
The test for anything here: *if editing it recompiles unrelated modules and it changes often, it
|
||||||
|
does not belong.* Per-module code (a service's API client, its tool implementations, its
|
||||||
|
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
|
||||||
|
turned the old SDK into constant maintenance and made every edit rebuild every module.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
|
||||||
|
|
||||||
|
### `contracts` — the runtime shapes module code touches
|
||||||
|
Not the manifest schema (the control plane owns and parses that, in Go). The shapes a module's
|
||||||
|
*running code* receives and returns: a provisioning **grant** and its credentials, the
|
||||||
|
**mesh interface** definitions (what an `analytics` grant contains, what an `oidc-client` grant
|
||||||
|
contains — the contract both a provider and a consumer conform to), and the tool and event types.
|
||||||
|
These change rarely and deliberately; when one does, a rebuild of everything is *correct*.
|
||||||
|
|
||||||
|
### `provisioner` — the reconcile harness every provider shares
|
||||||
|
The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each
|
||||||
|
requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and
|
||||||
|
keep converging (`watch`). That loop lives here. A **module provides only the adapter** — "create
|
||||||
|
a database", "create a umami site" — which is the volatile, per-service half and belongs in the
|
||||||
|
module. This is the ADR 0044 line drawn through provisioning: stable loop here, per-service create
|
||||||
|
in the module.
|
||||||
|
|
||||||
|
### `tools` — the tool-serving harness
|
||||||
|
`registerModuleTools`, the `ToolDefinition` type, and the registry/worker that loads a module's
|
||||||
|
tools and exposes them through the mesh's command surface. *How* a tool is declared and served is
|
||||||
|
settled; it does not change when an individual tool does. The tools themselves, and the API client
|
||||||
|
they call, live in the module.
|
||||||
|
|
||||||
|
### `messaging` — the broker and event framework
|
||||||
|
The broker client (connect, request/reply, publish/subscribe), the event-consumer a module uses to
|
||||||
|
react to mesh events, and the envelope/routing-key conventions. Transport primitives.
|
||||||
|
|
||||||
|
### `primitives` — sealing, semver, resolved-env
|
||||||
|
Seal/unseal for secrets, semantic version comparison, and the accessor a module uses to read its
|
||||||
|
own resolved environment.
|
||||||
|
|
||||||
|
## What is NOT in it, and where it goes
|
||||||
|
|
||||||
|
- **A module's API client and its tool implementations** → in the module. (The Plex client, the
|
||||||
|
Umami client, `tools/plex.ts` — all module-local. ADR 0044.)
|
||||||
|
- **Delivery machinery** — build executor, bundler, dependency resolver, artifact manager, feature
|
||||||
|
handlers → **mesh-control**. It co-evolves with the pipeline.
|
||||||
|
- **Host synchronisers** — config-sync, ufw config, vhost generation, systemd, health checks →
|
||||||
|
**mesh-host**'s apply engine. The host applies these; they are not module code.
|
||||||
|
- **Domain logic** — tasks, workflows, agents, provider integrations → **tier-2 contexts**.
|
||||||
|
|
||||||
|
That is why this stays small: everything volatile has somewhere else to be.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
A module's tools:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { registerModuleTools, ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { UmamiClient } from "../client.js"; // the client lives in the module, not here
|
||||||
|
|
||||||
|
registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL)));
|
||||||
|
```
|
||||||
|
|
||||||
|
A provider's provisioner:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { runProvisioner, Grant, Credential } from "@novox/mesh-sdk/provisioner";
|
||||||
|
|
||||||
|
runProvisioner("analytics", {
|
||||||
|
async create(grant: Grant): Promise<Credential> { /* create a umami site, return the grant */ },
|
||||||
|
async remove(grant: Grant): Promise<void> { /* delete the site */ },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is the only thing the module writes; the watching, sealing and grant-file handling are
|
||||||
|
the harness's.
|
||||||
|
|
||||||
|
## Where the reasoning lives
|
||||||
|
|
||||||
|
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq):
|
||||||
|
|
||||||
|
- `02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md` — the stability rule this repository is
|
||||||
|
- `02-DECISIONS/0045-what-a-module-is.md` — a module, and why its per-module code lives in the module
|
||||||
Generated
+47
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/mesh-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@novox/mesh-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||||
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/mesh-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "The stable spine a Novox Mesh module's own code builds against.",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.js",
|
||||||
|
"./contracts": "./dist/contracts/index.js",
|
||||||
|
"./provisioner": "./dist/provisioner/index.js",
|
||||||
|
"./tools": "./dist/tools/index.js",
|
||||||
|
"./messaging": "./dist/messaging/index.js",
|
||||||
|
"./primitives": "./dist/primitives/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, writeFile, readFile, readdir } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { seal, unseal, compareVersions } from "../dist/primitives/index.js";
|
||||||
|
import { registerModuleTools, collectTools, resetTools } from "../dist/tools/index.js";
|
||||||
|
import { runProvisioner, type Grant } from "../dist/provisioner/index.js";
|
||||||
|
|
||||||
|
test("seal round-trips and rejects the wrong key", () => {
|
||||||
|
const sealed = seal("hunter2", "node-key");
|
||||||
|
assert.equal(unseal(sealed, "node-key"), "hunter2");
|
||||||
|
assert.notEqual(sealed, "hunter2");
|
||||||
|
assert.throws(() => unseal(sealed, "wrong-key"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("semver orders releases", () => {
|
||||||
|
assert.equal(compareVersions("1.2.3", "1.2.10"), -1);
|
||||||
|
assert.equal(compareVersions("2.0.0", "1.9.9"), 1);
|
||||||
|
assert.equal(compareVersions("v1.0.0", "1.0.0"), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("module tools register and collect, a thrower is skipped not fatal", () => {
|
||||||
|
resetTools();
|
||||||
|
registerModuleTools("umami", () => [
|
||||||
|
{ name: "umami_stats", description: "d", input: {}, run: async () => 1 },
|
||||||
|
]);
|
||||||
|
registerModuleTools("broken", () => {
|
||||||
|
throw new Error("no token");
|
||||||
|
});
|
||||||
|
const collected = collectTools({});
|
||||||
|
const umami = collected.find((c) => c.module === "umami");
|
||||||
|
const broken = collected.find((c) => c.module === "broken");
|
||||||
|
assert.equal(umami?.tools.length, 1);
|
||||||
|
assert.equal(broken?.tools.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provisioner creates a sealed credential for a grant, then removes on withdrawal", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "prov-"));
|
||||||
|
const created: string[] = [];
|
||||||
|
const removed: string[] = [];
|
||||||
|
|
||||||
|
const grant: Grant = { resource: "analytics", consumer: "webapp", node: "anchor", values: { name: "webapp" } };
|
||||||
|
await writeFile(join(dir, "webapp.grant.json"), JSON.stringify(grant));
|
||||||
|
|
||||||
|
const stop = runProvisioner(
|
||||||
|
"analytics",
|
||||||
|
{
|
||||||
|
async create(g) {
|
||||||
|
created.push(g.consumer);
|
||||||
|
return { fields: { siteId: "abc", snippet: "<script>", dashboard: "https://x/webapp" } };
|
||||||
|
},
|
||||||
|
async remove(g) {
|
||||||
|
removed.push(g.consumer);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ grants: dir, sealKey: "k", everyMs: 20 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => created.length === 1, 2000);
|
||||||
|
const files = await readdir(dir);
|
||||||
|
const credFile = files.find((f) => f.endsWith(".credential"));
|
||||||
|
assert.ok(credFile, "a credential file was written");
|
||||||
|
const sealed = await readFile(join(dir, credFile!), "utf8");
|
||||||
|
const body = JSON.parse(unseal(sealed, "k")) as { fields: Record<string, string> };
|
||||||
|
assert.equal(body.fields.siteId, "abc");
|
||||||
|
|
||||||
|
// Withdraw the grant → the harness removes via the adapter and deletes the credential.
|
||||||
|
await (await import("node:fs/promises")).rm(join(dir, "webapp.grant.json"));
|
||||||
|
await waitFor(() => removed.length === 1, 2000);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitFor(cond: () => boolean, ms: number): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!cond()) {
|
||||||
|
if (Date.now() - start > ms) throw new Error("timed out waiting");
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user