From a19a2f5cf06a3d6338a1f8c813711043cb0f2e08 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 23:01:59 +0200 Subject: [PATCH] =?UTF-8?q?Stand=20up=20mesh-sdk=20=E2=80=94=20the=20stabl?= =?UTF-8?q?e=20spine=20a=20module=20builds=20against?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 2 + README.md | 87 ++++++++++++++++++++++++ package-lock.json | 47 +++++++++++++ package.json | 22 +++++++ src/contracts/index.ts | 52 +++++++++++++++ src/index.ts | 11 ++++ src/messaging/index.ts | 48 ++++++++++++++ src/primitives/index.ts | 87 ++++++++++++++++++++++++ src/provisioner/index.ts | 139 +++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 48 ++++++++++++++ test/sdk.test.ts | 81 +++++++++++++++++++++++ tsconfig.json | 15 +++++ 12 files changed, 639 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/contracts/index.ts create mode 100644 src/index.ts create mode 100644 src/messaging/index.ts create mode 100644 src/primitives/index.ts create mode 100644 src/provisioner/index.ts create mode 100644 src/tools/index.ts create mode 100644 test/sdk.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..47e5079 --- /dev/null +++ b/README.md @@ -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 { /* create a umami site, return the grant */ }, + async remove(grant: Grant): Promise { /* 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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5d26500 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..44e0de8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/contracts/index.ts b/src/contracts/index.ts new file mode 100644 index 0000000..cee58d7 --- /dev/null +++ b/src/contracts/index.ts @@ -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>; +} + +/** 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>; +} + +/** + * 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>; + readonly run: (args: Readonly>) => Promise; +} + +/** A message crossing the broker: a routing key and a JSON body, per-node addressed. */ +export interface Envelope { + readonly key: string; + readonly node: string; + readonly body: T; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3303688 --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/src/messaging/index.ts b/src/messaging/index.ts new file mode 100644 index 0000000..ac0871c --- /dev/null +++ b/src/messaging/index.ts @@ -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(key: string, body: Req): Promise; + /** Emit an event onto the mesh. */ + publish(env: Envelope): Promise; + /** React to events matching a routing-key pattern. Returns an unsubscribe. */ + subscribe(pattern: string, handler: (env: Envelope) => Promise): Promise<() => void>; + close(): Promise; +} + +/** + * 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( + pattern: string, + handler: (env: Envelope) => Promise, +): Promise<() => void> { + return broker().subscribe(pattern, handler); +} diff --git a/src/primitives/index.ts b/src/primitives/index.ts new file mode 100644 index 0000000..5d6edd2 --- /dev/null +++ b/src/primitives/index.ts @@ -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::::`, 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; +} diff --git a/src/provisioner/index.ts b/src/provisioner/index.ts new file mode 100644 index 0000000..2606bf4 --- /dev/null +++ b/src/provisioner/index.ts @@ -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; + remove(grant: Grant): Promise; +} + +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(); // consumer -> hash of the grant last applied + let stopped = false; + + async function reconcile(): Promise { + 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 => { + 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 { + 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 { + 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; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..75a38ab --- /dev/null +++ b/src/tools/index.ts @@ -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; +} diff --git a/test/sdk.test.ts b/test/sdk.test.ts new file mode 100644 index 0000000..1ec0cef --- /dev/null +++ b/test/sdk.test.ts @@ -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: "