One bus: the runtime pins the certificate after the server speaks, and the old transport goes

Every module that dialled the new bus failed its handshake with "wrong version
number": the runtime pinned the server's certificate by a raw TLS connection to a
port on which the server speaks first, in the clear. The pin is taken after the
INFO line now, on the same socket, and then the real connection verifies against
exactly that certificate.

And the old transport is deleted — its client, its tests, its dependency — with
the wire-compatibility pins that only existed for the move (novox/hq ADR 0131,
design 28 task 5.5). A credential names the bus, and there is one.
This commit is contained in:
2026-09-28 03:08:59 +02:00
parent cd26131c61
commit f0104b7846
9 changed files with 110 additions and 697 deletions
-39
View File
@@ -1,39 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { registerModuleTools, resetTools, invokeTool } from "@novox/mesh-sdk/tools";
import { connectAmqp } from "../dist/broker-amqp.js";
import { runTools } from "../dist/runtime.js";
// Requires a real broker at $MESH_BROKER_URL. The test harness spins LavinMQ (the mesh's broker)
// and points this at it; skipped if it is not set, never failed for the environment.
const url = process.env.MESH_BROKER_URL;
test("a module's tool serves and is invoked over a real AMQP broker", { skip: !url }, async () => {
resetTools();
// A module registers a real tool, exactly as umami does.
registerModuleTools("demo", () => [
{
name: "greet",
description: "return a greeting",
input: { who: { type: "string" } },
run: async (args) => ({ hello: String(args.who), from: "the tool runtime" }),
},
]);
// The runtime binds the broker and serves the module (no module entrypoints to import here — the
// tool is registered in-process — but this is the exact runtime that serves on a node).
const serverBroker = await connectAmqp(url!);
const stop = await runTools({ broker: serverBroker, moduleEntrypoints: [] });
// A separate connection — a caller, like mesh-controller's command API — invokes over the broker,
// by module and tool (novox/hq ADR 0047: served on serve.demo.greet, invoked as demo.greet).
const caller = await connectAmqp(url!);
const result = (await invokeTool(caller, "demo", "greet", { who: "mesh" })) as { hello: string };
assert.equal(result.hello, "mesh");
stop();
await caller.close();
await serverBroker.close();
});
-142
View File
@@ -1,142 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import amqp from "amqplib";
import { connectAmqp } from "../dist/broker-amqp.js";
import { useBroker } from "@novox/mesh-sdk/messaging";
import { emit, on, type Event } from "@novox/mesh-sdk/events";
// Binding conformance: does the mesh-tools AMQP *adapter* honour the ADR 0042 wire contract —
// headers on the wire, a body that is only the payload, persistent messages, a durable per-consumer
// queue, dead-letter, poison handling? This tests the adapter in isolation, against a disposable
// broker that stands in for the one the mesh hosts (ADR 0001). It is NOT an event test of the mesh:
// that lives in a full lab scenario where the mesh raises the broker as foundation. Requires
// $MESH_BROKER_URL (a throwaway broker); skipped, never failed, when it is not set.
const url = process.env.MESH_BROKER_URL;
test("an event rides the wire with ADR 0042 headers and a body that is only the payload", { skip: !url }, async () => {
const sub = await connectAmqp(url!);
const pub = await connectAmqp(url!);
// The consumer's identity names its durable queue (<node>.<module>.events).
process.env.MESH_NODE = "lab";
process.env.MESH_MODULE = "audit-logger";
useBroker(() => sub);
const got: Event[] = [];
await on("#", async (e) => void got.push(e));
await delay(200); // let the binding settle before publishing
// The emitter is a different module on the same node.
process.env.MESH_MODULE = "umami";
useBroker(() => pub);
await emit("module.umami.site.created", { domain: "my-app" }, { causationId: "cmd-1" });
await waitFor(() => got.length > 0, 4000);
const e = got[0];
assert.equal(e.type, "module.umami.site.created");
assert.equal(e.source, "umami"); // x-source — read from a header, not the body
assert.equal(e.node, "lab"); // x-node
assert.ok(e.id, "x-event-id present"); // the handle a consumer dedups on
assert.equal(e.causationId, "cmd-1"); // x-causation-id round-trips
assert.match(e.at, /^\d{4}-\d{2}-\d{2}T/); // x-time, RFC-3339
assert.deepEqual(e.body, { domain: "my-app" }); // provenance never leaked into the body
// The durable per-consumer queue exists and is bound — a passive assert throws if it does not.
const probe = await amqp.connect(url!);
const pch = await probe.createChannel();
await pch.checkQueue("lab.audit-logger.events");
await probe.close();
await sub.close();
await pub.close();
delete process.env.MESH_NODE;
delete process.env.MESH_MODULE;
});
test("a handler that keeps failing dead-letters the event past the redelivery limit", { skip: !url }, async () => {
const module = `flaky-${Date.now()}`;
const c = await connectAmqp(url!);
process.env.MESH_NODE = "lab";
process.env.MESH_MODULE = module;
useBroker(() => c);
let attempts = 0;
await on("module.test.boom", async () => {
attempts++;
throw new Error("boom");
});
await delay(200);
await emit("module.test.boom", { n: 1 });
// First delivery requeues once; the redelivered copy is dead-lettered — two attempts, then it
// leaves the consumer queue for good.
await waitFor(() => attempts >= 2, 5000);
await delay(300);
assert.equal(attempts, 2, "attempted twice, not looping forever");
// The poison event is retained on the dead-letter queue for inspection, not vanished.
const probe = await amqp.connect(url!);
const pch = await probe.createChannel();
const dead = await pch.get("mesh.events.dead", { noAck: true });
assert.ok(dead, "the rejected event is on mesh.events.dead");
assert.equal((dead as amqp.GetMessage).fields.routingKey, "module.test.boom");
await probe.close();
await c.close();
delete process.env.MESH_NODE;
delete process.env.MESH_MODULE;
});
test("an undecodable event body is dead-lettered, not looped and not silently swallowed", { skip: !url }, async () => {
const module = `poison-${Date.now()}`;
const c = await connectAmqp(url!);
process.env.MESH_NODE = "lab";
process.env.MESH_MODULE = module;
useBroker(() => c);
// Drain any earlier dead events so the assert below sees only this test's.
const drain = await amqp.connect(url!);
const dch = await drain.createChannel();
await dch.purgeQueue("mesh.events.dead");
let handlerRuns = 0;
await on("module.poison.raw", async () => void handlerRuns++);
await delay(200);
// Publish a body that is not JSON straight onto the events exchange — a malformed emitter. A
// confirm channel, waited on, so the broker has the message before the connection closes.
const raw = await amqp.connect(url!);
const rch = await raw.createConfirmChannel();
rch.publish("mesh.events", "module.poison.raw", Buffer.from("this is not json{"), {
persistent: true,
headers: { "x-event-id": "poison-1", "x-source": "bad", "x-node": "lab" },
});
await rch.waitForConfirms();
await raw.close();
// The handler never ran (the body never decoded), and the message is on the dead queue — set
// aside for inspection, not stuck redelivering forever.
await delay(600);
assert.equal(handlerRuns, 0, "a body that never decodes never reaches the handler");
const dead = await dch.get("mesh.events.dead", { noAck: true });
assert.ok(dead, "the poison event is retained on mesh.events.dead");
assert.equal((dead as amqp.GetMessage).fields.routingKey, "module.poison.raw");
await drain.close();
await c.close();
delete process.env.MESH_NODE;
delete process.env.MESH_MODULE;
});
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function waitFor(cond: () => boolean, ms: number): Promise<void> {
const start = Date.now();
while (!cond()) {
if (Date.now() - start > ms) throw new Error("condition not met in time");
await delay(25);
}
}
+3 -6
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fatalBrokerReason, PinMismatchError } from "../src/broker-amqp.ts";
import { fatalBrokerReason, PinMismatchError } from "../src/broker-nats.ts";
// novox/hq issue 058 (and its review): serve mode retries a broker that is not up yet, but must
// give up at once on a failure waiting cannot fix — otherwise a permanent fault loops for ever
@@ -31,11 +31,8 @@ test("a malformed broker URL is fatal — it never parses on the next try", () =
});
test("a refused login is fatal — a wrong or revoked credential, not an absent broker", () => {
for (const msg of [
"Handshake terminated by server: 403 (ACCESS-REFUSED) with message \"ACCESS_REFUSED - Login was refused\"",
"Login was refused using authentication mechanism PLAIN",
"ACCESS_REFUSED",
]) {
// The bus refuses a login in its own words; each is final, because the next try says the same.
for (const msg of ["Authorization Violation", "nats: user authentication expired", "Permissions Violation for Subscription to \"x\""]) {
assert.notEqual(fatalBrokerReason(new Error(msg)), null, `should be fatal: ${msg}`);
}
});
-68
View File
@@ -1,68 +0,0 @@
/**
* **The old bus's wire is byte-identical after the rename, and this is the test that lets the change
* be merged to a running mesh.**
*
* Every module's event names were converted from the old bus's routing keys to local names
* (novox/hq 04-ISSUES/127), and the old bus's client maps them back. If that mapping is wrong
* anywhere, a live mesh's events stop being delivered — silently, because a binding that matches
* nothing is not an error.
*
* So this pins the mapping against the literal routing keys the mesh used before, taken from the
* manifests as they were. It needs no bus: it is about a string.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { routingKeyFor, bindingFor, localKeyFor, topicMatches } from "../dist/broker-amqp.js";
test("a converted emit produces the routing key the mesh published before", () => {
// left: what the module's code says now. right: what went on the wire before, unchanged.
const same: [string, string, string][] = [
["plex", "playback.started", "module.plex.playback.started"],
["sonarr", "download.completed", "module.sonarr.download.completed"],
["builder", "built", "module.builder.built"],
["mesh-catalog", "upgraded", "module.mesh-catalog.upgraded"],
["keycloak", "user.created", "module.keycloak.user.created"],
["mesh-vault", "secret.rotated", "module.mesh-vault.secret.rotated"],
];
for (const [self, local, before] of same) {
assert.equal(routingKeyFor(local, self), before, `${self} emitting ${local}`);
}
});
test("a converted subscription binds what it bound before", () => {
const same: [string, string][] = [
["builder.built", "module.builder.built"],
["*.download.completed", "module.*.download.completed"],
["*.usage.*", "module.*.usage.*"],
// The audit logger's "everything": `#` on this bus, and it must stay `#`.
["**", "#"],
];
for (const [declared, before] of same) {
assert.equal(bindingFor(declared), before, `consuming ${declared}`);
}
});
test("a handler still matches what the bus delivers", () => {
// The key a handler is given is the local one now, and the pattern it compares against is local
// too — so the pair must still meet for every case the mesh actually has.
const pairs: [string, string][] = [
["builder.built", "module.builder.built"],
["*.download.completed", "module.sonarr.download.completed"],
["*.usage.*", "module.anthropic-consumer.usage.session"],
["**", "module.anything.at.all"],
];
for (const [pattern, delivered] of pairs) {
assert.ok(
topicMatches(pattern, localKeyFor(delivered)),
`${pattern} no longer matches ${delivered}, so a running module would stop reacting`,
);
}
});
test("a routing key already in the old form is left alone", () => {
// Belt for the transition: anything not yet converted still goes out as it did, so a module built
// from an older manifest keeps working beside one built from a current manifest.
assert.equal(routingKeyFor("module.plex.playback.started", "plex"), "module.plex.playback.started");
assert.equal(bindingFor("module.builder.built"), "module.builder.built");
});