From b6d99a166c8a63b2271bf64a919fc32343c049a6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 17:19:48 +0100 Subject: [PATCH 1/5] feat(core): pin certificate fingerprints for federation links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FederationManager accepted any certificate on inbound or outbound TLS connections — rejectUnauthorized: false is required since these are self-signed with no CA, but nothing verified which self-signed cert was presented, so any certificate was treated as a valid federation peer. Add fingerprintDer(), which hashes a live tls.PeerCertificate's raw DER the same way getCertificateFingerprint() hashes a PEM certificate, so a fingerprint pinned from one form compares equal to the other presented live over a socket. --- src/core/identity.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/identity.ts b/src/core/identity.ts index 2b0cc30..8b2b27e 100644 --- a/src/core/identity.ts +++ b/src/core/identity.ts @@ -321,6 +321,13 @@ export function generateIdentity(): PeerIdentity { */ export function getCertificateFingerprint(certificate: string): string { const der = pemToDer(certificate); + return fingerprintDer(der); +} + +/** + * Compute the SHA-256 fingerprint of a certificate already presented as raw DER bytes — the shape `tls.TLSSocket.getPeerCertificate().raw` returns for a live connection. Same hashing and formatting as `getCertificateFingerprint`, so a value pinned from a PEM certificate compares equal to the fingerprint of that same certificate presented live over a socket. + */ +export function fingerprintDer(der: Buffer): string { const hex = createHash("sha256").update(der).digest("hex").toUpperCase(); const matched = hex.match(/.{2}/g); if (matched === null) return ""; From 21581f2ed8dd966efa9d9e738acd42f1de4ce62f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 17:19:57 +0100 Subject: [PATCH 2/5] feat(core): verify federation peer certificates against a trusted allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither direction of a federation link checked the peer's certificate fingerprint: connect() accepted whatever the remote server presented, and handleInbound() accepted whatever the remote client presented and immediately synced full mesh state (every visible agent, every federated room's membership) to it. Federation trusted nobody in particular and everybody at once. Add a per-instance trusted-fingerprint allowlist (empty by default — federate with nobody until an operator explicitly pins a remote mesh's fingerprint, the same no-CA pin-the-key model ordinary peer connections already use) and verify the presented certificate against it before a link is created in either direction. An unpinned certificate gets the socket destroyed before any handshake is processed. Also add listen()/stopListening(): a real production TLS server for inbound federation connections. Nothing previously stood one up — handleInbound() existed only as a function the integration test called against its own hand-rolled tls.createServer. --- src/core/federation.ts | 110 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/src/core/federation.ts b/src/core/federation.ts index db8284d..b0562a8 100644 --- a/src/core/federation.ts +++ b/src/core/federation.ts @@ -16,7 +16,7 @@ import { encode, isMeshMessage, MessageBuffer } from "./wire-protocol.js"; import type { MeshMessage } from "./wire-protocol.js"; import type { AgentIdentity, RoomMessage } from "./types.js"; import { nanoid } from "./nanoid.js"; -import { generateIdentity } from "./identity.js"; +import { fingerprintDer, generateIdentity } from "./identity.js"; import type { PeerIdentity } from "./identity.js"; // --------------------------------------------------------------------------- @@ -78,6 +78,11 @@ export class FederationManager { private pingTimers = new Map>(); private shutDown = false; private pendingPongs = new Map>(); + /** + * Certificate fingerprints this instance will federate with, inbound or outbound. Empty by default — federation trusts nobody until an operator explicitly pins a remote mesh's fingerprint, the same no-CA, pin-the-key trust model ordinary peer connections already use. + */ + private trustedFingerprints = new Set(); + private listener: tls.Server | undefined; constructor(meshId: string, meshName: string, callbacks: FedCallbacks) { this.meshId = meshId; @@ -91,6 +96,45 @@ export class FederationManager { return this.identity; } + // ----------------------------------------------------------------------- + // Trust — which remote mesh fingerprints this instance will federate with + // ----------------------------------------------------------------------- + + /** Pin a remote mesh's certificate fingerprint as trusted for federation. */ + addTrustedFingerprint(fingerprint: string): void { + this.trustedFingerprints.add(fingerprint); + } + + /** Remove a previously pinned fingerprint. Existing links using it are not torn down. */ + removeTrustedFingerprint(fingerprint: string): void { + this.trustedFingerprints.delete(fingerprint); + } + + /** List currently trusted fingerprints. */ + listTrustedFingerprints(): string[] { + return [...this.trustedFingerprints]; + } + + /** + * Verify the certificate a connected TLS socket presented against the trusted-fingerprint allowlist. Returns the presented fingerprint when trusted, `undefined` (and destroys the socket) otherwise. + * + * This is the check that was missing entirely before: the socket was accepted with `rejectUnauthorized: false` (required, since these are self-signed certs with no CA) but nothing then verified *which* self-signed cert was presented, so any certificate was accepted as a valid federation peer. + */ + private verifyPeerOrDestroy(socket: tls.TLSSocket): string | undefined { + const cert = socket.getPeerCertificate(); + // Node's types declare every PeerCertificate field non-optional, but the documented runtime behaviour when the peer presents no certificate at all is an empty object — not null/undefined, and not a Buffer-typed `raw`. Detect that real shape rather than trusting the declared type. + if (Object.keys(cert).length === 0) { + socket.destroy(); + return undefined; + } + const fingerprint = fingerprintDer(cert.raw); + if (!this.trustedFingerprints.has(fingerprint)) { + socket.destroy(); + return undefined; + } + return fingerprint; + } + // ----------------------------------------------------------------------- // Outbound links // ----------------------------------------------------------------------- @@ -105,6 +149,13 @@ export class FederationManager { const linkId = nanoid(8); const socket = await this.tlsConnect(host, port); + if (this.verifyPeerOrDestroy(socket) === undefined) { + throw new Error( + `Federation connection to ${host}:${String(port)} rejected: ` + + "the presented certificate is not in the trusted-fingerprint allowlist. " + + "Call addTrustedFingerprint() with the remote mesh's fingerprint first.", + ); + } const link: FedLink = { id: linkId, remoteMeshId: "", @@ -147,6 +198,13 @@ export class FederationManager { throw new Error("FederationManager is shut down"); } + if (this.verifyPeerOrDestroy(socket) === undefined) { + throw new Error( + "Inbound federation connection rejected: the presented certificate " + + "is not in the trusted-fingerprint allowlist.", + ); + } + const linkId = nanoid(8); const link: FedLink = { id: linkId, @@ -235,12 +293,62 @@ export class FederationManager { await this.broadcastToReady(msg); } + // ----------------------------------------------------------------------- + // Inbound listener — accepts federation links from remote coordinators + // ----------------------------------------------------------------------- + + /** + * Start listening for inbound federation connections. Every accepted connection is routed through `handleInbound()`, which enforces the trusted-fingerprint check before a link is ever created — nothing here bypasses that check. + * + * Previously nothing in the shipped product called `handleInbound()` at all: it existed only as a function the integration test invoked directly against a hand-rolled `tls.createServer`. This is that server, promoted to real code. + */ + listen(host: string, port: number): Promise { + if (this.listener) { + throw new Error("FederationManager is already listening"); + } + return new Promise((resolve, reject) => { + const server = tls.createServer( + { + key: this.identity.privateKey, + cert: this.identity.certificate, + // Same as the outbound side: no CA, so we don't ask Node to verify the chain. requestCert is what makes the connecting peer's own certificate available to verifyPeerOrDestroy() inside handleInbound() — without it there is nothing to check. + rejectUnauthorized: false, + requestCert: true, + }, + (socket) => { + this.handleInbound(socket).catch(() => { + // Rejected (untrusted fingerprint, or shutting down) — the socket is already destroyed inside handleInbound/verifyPeerOrDestroy. + }); + }, + ); + + server.listen(port, host, () => { + this.listener = server; + resolve(); + }); + server.on("error", reject); + }); + } + + /** Stop accepting new inbound federation connections. Existing links are unaffected. */ + stopListening(): Promise { + const server = this.listener; + if (!server) return Promise.resolve(); + this.listener = undefined; + return new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + } + // ----------------------------------------------------------------------- // Shutdown // ----------------------------------------------------------------------- async shutdown(): Promise { this.shutDown = true; + await this.stopListening(); for (const linkId of [...this.links.keys()]) { await this.disconnect(linkId); } From 3b7f85d623e002519959cdfde049e13f1ac9843e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 17:20:05 +0100 Subject: [PATCH 3/5] feat(core): expose federation trust management through CommsStore Extend the CommsStore interface with the operations needed to actually use federation's new fingerprint allowlist: getFederationFingerprint, fedTrust/fedUntrust/fedTrustedFingerprints, and fedListen/fedStopListening. MeshStore delegates to the corresponding FederationManager methods. FileStore, which doesn't support networking at all, follows its existing not-supported pattern for the mutating operations and returns empty/no-op results for the read-only ones. --- src/core/comms-store.ts | 12 ++++++++++++ src/core/mesh-store.ts | 27 +++++++++++++++++++++++++++ src/core/store.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/core/comms-store.ts b/src/core/comms-store.ts index ad101e4..130629c 100644 --- a/src/core/comms-store.ts +++ b/src/core/comms-store.ts @@ -105,6 +105,18 @@ export interface CommsStore { fedConnect(host: string, port: number, name?: string): Promise; fedDisconnect(linkId: string): Promise; fedLinks(): FedLink[]; + /** This instance's own federation TLS fingerprint, to hand to an operator on the other side to pin. */ + getFederationFingerprint(): string; + /** Pin a remote mesh's certificate fingerprint as trusted for federation, inbound or outbound. */ + fedTrust(fingerprint: string): Promise; + /** Remove a previously pinned federation fingerprint. */ + fedUntrust(fingerprint: string): Promise; + /** List currently trusted federation fingerprints. */ + fedTrustedFingerprints(): string[]; + /** Start accepting inbound federation links on host:port. Rejects any connection whose certificate isn't pinned via fedTrust(). */ + fedListen(host: string, port: number): Promise; + /** Stop accepting new inbound federation connections. Existing links are unaffected. */ + fedStopListening(): Promise; // -- Connection approval -- acceptConnection(connectionId: string): Promise; rejectConnection(connectionId: string, reason: string): Promise; diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 121beb5..268a60f 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -22,6 +22,7 @@ import { MdnsDiscoveryBackend } from "./discovery-mdns.js"; import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js"; import { FederationManager } from "./federation.js"; import type { FedLink } from "./federation.js"; +import { getCertificateFingerprint } from "./identity.js"; import type { MeshMessage, MeshStatePatch, PeerInfo } from "./wire-protocol.js"; import type { ConnectionHandle, @@ -1644,6 +1645,32 @@ export class MeshStore implements CommsStore { return this.federation.listLinks(); } + getFederationFingerprint(): string { + return getCertificateFingerprint(this.federation.tlsIdentity.certificate); + } + + fedTrust(fingerprint: string): Promise { + this.federation.addTrustedFingerprint(fingerprint); + return Promise.resolve(); + } + + fedUntrust(fingerprint: string): Promise { + this.federation.removeTrustedFingerprint(fingerprint); + return Promise.resolve(); + } + + fedTrustedFingerprints(): string[] { + return this.federation.listTrustedFingerprints(); + } + + fedListen(host: string, port: number): Promise { + return this.federation.listen(host, port); + } + + fedStopListening(): Promise { + return this.federation.stopListening(); + } + // ----------------------------------------------------------------------- // Federation callbacks (inbound from remote meshes) // ----------------------------------------------------------------------- diff --git a/src/core/store.ts b/src/core/store.ts index 00df400..a9574bf 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -691,6 +691,39 @@ export class FileStore implements CommsStore { fedLinks(): FedLink[] { return []; } + + getFederationFingerprint(): string { + return ""; + } + + fedTrust(): Promise { + throw new CommsError( + "FileStore does not support federation", + "NOT_SUPPORTED", + ); + } + + fedUntrust(): Promise { + throw new CommsError( + "FileStore does not support federation", + "NOT_SUPPORTED", + ); + } + + fedTrustedFingerprints(): string[] { + return []; + } + + fedListen(): Promise { + throw new CommsError( + "FileStore does not support federation", + "NOT_SUPPORTED", + ); + } + + fedStopListening(): Promise { + return Promise.resolve(); + } // Connection approval — not supported by FileStore // ----------------------------------------------------------------------- From 95c664cb85d13ed8d80d72966c4ea121b6af79c1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 17:20:11 +0100 Subject: [PATCH 4/5] feat(tool): add MCP actions for managing federation trust mesh_fed_fingerprint, mesh_fed_trust, mesh_fed_untrust, mesh_fed_trusted, mesh_fed_listen, and mesh_fed_stop_listening give an agent the operations needed to actually establish a federation link under the new fingerprint-verification requirement: read this instance's own fingerprint to hand to the other side, pin the other side's fingerprint once received out of band, and start accepting inbound links. --- src/core/tool.ts | 99 +++++++++++++++++++++++++++++++++++++++++++++++ src/core/types.ts | 16 ++++++++ 2 files changed, 115 insertions(+) diff --git a/src/core/tool.ts b/src/core/tool.ts index 4efb693..e7ff167 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -108,6 +108,18 @@ export class CommsTool { return await this.meshFedDisconnect(ctx, action); case "mesh_fed_links": return this.meshFedLinks(ctx); + case "mesh_fed_fingerprint": + return this.meshFedFingerprint(ctx); + case "mesh_fed_trust": + return await this.meshFedTrust(ctx, action); + case "mesh_fed_untrust": + return await this.meshFedUntrust(ctx, action); + case "mesh_fed_trusted": + return this.meshFedTrusted(ctx); + case "mesh_fed_listen": + return await this.meshFedListen(ctx, action); + case "mesh_fed_stop_listening": + return await this.meshFedStopListening(ctx); default: return { content: `Unknown action: ${JSON.stringify(action).slice(0, 100)}`, @@ -609,6 +621,93 @@ export class CommsTool { }; } + private meshFedFingerprint(_ctx: CommsContext): CommsResult { + const fingerprint = this.store.getFederationFingerprint(); + return { + content: `This mesh's federation fingerprint: ${fingerprint}\nHand this to the operator on the other side so they can run mesh_fed_trust with it — and do the same in reverse before either side connects.`, + isError: false, + }; + } + + private async meshFedTrust( + _ctx: CommsContext, + action: CommsAction & { action: "mesh_fed_trust" }, + ): Promise { + try { + await this.store.fedTrust(action.fingerprint); + return { + content: `Trusted federation fingerprint: ${action.fingerprint}`, + isError: false, + }; + } catch (err) { + return { + content: `Failed to trust fingerprint: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + private async meshFedUntrust( + _ctx: CommsContext, + action: CommsAction & { action: "mesh_fed_untrust" }, + ): Promise { + try { + await this.store.fedUntrust(action.fingerprint); + return { + content: `Untrusted federation fingerprint: ${action.fingerprint}`, + isError: false, + }; + } catch (err) { + return { + content: `Failed to untrust fingerprint: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + private meshFedTrusted(_ctx: CommsContext): CommsResult { + const fingerprints = this.store.fedTrustedFingerprints(); + if (fingerprints.length === 0) + return { content: "No trusted federation fingerprints.", isError: false }; + return { + content: `Trusted federation fingerprints:\n${fingerprints.map((f) => ` ${f}`).join("\n")}`, + isError: false, + }; + } + + private async meshFedListen( + _ctx: CommsContext, + action: CommsAction & { action: "mesh_fed_listen" }, + ): Promise { + try { + await this.store.fedListen(action.host, action.port); + return { + content: `Listening for inbound federation links on ${action.host}:${String(action.port)}. Only connections presenting a trusted fingerprint (mesh_fed_trust) will be accepted.`, + isError: false, + }; + } catch (err) { + return { + content: `Failed to start federation listener: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + private async meshFedStopListening(_ctx: CommsContext): Promise { + try { + await this.store.fedStopListening(); + return { + content: "Stopped accepting inbound federation connections.", + isError: false, + }; + } catch (err) { + return { + content: `Failed to stop federation listener: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + private async meshReject( _ctx: CommsContext, action: CommsAction & { action: "mesh_reject" }, diff --git a/src/core/types.ts b/src/core/types.ts index d76e24a..7e176b3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -384,6 +384,22 @@ export const CommsActionSchema = defineSchema( linkId: z.string(), }), z.object({ action: z.literal("mesh_fed_links") }), + z.object({ action: z.literal("mesh_fed_fingerprint") }), + z.object({ + action: z.literal("mesh_fed_trust"), + fingerprint: z.string(), + }), + z.object({ + action: z.literal("mesh_fed_untrust"), + fingerprint: z.string(), + }), + z.object({ action: z.literal("mesh_fed_trusted") }), + z.object({ + action: z.literal("mesh_fed_listen"), + host: z.string(), + port: z.number(), + }), + z.object({ action: z.literal("mesh_fed_stop_listening") }), ]), ); export type CommsAction = z.infer; From 9740d8c25d9064f0bc3ce50f6904b915a41de442 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 17:20:19 +0100 Subject: [PATCH 5/5] test(core): cover federation fingerprint rejection and run the suite in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the federation integration test to establish links through the real production path (store.fedListen(), replacing the test's own hand-rolled tls.createServer) and to pin fingerprints on both sides before connecting, matching the new verification requirement. Add a case confirming an inbound connection with no pinned fingerprint is rejected and creates no link. Wire the test into package.json as test:federation, included in test:all — previously nothing ran it at all, which is how handleInbound() went unwired for this long without anything catching it. --- package.json | 3 +- src/test/federation.integration.test.ts | 118 ++++++++---------------- 2 files changed, 40 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index cab48ac..72d27c6 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", - "test:all": "pnpm test && pnpm test:delivery", + "test:federation": "node dist/test/federation.integration.test.js", + "test:all": "pnpm test && pnpm test:delivery && pnpm test:federation", "test:frontend": "tsx --test src/bridges/user/web/frontend/test/**/*.unit.test.ts", "test:e2e": "playwright test", "typecheck": "tsc --noEmit" diff --git a/src/test/federation.integration.test.ts b/src/test/federation.integration.test.ts index 3d85ee9..2fb5e64 100644 --- a/src/test/federation.integration.test.ts +++ b/src/test/federation.integration.test.ts @@ -1,13 +1,14 @@ /** - * Federation integration test — verifies that two MeshStore instances on - * different "machines" (simulated via separate TCP meshes) can federate - * through coordinator-to-coordinator TLS links. + * Federation integration test — verifies that two MeshStore instances on different "machines" (simulated via separate TCP meshes) can federate through coordinator-to-coordinator TLS links, and that an inbound link presenting an untrusted certificate is rejected outright. * * Tests: - * 1. Establish federation link between two meshes - * 2. Agent presence propagates across federation - * 3. Messages in federated rooms propagate across federation - * 4. Non-federated rooms are isolated (messages never cross) + * 0. An inbound connection with no pinned fingerprint is rejected + * 1. Establish federation link between two meshes once both fingerprints are trusted + * 2. Agent presence propagates across federation + * 3. Messages in federated rooms propagate across federation + * 4. Non-federated rooms are isolated (messages never cross) + * 5. Federation link listing + * 6. Disconnect federation link * * Run: node dist/test/federation.integration.test.js */ @@ -20,7 +21,6 @@ import * as net from "node:net"; // Use high ports to avoid collisions with real meshes const MESH_A_PORT = 28876; const MESH_B_PORT = 28877; -const FED_PORT_A = 28878; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -30,10 +30,6 @@ function sleep(ms: number): Promise { // Helpers // --------------------------------------------------------------------------- -/** - * Create a mesh store that also listens on a second port for federation - * inbound connections. Returns the store and the federation listener port. - */ async function createMesh( name: string, coordinatorPort: number, @@ -60,9 +56,7 @@ async function createMesh( return { store, deliveries }; } -/** - * Find a free port on localhost. - */ +/** Find a free port on localhost. */ function findFreePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -86,7 +80,6 @@ function findFreePort(): Promise { async function main(): Promise { console.log("=== Federation Integration Tests ===\n"); - // Create two separate meshes (simulating two machines) console.log("Creating mesh A (coordinator)..."); const a = await createMesh("mesh-a-agent", MESH_A_PORT); await sleep(100); @@ -95,20 +88,39 @@ async function main(): Promise { const b = await createMesh("mesh-b-agent", MESH_B_PORT); await sleep(100); - // Find a free port for the federation server const fedPort = await findFreePort(); console.log(`Using federation port ${String(fedPort)}`); - // Start a TLS federation server on mesh A that the FederationManager - // can accept connections on. We use the federation manager's TLS identity. - const fedServer = await startFedServer( - a.store.federation.tlsIdentity, - fedPort, - a.store, - ); + // Start A's real production federation listener (fedListen -> FederationManager.listen -> handleInbound, the same path a deployed coordinator uses — not a hand-rolled test-only TLS server). + await a.store.fedListen("127.0.0.1", fedPort); await sleep(100); - // --- Test 1: Establish federation link --- + // --- Test 0: untrusted inbound connection is rejected --- + console.log("\nTest 0: untrusted connection is rejected..."); + await assert.rejects( + () => b.store.fedConnect("127.0.0.1", fedPort), + /rejected|not in the trusted-fingerprint allowlist/i, + "Connecting before either side has pinned the other's fingerprint should be rejected", + ); + assert.strictEqual( + b.store.fedLinks().length, + 0, + "B should have no federation links after a rejected attempt", + ); + console.log(" Rejected as expected — no link was created."); + + // --- Pin fingerprints on both sides, mirroring what an operator does out of band --- + console.log("\nPinning fingerprints on both sides..."); + const fingerprintA = a.store.getFederationFingerprint(); + const fingerprintB = b.store.getFederationFingerprint(); + assert.ok(fingerprintA.length > 0, "A should report its own fingerprint"); + assert.ok(fingerprintB.length > 0, "B should report its own fingerprint"); + await a.store.fedTrust(fingerprintB); + await b.store.fedTrust(fingerprintA); + assert.deepStrictEqual(a.store.fedTrustedFingerprints(), [fingerprintB]); + assert.deepStrictEqual(b.store.fedTrustedFingerprints(), [fingerprintA]); + + // --- Test 1: Establish federation link now that both sides trust each other --- console.log("\nTest 1: Establish federation link..."); const linkId = await b.store.fedConnect("127.0.0.1", fedPort); console.log(` Link established: ${linkId}`); @@ -124,7 +136,6 @@ async function main(): Promise { // --- Test 2: Agent presence propagates --- console.log("Test 2: Agent presence propagates..."); - // After federation, B should see A's agent as a federated agent const agentsB = await b.store.listAgents(b.store.peerId); console.log(` B sees ${String(agentsB.length)} agent(s)`); const fedAgentsB = agentsB.filter((ag) => ag.tags.includes("federated")); @@ -133,7 +144,6 @@ async function main(): Promise { "B should see at least 1 federated agent from A", ); - // A should see B's agent as a federated agent const agentsA = await a.store.listAgents(a.store.peerId); console.log(` A sees ${String(agentsA.length)} agent(s)`); const fedAgentsA = agentsA.filter((ag) => ag.tags.includes("federated")); @@ -145,7 +155,6 @@ async function main(): Promise { // --- Test 3: Federated room messages propagate --- console.log("Test 3: Federated room messages propagate..."); - // Create a federated room on mesh A const fedRoomId = `fed-room-${String(Date.now())}`; const fedRoom = await a.store.createRoom({ name: fedRoomId, @@ -157,7 +166,6 @@ async function main(): Promise { console.log(` Created federated room: ${fedRoom.id}`); await sleep(200); - // Create the same federated room on mesh B (same ID) const fedRoomB = await b.store.createRoom({ name: fedRoomId, type: "public", @@ -168,11 +176,9 @@ async function main(): Promise { console.log(` Created matching federated room on B: ${fedRoomB.id}`); await sleep(200); - // Clear deliveries a.deliveries.length = 0; b.deliveries.length = 0; - // Send a message from A's agent in the federated room const msg = await a.store.sendRoomMessage( fedRoom.id, a.store.peerId, @@ -181,7 +187,6 @@ async function main(): Promise { console.log(` A sent: "${msg.content}"`); await sleep(500); - // B should receive the federated message const fedMsgs = b.deliveries.filter( (e) => e.type === "room_message" && e.message.content === "Hello from mesh A!", @@ -192,7 +197,6 @@ async function main(): Promise { // --- Test 4: Non-federated rooms are isolated --- console.log("Test 4: Non-federated rooms are isolated..."); - // Create a non-federated room on mesh A const localRoomId = `local-room-${String(Date.now())}`; console.log(` Creating non-federated room: ${localRoomId}`); const localRoom = await a.store.createRoom({ @@ -207,10 +211,8 @@ async function main(): Promise { ); await sleep(100); - // Clear deliveries b.deliveries.length = 0; - // Send a message in the non-federated room console.log(" Sending message in non-federated room..."); await a.store.sendRoomMessage( localRoom.id, @@ -220,7 +222,6 @@ async function main(): Promise { console.log(" Message sent."); await sleep(100); - // B should NOT receive this message const leakedMsgs = b.deliveries.filter( (e) => e.type === "room_message" && e.message.content === "Secret local message", @@ -252,56 +253,13 @@ async function main(): Promise { // --- Cleanup --- console.log("\nCleaning up..."); - fedServer.close(); + await a.store.fedStopListening(); await a.store.shutdown(); await b.store.shutdown(); console.log("\n✓ All federation tests passed!"); } -// --------------------------------------------------------------------------- -// Federation TLS server (simulates coordinator-to-coordinator link) -// --------------------------------------------------------------------------- - -import * as tls from "node:tls"; -import type { PeerIdentity } from "../core/identity.js"; -import { encode, isMeshMessage, MessageBuffer } from "../core/wire-protocol.js"; -import type { MeshMessage } from "../core/wire-protocol.js"; - -/** - * Start a simple TLS server that accepts federation connections and - * delegates them to the FederationManager on the given store. - */ -function startFedServer( - identity: PeerIdentity, - port: number, - store: MeshStore, -): Promise { - return new Promise((resolve, reject) => { - const server = tls.createServer( - { - key: identity.privateKey, - cert: identity.certificate, - rejectUnauthorized: false, - requestCert: true, - }, - (socket) => { - // Delegate to the FederationManager's inbound handler - void store.federation.handleInbound(socket).catch((err: unknown) => { - console.error("Fed inbound error:", err); - socket.destroy(); - }); - }, - ); - - server.listen(port, "127.0.0.1", () => { - resolve(server); - }); - - server.on("error", reject); - }); -} - main().catch((err: unknown) => { console.error("Test failed:", err); process.exit(1);