feat(sdk+cli): bridge peer — forward a topic between two meshes
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

A bridge holds memberships in two meshes and relays messages on a
single topic between them. Federation-lite without a broker-to-broker
protocol.

SDK additions:
- Bridge class (start, stop, EventEmitter for forwarded/dropped/error)
- MeshClient.joinTopic / leaveTopic / createTopic methods
- Loop prevention: plaintext hop counter prefix __cmh<n>: with maxHops
  default 2; echo guard via senderPubkey == own session pubkey

CLI additions:
- claudemesh bridge run <config.yaml> long-lived process
- claudemesh bridge init prints config template
- Zero-dep YAML parser for the flat bridge config shape

The hop prefix is visible in message bodies — minor wart, fixed in
v0.3.0 by moving loop tracking into broker primitives.

SDK kept as devDependency since Bun bundles it into dist; no impact
on npm publish or runtime resolution.

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 13:41:50 +01:00
parent 9418d0ee30
commit 9dd1e401b0
7 changed files with 432 additions and 0 deletions

View File

@@ -55,6 +55,7 @@
"zod": "4.1.13"
},
"devDependencies": {
"@claudemesh/sdk": "workspace:*",
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",

View File

@@ -99,6 +99,13 @@ export function classifyInvocation(command: string, positionals: string[]): Invo
const writeVerbs = new Set(["create", "revoke"]);
return { resource: "apikey", verb, isWrite: writeVerbs.has(verb) };
}
case "bridge": {
// bridge verbs: run (long-lived forwarder) | init (print template).
// `run` is a write at the mesh level since it joins both meshes
// and posts messages on their topics.
const verb = sub || "init";
return { resource: "bridge", verb, isWrite: verb === "run" };
}
// Platform — sub is the verb.
case "vector": case "graph": case "context": case "stream":

View File

