The lighter sibling of provisioning — 1:many and broadcast, no credential,
just the broker's topic routing. A thin, audit-ready surface over the
broker's publish/subscribe:
- emit(type, body): publishes an Event carrying who emitted it (MESH_MODULE),
on which node (MESH_NODE) and when (ISO timestamp) — so a listener can
build a real audit trail.
- on(pattern, handler): react to events by topic pattern. The audit logger
is just on("#", ...).
Tested: a module emits; a targeted listener (module.umami.#) hears only its
events, the audit sink (#) hears every module's, and the metadata audit
needs is present.
The declared side — a manifest's emits/consumes, so the mesh knows the
event graph — and the audit-logger module are the next pieces.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
216 lines
8.6 KiB
TypeScript
216 lines
8.6 KiB
TypeScript
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, serveTools, listTools } from "../dist/tools/index.js";
|
|
import { runProvisioner, type Grant } from "../dist/provisioner/index.js";
|
|
import { emit, on, type Event } from "../dist/events/index.js";
|
|
import { useBroker } from "../dist/messaging/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();
|
|
});
|
|
|
|
test("modules can SERVE: a real async tool, loaded and invoked over the broker", async () => {
|
|
resetTools();
|
|
|
|
// Stand up a fake upstream the tool actually calls over the network — proving a tool that does
|
|
// real work (not a pure function) serves end to end.
|
|
const { createServer } = await import("node:http");
|
|
const server = createServer((_req, res) => {
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ id: "site-123" }));
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, r));
|
|
const addr = server.address() as { port: number };
|
|
const base = `http://127.0.0.1:${addr.port}`;
|
|
|
|
// A module registers its tool exactly as umami does — the tool calls the upstream and returns a
|
|
// result computed from it.
|
|
registerModuleTools("demo", () => [
|
|
{
|
|
name: "create_site",
|
|
description: "register a site and return its snippet",
|
|
input: { domain: { type: "string" } },
|
|
run: async (args) => {
|
|
const res = await fetch(`${base}/api/websites`, { method: "POST" });
|
|
const { id } = (await res.json()) as { id: string };
|
|
return { domain: String(args.domain), snippet: `<script data-website-id="${id}"></script>` };
|
|
},
|
|
},
|
|
]);
|
|
|
|
const broker = memBroker();
|
|
const stop = await serveTools(broker, {});
|
|
|
|
// Invoke it the way a caller (mesh-control's command API) would — over the broker, by name.
|
|
const result = await broker.request<{ tool: string; args: Record<string, unknown> }, { snippet: string }>(
|
|
"tools.invoke",
|
|
{ tool: "create_site", args: { domain: "my-app" } },
|
|
);
|
|
assert.match(result.snippet, /data-website-id="site-123"/);
|
|
|
|
// Discovery works, and an unknown tool is refused rather than silently dropped.
|
|
assert.deepEqual(listTools({}).map((t) => t.name), ["create_site"]);
|
|
await assert.rejects(broker.request("tools.invoke", { tool: "nope", args: {} }));
|
|
|
|
stop();
|
|
server.close();
|
|
});
|
|
|
|
test("serving refuses two modules exposing one tool name", async () => {
|
|
resetTools();
|
|
registerModuleTools("a", () => [{ name: "dup", description: "", input: {}, run: async () => 1 }]);
|
|
registerModuleTools("b", () => [{ name: "dup", description: "", input: {}, run: async () => 2 }]);
|
|
await assert.rejects(serveTools(memBroker(), {}), /exposed by two modules/);
|
|
});
|
|
|
|
// A minimal in-memory broker: request routes to a registered handle, and publish routes to every
|
|
// subscriber whose topic pattern matches. Enough to exercise tools and events; the real binding is
|
|
// the mesh's AMQP one, provided by the hosting runtime.
|
|
function memBroker() {
|
|
const handlers = new Map<string, (b: unknown) => Promise<unknown>>();
|
|
const subs: { pattern: string; handler: (env: { key: string; node: string; body: unknown }) => Promise<void> }[] = [];
|
|
return {
|
|
async request<Req, Res>(key: string, body: Req): Promise<Res> {
|
|
const h = handlers.get(key);
|
|
if (!h) throw new Error(`no handler for ${key}`);
|
|
return (await h(body)) as Res;
|
|
},
|
|
async handle<Req, Res>(key: string, handler: (b: Req) => Promise<Res>): Promise<() => void> {
|
|
handlers.set(key, handler as (b: unknown) => Promise<unknown>);
|
|
return () => handlers.delete(key);
|
|
},
|
|
async publish<T>(env: { key: string; node: string; body: T }): Promise<void> {
|
|
for (const s of subs) if (topicMatch(s.pattern, env.key)) await s.handler(env);
|
|
},
|
|
async subscribe<T>(pattern: string, handler: (env: { key: string; node: string; body: T }) => Promise<void>): Promise<() => void> {
|
|
const entry = { pattern, handler: handler as (env: { key: string; node: string; body: unknown }) => Promise<void> };
|
|
subs.push(entry);
|
|
return () => {
|
|
const i = subs.indexOf(entry);
|
|
if (i >= 0) subs.splice(i, 1);
|
|
};
|
|
},
|
|
async close(): Promise<void> {},
|
|
};
|
|
}
|
|
|
|
// AMQP topic matching: `*` one segment, `#` any run of segments.
|
|
function topicMatch(pattern: string, key: string): boolean {
|
|
if (pattern === "#") return true;
|
|
const p = pattern.split(".");
|
|
const k = key.split(".");
|
|
let pi = 0;
|
|
let ki = 0;
|
|
while (pi < p.length) {
|
|
if (p[pi] === "#") return true; // simplification: # only as a trailing wildcard
|
|
if (ki >= k.length) return false;
|
|
if (p[pi] !== "*" && p[pi] !== k[ki]) return false;
|
|
pi++;
|
|
ki++;
|
|
}
|
|
return ki === k.length;
|
|
}
|
|
|
|
test("events: a module emits, a listener and the audit sink (#) both receive it, with metadata", async () => {
|
|
const broker = memBroker();
|
|
useBroker(() => broker);
|
|
|
|
process.env.MESH_MODULE = "umami";
|
|
process.env.MESH_NODE = "anchor";
|
|
|
|
const heard: Event[] = [];
|
|
const audited: Event[] = [];
|
|
await on("module.umami.#", async (e) => void heard.push(e));
|
|
await on("#", async (e) => void audited.push(e)); // the audit logger is just this
|
|
|
|
await emit("module.umami.site.created", { domain: "my-app" });
|
|
await emit("module.plex.play.started", { title: "x" }); // a different module's event
|
|
|
|
// The umami listener heard only umami's event; the audit sink heard both.
|
|
assert.deepEqual(heard.map((e) => e.type), ["module.umami.site.created"]);
|
|
assert.deepEqual(audited.map((e) => e.type), ["module.umami.site.created", "module.plex.play.started"]);
|
|
|
|
// The metadata an audit trail needs is present.
|
|
const e = heard[0];
|
|
assert.equal(e.source, "umami");
|
|
assert.equal(e.node, "anchor");
|
|
assert.equal((e.body as { domain: string }).domain, "my-app");
|
|
assert.match(e.at, /^\d{4}-\d{2}-\d{2}T/);
|
|
|
|
delete process.env.MESH_MODULE;
|
|
delete process.env.MESH_NODE;
|
|
});
|
|
|
|
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));
|
|
}
|
|
}
|