mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
72df96c297
* feat(agentbbs): cross-host federation — signed envelopes, pinned peers, union merge
Phase 1 gave every host a local append-only room log and derived roomId
deterministically from the room label, so two hosts that register #sales
already compute the same roomId without talking. This adds the layer that
actually moves envelopes between them.
Design. A room log is an append-only set of immutable envelopes, so reconciling
two hosts is a set union — there is no conflicting write to arbitrate and
nothing for a consensus round to decide. Union is commutative, associative and
idempotent, so sync is order independent and safe to retry. That is a
grow-only set keyed on envelopeId, and it is cheaper and less failure-prone
than the Byzantine agreement the plugin README gestures at.
What union does not give you is authenticity: if any peer can inject, the merge
faithfully replicates forgeries. So each host now holds a persistent Ed25519
identity (Phase 1's key was ephemeral per process, which no peer can pin),
every published envelope is signed, and a receiver verifies against the key it
pinned at peer-add time rather than one carried in the envelope — otherwise an
attacker signs with their own key and claims any origin.
Transport is pull-based HTTP: peers poll GET /agentbbs/v1/rooms/:roomId/
envelopes?since=N. Pull needs no inbound connectivity, tolerates a node being
offline, and leaves ingest volume under the receiver's control; push would make
every node an unauthenticated write target. The server binds 127.0.0.1 unless
a bindHost is passed explicitly, and exposes no route that mutates state.
Bounds are enforced receive-side where a sender cannot negotiate them away:
envelopes per sync, bytes per envelope, bytes per response, peer count, and a
hop limit that is incremented on merge so a cycle terminates. hops is excluded
from the signed material, since it is mutated in transit by design.
Five tools: identity, peer_add, peers, serve, sync.
Tested: 35 new tests, most of them the trust boundary — forged payload,
forged origin, key substitution, unpinned signer, cross-room injection, replay,
oversize, hop exhaustion, traversal at the HTTP boundary, and private key never
served. Convergence and idempotence are asserted against two real nodes over a
real socket, not mocks. Verified end to end with three independent nodes:
all three converge on the same envelope set, a second round merges nothing,
and an unpinned node's injection is rejected.
The Phase 1 structural test pinned the surface at exactly 4 tools; it now pins
9 and names both groups, so adding a tool stays a deliberate contract change.
Full package suite: 68 failures against 70 on main, +35 passing — no
regressions.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* ci(agentbbs): stop rule 2 firing on the federation wire namespace
ADR-164 rule 2 exists to stop `agentbbs` becoming a mandatory dependency: a
static `import ... from 'agentbbs'` is forbidden, and any other mention has to
sit behind loadAgentbbs / a dynamic import. Its catch-all is a substring test
for "agentbbs" anywhere in the file.
agentbbs-federation.ts trips that catch-all without importing anything. Its
only remaining mentions after comment-stripping are string literals that never
reach a module resolver: the '/agentbbs/v1/...' HTTP route prefix and the
'agentbbs:<kind>:' hash domain-separators.
Adds those two shapes to the existing benign-pattern strip list. The
static-import check is untouched and remains the real gate.
Verified this narrows the check rather than loosening it, by running the
workflow's own rule-2 body verbatim against fixtures:
- the real federation module -> passes (false positive gone)
- `import { x } from 'agentbbs'` -> still caught
- unguarded runtime reference -> still caught
- guarded `await import('agentbbs')` -> passes
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* ci(agentbbs): feed rule 2 via a quoted heredoc so bash stops mangling it
The rule 2 fix in fda44cd1f passed locally but still failed in CI. Cause:
the check runs as `node -e "..."` — a bash double-quoted string — so bash
rewrites the script before node ever parses it:
- `\\?` collapsed to `\?`, turning the intended optional-backslash
lookahead into a lookahead for a literal '?'. It could never match
`agentbbs/v1`, so the strip was a no-op and rule 2 kept firing.
- backticks in the explanatory comments were command substitution, so
bash actually executed `export { agentbbsTools } from ...` etc. and
spliced their (empty) output back into the script. Pre-existing since
#2503 — visible as `import: command not found` noise in every run.
Fix the boundary rather than re-escaping: feed the script through a
QUOTED heredoc (`node - <<'RULE2'`), which bash passes through verbatim.
Regex backslashes and comment backticks now reach node as written. Also
drops backslashes from the new lookaheads so the pattern is robust even
if the step is ever converted back to an inline string.
Verified by executing the step's actual `run:` block through `bash -c`
(parsed out of the YAML), not a hand-retyped copy — that gap is what hid
the bug. Negative controls, all through that same path:
static import of agentbbs ............................. CAUGHT
unguarded runtime ref (deps['agentbbs']) .............. CAUGHT
wire route + real unguarded ref (bypass test) ......... CAUGHT
guarded dynamic import ................................ pass
wire route only ('/agentbbs/v1/...') .................. pass
hash domain only ('agentbbs:node:') ................... pass
escaped-slash route (/agentbbs\/v1\/rooms/) ........... pass
real tree ............................................. pass
The static-import check is untouched and remains the real gate.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
388 lines
17 KiB
TypeScript
388 lines
17 KiB
TypeScript
/**
|
|
* agentbbs Phase 2 cross-host federation.
|
|
*
|
|
* The happy path here is small; most of this file is the trust boundary.
|
|
* Merge ingests bytes from another machine, so the tests that matter are the
|
|
* ones that prove a hostile peer cannot get an envelope past verification —
|
|
* forged signatures, key substitution, replay, oversize, hop exhaustion, and
|
|
* cross-room injection.
|
|
*
|
|
* Two nodes are run against real temp directories and a real HTTP server, so
|
|
* this exercises the actual transport rather than a mocked one.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, appendFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import type { Server } from 'node:http';
|
|
|
|
import {
|
|
getNodeIdentity,
|
|
signEnvelope,
|
|
verifyEnvelope,
|
|
canonicalEnvelopeBytes,
|
|
addPeer,
|
|
removePeer,
|
|
readPeers,
|
|
readEnvelopes,
|
|
mergeEnvelopes,
|
|
syncRoomFromPeer,
|
|
serveFederation,
|
|
validatePeerUrl,
|
|
validateRoomId,
|
|
MAX_HOPS,
|
|
MAX_ENVELOPE_BYTES,
|
|
type SignedEnvelope,
|
|
} from '../src/mcp-tools/agentbbs-federation.js';
|
|
|
|
let dirA: string, dirB: string;
|
|
const servers: Server[] = [];
|
|
|
|
function tmp(p: string) { return mkdtempSync(join(tmpdir(), p)); }
|
|
|
|
function envelope(over: Partial<SignedEnvelope> = {}): SignedEnvelope {
|
|
return {
|
|
envelopeId: 'e-' + Math.random().toString(36).slice(2, 10),
|
|
roomId: 'sales-dd6531eb',
|
|
seq: 1,
|
|
msgType: 'Note',
|
|
payload: { hello: 'world' },
|
|
timestamp: '2026-09-09T00:00:00.000Z',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => { dirA = tmp('abbs-a-'); dirB = tmp('abbs-b-'); });
|
|
afterEach(() => {
|
|
for (const s of servers.splice(0)) { try { s.close(); } catch { /* already closed */ } }
|
|
for (const d of [dirA, dirB]) { try { rmSync(d, { recursive: true, force: true }); } catch { /* gone */ } }
|
|
});
|
|
|
|
describe('node identity', () => {
|
|
it('creates a stable identity and reuses it across calls', async () => {
|
|
const a = await getNodeIdentity(dirA);
|
|
const b = await getNodeIdentity(dirA);
|
|
expect(a.nodeId).toBe(b.nodeId);
|
|
expect(a.nodeId).toMatch(/^[0-9a-f]{16}$/);
|
|
expect(a.publicKey).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('gives different hosts different identities', async () => {
|
|
const a = await getNodeIdentity(dirA);
|
|
const b = await getNodeIdentity(dirB);
|
|
expect(a.nodeId).not.toBe(b.nodeId);
|
|
});
|
|
|
|
it('persists the private key 0600 and never widens it', async () => {
|
|
await getNodeIdentity(dirA);
|
|
const mode = statSync(join(dirA, 'node-identity.json')).mode & 0o777;
|
|
expect(mode).toBe(0o600);
|
|
});
|
|
|
|
it('rejects a malformed identity file rather than trusting it', async () => {
|
|
await getNodeIdentity(dirA);
|
|
const p = join(dirA, 'node-identity.json');
|
|
const bad = JSON.parse(readFileSync(p, 'utf-8'));
|
|
bad.nodeId = 'not-hex';
|
|
require('node:fs').writeFileSync(p, JSON.stringify(bad));
|
|
await expect(getNodeIdentity(dirA)).rejects.toThrow(/malformed/);
|
|
});
|
|
});
|
|
|
|
describe('signing and verification', () => {
|
|
it('signs and verifies a round trip', async () => {
|
|
const id = await getNodeIdentity(dirA);
|
|
const signed = await signEnvelope(dirA, envelope());
|
|
expect(signed.origin).toBe(id.nodeId);
|
|
expect(await verifyEnvelope(signed, id.publicKey)).toBe(true);
|
|
});
|
|
|
|
it('survives a JSON round trip — the receiver reconstructs identical bytes', async () => {
|
|
const id = await getNodeIdentity(dirA);
|
|
const signed = await signEnvelope(dirA, envelope({ payload: { b: 2, a: 1, nested: { z: 1, y: [3, 2] } } }));
|
|
const overWire = JSON.parse(JSON.stringify(signed));
|
|
expect(await verifyEnvelope(overWire, id.publicKey)).toBe(true);
|
|
});
|
|
|
|
it('canonicalizes payload key order, so equal values sign equally', () => {
|
|
// Pin envelopeId — the helper randomizes it, and we are isolating payload
|
|
// key order as the only difference between the two inputs.
|
|
const one = canonicalEnvelopeBytes(envelope({ envelopeId: 'fixed', payload: { a: 1, b: 2 } }));
|
|
const two = canonicalEnvelopeBytes(envelope({ envelopeId: 'fixed', payload: { b: 2, a: 1 } }));
|
|
expect(Buffer.from(one).toString()).toBe(Buffer.from(two).toString());
|
|
});
|
|
|
|
it('excludes hops from the signature so relaying does not invalidate it', async () => {
|
|
const id = await getNodeIdentity(dirA);
|
|
const signed = await signEnvelope(dirA, envelope());
|
|
const relayed = { ...signed, hops: 3 };
|
|
expect(await verifyEnvelope(relayed, id.publicKey)).toBe(true);
|
|
});
|
|
|
|
describe('adversarial', () => {
|
|
it('rejects a tampered payload', async () => {
|
|
const id = await getNodeIdentity(dirA);
|
|
const signed = await signEnvelope(dirA, envelope());
|
|
expect(await verifyEnvelope({ ...signed, payload: { hello: 'tampered' } }, id.publicKey)).toBe(false);
|
|
});
|
|
|
|
it('rejects a tampered origin — you cannot claim to be another node', async () => {
|
|
const id = await getNodeIdentity(dirA);
|
|
const signed = await signEnvelope(dirA, envelope());
|
|
expect(await verifyEnvelope({ ...signed, origin: 'deadbeefdeadbeef' }, id.publicKey)).toBe(false);
|
|
});
|
|
|
|
it('rejects verification against a different key (key substitution)', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const signedByA = await signEnvelope(dirA, envelope());
|
|
expect(await verifyEnvelope(signedByA, idB.publicKey)).toBe(false);
|
|
});
|
|
|
|
it('rejects an unsigned or malformed-signature envelope', async () => {
|
|
const id = await getNodeIdentity(dirA);
|
|
expect(await verifyEnvelope(envelope(), id.publicKey)).toBe(false);
|
|
expect(await verifyEnvelope(envelope({ signature: 'zz' }), id.publicKey)).toBe(false);
|
|
expect(await verifyEnvelope(envelope({ signature: 'a'.repeat(128) }), id.publicKey)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('peer registry', () => {
|
|
const KEY = 'a'.repeat(64);
|
|
|
|
it('pins a peer and lists it', () => {
|
|
const p = addPeer(dirA, { nodeId: '0123456789abcdef', url: 'http://127.0.0.1:9999', publicKey: KEY });
|
|
expect(p.nodeId).toBe('0123456789abcdef');
|
|
expect(readPeers(dirA)).toHaveLength(1);
|
|
expect(removePeer(dirA, '0123456789abcdef')).toBe(true);
|
|
expect(readPeers(dirA)).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses to silently re-pin a known nodeId to a different key', () => {
|
|
addPeer(dirA, { nodeId: '0123456789abcdef', url: 'http://127.0.0.1:1', publicKey: KEY });
|
|
expect(() => addPeer(dirA, { nodeId: '0123456789abcdef', url: 'http://127.0.0.1:1', publicKey: 'b'.repeat(64) }))
|
|
.toThrow(/already pinned/);
|
|
});
|
|
|
|
it('rejects malformed identifiers and hostile URLs', () => {
|
|
expect(() => addPeer(dirA, { nodeId: 'short', url: 'http://x', publicKey: KEY })).toThrow(/nodeId/);
|
|
expect(() => addPeer(dirA, { nodeId: '0123456789abcdef', url: 'http://x', publicKey: 'nothex' })).toThrow(/publicKey/);
|
|
expect(() => validatePeerUrl('file:///etc/passwd')).toThrow(/http or https/);
|
|
expect(() => validatePeerUrl('http://user:pw@host')).toThrow(/credentials/);
|
|
expect(() => validatePeerUrl('not a url')).toThrow();
|
|
});
|
|
|
|
it('rejects room ids that could escape the log directory', () => {
|
|
expect(() => validateRoomId('../../etc/passwd')).toThrow();
|
|
expect(() => validateRoomId('a/../../b')).toThrow(/\.\./);
|
|
expect(() => validateRoomId('')).toThrow();
|
|
expect(() => validateRoomId('x'.repeat(129))).toThrow(/128/);
|
|
});
|
|
});
|
|
|
|
describe('union merge', () => {
|
|
it('merges verified envelopes from a peer', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const e1 = await signEnvelope(dirB, envelope({ seq: 1 }));
|
|
const e2 = await signEnvelope(dirB, envelope({ seq: 2 }));
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [e1, e2], idB.publicKey);
|
|
expect(r.merged).toBe(2);
|
|
expect(readEnvelopes(dirA, 'sales-dd6531eb')).toHaveLength(2);
|
|
});
|
|
|
|
it('is idempotent — replaying the same batch merges nothing new', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const e1 = await signEnvelope(dirB, envelope());
|
|
await mergeEnvelopes(dirA, 'sales-dd6531eb', [e1], idB.publicKey);
|
|
const again = await mergeEnvelopes(dirA, 'sales-dd6531eb', [e1], idB.publicKey);
|
|
expect(again.merged).toBe(0);
|
|
expect(again.skippedDuplicate).toBe(1);
|
|
expect(readEnvelopes(dirA, 'sales-dd6531eb')).toHaveLength(1);
|
|
});
|
|
|
|
it('is order independent — A then B equals B then A', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const e1 = await signEnvelope(dirB, envelope({ seq: 1 }));
|
|
const e2 = await signEnvelope(dirB, envelope({ seq: 2 }));
|
|
const forward = tmp('abbs-f-'); const backward = tmp('abbs-r-');
|
|
try {
|
|
await mergeEnvelopes(forward, 'sales-dd6531eb', [e1, e2], idB.publicKey);
|
|
await mergeEnvelopes(backward, 'sales-dd6531eb', [e2, e1], idB.publicKey);
|
|
const ids = (d: string) => readEnvelopes(d, 'sales-dd6531eb').map(e => e.envelopeId).sort();
|
|
expect(ids(forward)).toEqual(ids(backward));
|
|
} finally {
|
|
rmSync(forward, { recursive: true, force: true });
|
|
rmSync(backward, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('adversarial', () => {
|
|
it('drops forged envelopes rather than replicating them', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const good = await signEnvelope(dirB, envelope());
|
|
const forged = { ...good, envelopeId: 'forged-1', payload: { hello: 'evil' } };
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [forged], idB.publicKey);
|
|
expect(r.merged).toBe(0);
|
|
expect(r.skippedUnverified).toBe(1);
|
|
expect(readEnvelopes(dirA, 'sales-dd6531eb')).toHaveLength(0);
|
|
});
|
|
|
|
it('drops envelopes signed by a key other than the pinned one', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const rogue = tmp('abbs-rogue-');
|
|
try {
|
|
const signedByRogue = await signEnvelope(rogue, envelope());
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [signedByRogue], idB.publicKey);
|
|
expect(r.merged).toBe(0);
|
|
expect(r.skippedUnverified).toBe(1);
|
|
} finally { rmSync(rogue, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it('drops cross-room injection — an envelope for another room', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const other = await signEnvelope(dirB, envelope({ roomId: 'finance-f6552ffe' }));
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [other], idB.publicKey);
|
|
expect(r.merged).toBe(0);
|
|
expect(r.skippedUnverified).toBe(1);
|
|
});
|
|
|
|
it('drops envelopes at or past the hop limit', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const looped = await signEnvelope(dirB, envelope());
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [{ ...looped, hops: MAX_HOPS }], idB.publicKey);
|
|
expect(r.merged).toBe(0);
|
|
expect(r.skippedHopLimit).toBe(1);
|
|
});
|
|
|
|
it('increments hops on merge so a cycle terminates', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const e = await signEnvelope(dirB, envelope());
|
|
await mergeEnvelopes(dirA, 'sales-dd6531eb', [e], idB.publicKey);
|
|
expect(readEnvelopes(dirA, 'sales-dd6531eb')[0].hops).toBe(1);
|
|
});
|
|
|
|
it('drops oversize envelopes', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const big = await signEnvelope(dirB, envelope({ payload: { blob: 'x'.repeat(MAX_ENVELOPE_BYTES) } }));
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [big], idB.publicKey);
|
|
expect(r.merged).toBe(0);
|
|
expect(r.skippedOversize).toBe(1);
|
|
});
|
|
|
|
it('tolerates malformed rows without throwing', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [null as any, {} as any, 'x' as any], idB.publicKey);
|
|
expect(r.merged).toBe(0);
|
|
expect(r.skippedUnverified).toBe(3);
|
|
});
|
|
|
|
it('skips corrupt lines already in the local log', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
appendFileSync(join(dirA, 'room-sales-dd6531eb.jsonl'), 'not json\n');
|
|
const e = await signEnvelope(dirB, envelope());
|
|
const r = await mergeEnvelopes(dirA, 'sales-dd6531eb', [e], idB.publicKey);
|
|
expect(r.merged).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('http transport (two real nodes)', () => {
|
|
it('serves envelopes and a peer pulls, verifies and merges them', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const e1 = await signEnvelope(dirB, envelope({ seq: 1 }));
|
|
const e2 = await signEnvelope(dirB, envelope({ seq: 2 }));
|
|
appendFileSync(join(dirB, 'room-sales-dd6531eb.jsonl'), JSON.stringify(e1) + '\n' + JSON.stringify(e2) + '\n');
|
|
|
|
const { server, port } = await serveFederation(dirB, { port: 0 });
|
|
servers.push(server);
|
|
|
|
addPeer(dirA, { nodeId: idB.nodeId, url: `http://127.0.0.1:${port}`, publicKey: idB.publicKey });
|
|
const peer = readPeers(dirA)[0];
|
|
const r = await syncRoomFromPeer(dirA, peer, 'sales-dd6531eb');
|
|
|
|
expect(r.merged).toBe(2);
|
|
expect(readEnvelopes(dirA, 'sales-dd6531eb')).toHaveLength(2);
|
|
expect(readPeers(dirA)[0].lastSeq?.['sales-dd6531eb']).toBe(2);
|
|
});
|
|
|
|
it('a second sync is a no-op — since= advances', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
const e1 = await signEnvelope(dirB, envelope({ seq: 1 }));
|
|
appendFileSync(join(dirB, 'room-sales-dd6531eb.jsonl'), JSON.stringify(e1) + '\n');
|
|
const { server, port } = await serveFederation(dirB, { port: 0 });
|
|
servers.push(server);
|
|
addPeer(dirA, { nodeId: idB.nodeId, url: `http://127.0.0.1:${port}`, publicKey: idB.publicKey });
|
|
|
|
await syncRoomFromPeer(dirA, readPeers(dirA)[0], 'sales-dd6531eb');
|
|
const second = await syncRoomFromPeer(dirA, readPeers(dirA)[0], 'sales-dd6531eb');
|
|
expect(second.merged).toBe(0);
|
|
expect(readEnvelopes(dirA, 'sales-dd6531eb')).toHaveLength(1);
|
|
});
|
|
|
|
it('never serves the private key', async () => {
|
|
await getNodeIdentity(dirB);
|
|
const { server, port } = await serveFederation(dirB, { port: 0 });
|
|
servers.push(server);
|
|
const res = await fetch(`http://127.0.0.1:${port}/agentbbs/v1/identity`);
|
|
const body = await res.json() as Record<string, unknown>;
|
|
expect(body.publicKey).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(body.privateKey).toBeUndefined();
|
|
expect(JSON.stringify(body)).not.toContain(JSON.parse(readFileSync(join(dirB, 'node-identity.json'), 'utf-8')).privateKey);
|
|
});
|
|
|
|
it('binds loopback by default rather than a routable interface', async () => {
|
|
const { server, host } = await serveFederation(dirB, { port: 0 });
|
|
servers.push(server);
|
|
expect(host).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('is read only — mutating verbs are refused', async () => {
|
|
const { server, port } = await serveFederation(dirB, { port: 0 });
|
|
servers.push(server);
|
|
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
|
|
const res = await fetch(`http://127.0.0.1:${port}/agentbbs/v1/rooms/x/envelopes`, { method });
|
|
expect(res.status).toBe(405);
|
|
}
|
|
});
|
|
|
|
it('rejects a traversing roomId at the HTTP boundary', async () => {
|
|
const { server, port } = await serveFederation(dirB, { port: 0 });
|
|
servers.push(server);
|
|
const res = await fetch(`http://127.0.0.1:${port}/agentbbs/v1/rooms/${encodeURIComponent('../../etc/passwd')}/envelopes`);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('surfaces an unreachable peer as an error instead of hanging', async () => {
|
|
const idB = await getNodeIdentity(dirB);
|
|
addPeer(dirA, { nodeId: idB.nodeId, url: 'http://127.0.0.1:1', publicKey: idB.publicKey });
|
|
await expect(syncRoomFromPeer(dirA, readPeers(dirA)[0], 'sales-dd6531eb')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('convergence', () => {
|
|
it('two nodes that each publish converge on the same set after mutual sync', async () => {
|
|
const idA = await getNodeIdentity(dirA);
|
|
const idB = await getNodeIdentity(dirB);
|
|
const room = 'sales-dd6531eb';
|
|
|
|
const a1 = await signEnvelope(dirA, envelope({ seq: 1, payload: { from: 'A' } }));
|
|
const b1 = await signEnvelope(dirB, envelope({ seq: 1, payload: { from: 'B' } }));
|
|
appendFileSync(join(dirA, `room-${room}.jsonl`), JSON.stringify(a1) + '\n');
|
|
appendFileSync(join(dirB, `room-${room}.jsonl`), JSON.stringify(b1) + '\n');
|
|
|
|
const sa = await serveFederation(dirA, { port: 0 }); servers.push(sa.server);
|
|
const sb = await serveFederation(dirB, { port: 0 }); servers.push(sb.server);
|
|
|
|
addPeer(dirA, { nodeId: idB.nodeId, url: `http://127.0.0.1:${sb.port}`, publicKey: idB.publicKey });
|
|
addPeer(dirB, { nodeId: idA.nodeId, url: `http://127.0.0.1:${sa.port}`, publicKey: idA.publicKey });
|
|
|
|
await syncRoomFromPeer(dirA, readPeers(dirA)[0], room);
|
|
await syncRoomFromPeer(dirB, readPeers(dirB)[0], room);
|
|
|
|
const setA = readEnvelopes(dirA, room).map(e => e.envelopeId).sort();
|
|
const setB = readEnvelopes(dirB, room).map(e => e.envelopeId).sort();
|
|
expect(setA).toEqual(setB);
|
|
expect(setA).toHaveLength(2);
|
|
});
|
|
});
|