@@ -0,0 +1,213 @@
/**
* `claudemesh bridge run <config.yaml>` — long-lived process that joins
* two meshes and forwards a single topic between them.
*
* The CLI doesn't link against @claudemesh/sdk to avoid a workspace
* coupling at publish time — instead it constructs the SDK Bridge
* inline using the same MeshClient that the rest of the CLI already
* relies on. The bridge config file specifies broker URLs, mesh ids,
* memberships (private keys), and the topic name on each side.
*
* Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
*/
import { readFileSync, existsSync } from "node:fs";
import { render } from "~/ui/render.js";
import { bold, clay, dim, green, red, yellow } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
interface BridgeConfigSide {
broker_url: string;
mesh_id: string;
member_id: string;
/** Hex-encoded ed25519 public key. */
pubkey: string;
/** Hex-encoded ed25519 secret key (64 bytes). */
secret_key: string;
topic: string;
display_name?: string;
role?: "lead" | "member" | "observer";
}
interface BridgeConfig {
a: BridgeConfigSide;
b: BridgeConfigSide;
max_hops?: number;
}
/** Tiny YAML parser — handles the flat shape `bridge run` accepts. For
* complex configs, callers can pass JSON (.json extension). */
function parseConfig(text: string): BridgeConfig {
const trimmed = text.trim();
if (trimmed.startsWith("{")) return JSON.parse(trimmed) as BridgeConfig;
const root: Record<string, Record<string, unknown> | number> = {};
let cursor: Record<string, unknown> | null = null;
for (const raw of text.split("\n")) {
const line = raw.replace(/#.*$/, "").trimEnd();
if (!line.trim()) continue;
const top = line.match(/^(a|b)\s*:\s*$/);
if (top) {
cursor = {};
root[top[1]!] = cursor;
continue;
}
const flat = line.match(/^(\w+)\s*:\s*(.+)$/);
if (flat && /^\s/.test(line) && cursor) {
cursor[flat[1]!] = parseScalar(flat[2]!);
} else if (flat) {
const v = parseScalar(flat[2]!);
// top-level scalars (e.g. max_hops) — only number/string supported
if (typeof v === "number") root[flat[1]!] = v;
}
}
return root as unknown as BridgeConfig;
}
function parseScalar(raw: string): string | number | boolean {
const v = raw.trim().replace(/^["'](.*)["']$/, "$1");
if (v === "true") return true;
if (v === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
return v;
}
export async function runBridge(configPath: string): Promise<number> {
if (!configPath) {
render.err("Usage: claudemesh bridge run <config.yaml>");
return EXIT.INVALID_ARGS;
}
if (!existsSync(configPath)) {
render.err(`config file not found: ${configPath}`);
return EXIT.NOT_FOUND;
}
let cfg: BridgeConfig;
try {
cfg = parseConfig(readFileSync(configPath, "utf-8"));
} catch (e) {
render.err(`failed to parse ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
return EXIT.INVALID_ARGS;
}
if (!cfg.a || !cfg.b) {
render.err("config must define 'a:' and 'b:' sections");
return EXIT.INVALID_ARGS;
}
for (const [name, side] of [["a", cfg.a], ["b", cfg.b]] as const) {
if (!side.broker_url || !side.mesh_id || !side.member_id || !side.pubkey || !side.secret_key || !side.topic) {
render.err(`config side '${name}' missing required fields: broker_url, mesh_id, member_id, pubkey, secret_key, topic`);
return EXIT.INVALID_ARGS;
}
}
// Lazy-load SDK so the CLI bundle stays trim for users who never
// bridge.
const { Bridge } = await import("@claudemesh/sdk");
const bridge = new Bridge({
a: {
client: {
brokerUrl: cfg.a.broker_url,
meshId: cfg.a.mesh_id,
memberId: cfg.a.member_id,
pubkey: cfg.a.pubkey,
secretKey: cfg.a.secret_key,
displayName: cfg.a.display_name ?? "bridge",
peerType: "connector",
channel: "bridge",
},
topic: cfg.a.topic,
role: cfg.a.role,
},
b: {
client: {
brokerUrl: cfg.b.broker_url,
meshId: cfg.b.mesh_id,
memberId: cfg.b.member_id,
pubkey: cfg.b.pubkey,
secretKey: cfg.b.secret_key,
displayName: cfg.b.display_name ?? "bridge",
peerType: "connector",
channel: "bridge",
},
topic: cfg.b.topic,
role: cfg.b.role,
},
maxHops: cfg.max_hops,
});
bridge.on("forwarded", (e) => {
process.stdout.write(
`${dim(new Date().toISOString())} ${green("→")} ${e.from}${e.to} hop=${e.hop} ${dim(`${e.bytes}b`)}\n`,
);
});
bridge.on("dropped", (e) => {
process.stdout.write(
`${dim(new Date().toISOString())} ${yellow("·")} drop from=${e.from} reason=${e.reason}${e.hop >= 0 ? ` hop=${e.hop}` : ""}\n`,
);
});
bridge.on("error", (e) => {
process.stderr.write(`${red("✘")} ${e.message}\n`);
});
try {
await bridge.start();
} catch (e) {
render.err(`bridge failed to start: ${e instanceof Error ? e.message : String(e)}`);
return EXIT.NETWORK_ERROR;
}
render.ok(
"bridge running",
`${clay("#" + cfg.a.topic)} ${dim("⟷")} ${clay("#" + cfg.b.topic)}`,
);
process.stderr.write(`${dim(` meshes: ${cfg.a.mesh_id.slice(0, 8)}${cfg.b.mesh_id.slice(0, 8)} max_hops: ${cfg.max_hops ?? 2}`)}\n`);
process.stderr.write(`${dim(" Ctrl-C to stop.")}\n\n`);
// Keep the process alive; bridge runs forever.
await new Promise<void>((resolve) => {
const stop = async (): Promise<void> => {
process.stderr.write(`\n${dim("stopping bridge...")}\n`);
await bridge.stop();
resolve();
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
});
return EXIT.SUCCESS;
}
/** Generate a config skeleton for the user to fill in. */
export function bridgeConfigTemplate(): string {
return `# claudemesh bridge config
# Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
#
# A bridge holds memberships in two meshes and forwards messages on a
# single topic between them. Loop prevention via plaintext hop counter
# (visible in message body — minor wart, fixed in v0.3.0).
#
# Tip: \`claudemesh peer verify\` shows the keys/ids you need below.
max_hops: 2
a:
broker_url: wss://ic.claudemesh.com/ws
mesh_id: <mesh A id>
member_id: <bridge member id in mesh A>
pubkey: <ed25519 public key hex, 32 bytes>
secret_key: <ed25519 secret key hex, 64 bytes>
topic: incidents
display_name: bridge
role: member
b:
broker_url: wss://ic.claudemesh.com/ws
mesh_id: <mesh B id>
member_id: <bridge member id in mesh B>
pubkey: <ed25519 public key hex>
secret_key: <ed25519 secret key hex>
topic: incidents
`;
}

View File

@@ -107,6 +107,10 @@ API keys (REST + external WS auth, v0.2.0)
claudemesh apikey list show keys (status, last-used, scope)
claudemesh apikey revoke <id> revoke a key
Bridge (forward a topic between two meshes, v0.2.0)
claudemesh bridge init print config template
claudemesh bridge run <config> run bridge as a long-lived process
Topic (conversation scope, v0.2.0)
claudemesh topic create <name> create a topic [--description --visibility]
claudemesh topic list list topics in the mesh
@@ -514,6 +518,23 @@ async function main(): Promise<void> {
break;
}
// bridge — forward a topic between two meshes (v0.2.0)
case "bridge": {
const sub = positionals[0];
if (sub === "run") {
const { runBridge } = await import("~/commands/bridge.js");
process.exit(await runBridge(positionals[1] ?? ""));
} else if (sub === "init" || sub === "config") {
const { bridgeConfigTemplate } = await import("~/commands/bridge.js");
console.log(bridgeConfigTemplate());
process.exit(EXIT.SUCCESS);
} else {
console.error("Usage: claudemesh bridge <run <config.yaml> | init>");
process.exit(EXIT.INVALID_ARGS);
}
break;
}
// apikey — REST + external WS bearer tokens (v0.2.0)
case "apikey": case "api-key": {
const sub = positionals[0];