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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user