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
+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;
}