From 758ea0e42c09fd78b621c29a8c2ff15a25f78dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:36:32 +0100 Subject: [PATCH] feat(cli): invite-link parsing + join flow + keypair generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end join: user runs `claudemesh join ic://join/` and walks away with a signed member record + persistent keypair. new modules: - src/crypto/keypair.ts: libsodium ed25519 keypair generation. Format is crypto_sign_keypair raw bytes, hex-encoded (32-byte pub, 64-byte secret = seed || pub). Same format libsodium will need in Step 18 for sign/verify. - src/invite/parse.ts: ic://join/ parser with Zod shape validation + expiry check. encodeInviteLink helper for tests. - src/invite/enroll.ts: POST /join to broker, converts ws:// to http:// transparently. rewritten join command wires them together: 1. parse invite → 2. generate keypair → 3. POST /join → 4. persist config → 5. print success. state/config.ts: saveConfig now chmods the file to 0600 after write, since it holds ed25519 secret keys. No-op on Windows. signature verification (step 18) + invite-token one-time-use tracking are deferred. For now the invite link is a plain bearer token; any client with the link can join. verified end-to-end via apps/cli/scripts/join-roundtrip.ts: build invite → run join subprocess → load new config → connect as new member → send A→B → receive push. Flow passes. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/scripts/join-roundtrip.ts | 115 +++++++++++++++++++++++++++++ apps/cli/scripts/make-invite.ts | 24 ++++++ apps/cli/src/commands/join.ts | 88 ++++++++++++++++++---- apps/cli/src/crypto/keypair.ts | 36 +++++++++ apps/cli/src/index.ts | 2 +- apps/cli/src/invite/enroll.ts | 56 ++++++++++++++ apps/cli/src/invite/parse.ts | 81 ++++++++++++++++++++ apps/cli/src/state/config.ts | 14 +++- 8 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 apps/cli/scripts/join-roundtrip.ts create mode 100644 apps/cli/scripts/make-invite.ts create mode 100644 apps/cli/src/crypto/keypair.ts create mode 100644 apps/cli/src/invite/enroll.ts create mode 100644 apps/cli/src/invite/parse.ts diff --git a/apps/cli/scripts/join-roundtrip.ts b/apps/cli/scripts/join-roundtrip.ts new file mode 100644 index 0000000..23c5e0b --- /dev/null +++ b/apps/cli/scripts/join-roundtrip.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env bun +/** + * Full join → connect → send round-trip. + * + * Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json). + * Creates a fresh invite link, runs the join command, connects with + * the newly-generated member identity, sends a message to peer B, + * asserts receipt. + */ + +// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env — +// ESM imports hoist above statements, so we can't set process.env +// after the `import { env }` side effect has already run. +import { readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { BrokerClient } from "../src/ws/client"; +import type { JoinedMesh } from "../src/state/config"; +import { loadConfig, getConfigPath } from "../src/state/config"; + +if (!process.env.CLAUDEMESH_CONFIG_DIR) { + console.error( + "Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts", + ); + process.exit(1); +} +execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, { + stdio: "ignore", +}); + +const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as { + meshId: string; + peerB: { memberId: string; pubkey: string }; +}; + +async function main(): Promise { + // 1. Build invite. + const link = execSync("bun scripts/make-invite.ts").toString().trim(); + console.log("[rt] invite:", link.slice(0, 60) + "…"); + + // 2. Run `claudemesh join` with the same CONFIG_DIR. + const joinOut = execSync(`bun src/index.ts join "${link}"`, { + env: { + ...process.env, + CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt", + }, + }).toString(); + console.log("[rt] join output (tail):"); + console.log( + joinOut + .split("\n") + .slice(-7) + .map((l) => " " + l) + .join("\n"), + ); + + // 3. Load the fresh config and connect as the new peer. + console.log(`[rt] loading config from: ${getConfigPath()}`); + const config = loadConfig(); + console.log(`[rt] loaded ${config.meshes.length} mesh(es)`); + const joined = config.meshes.find((m) => m.slug === "rt-join"); + if (!joined) throw new Error("rt-join mesh not found in config"); + const joinedMesh: JoinedMesh = joined; + console.log( + `[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}…`, + ); + + // 4. Connect also as peer-B (the target) so we can observe receipt. + const targetMesh: JoinedMesh = { + ...joinedMesh, + memberId: seed.peerB.memberId, + slug: "rt-join-b", + pubkey: seed.peerB.pubkey, + }; + const joiner = new BrokerClient(joinedMesh); + const target = new BrokerClient(targetMesh); + + let received = ""; + target.onPush((m) => { + received = Buffer.from(m.ciphertext, "base64").toString("utf-8"); + console.log(`[rt] target got: "${received}"`); + }); + + await Promise.all([joiner.connect(), target.connect()]); + console.log(`[rt] joiner=${joiner.status} target=${target.status}`); + + const res = await joiner.send( + seed.peerB.pubkey, + "sent-by-newly-joined-peer", + "now", + ); + console.log("[rt] send result:", res); + + for (let i = 0; i < 30 && !received; i++) { + await new Promise((r) => setTimeout(r, 100)); + } + + joiner.close(); + target.close(); + + if (!res.ok) { + console.error("✗ FAIL: send did not ack"); + process.exit(1); + } + if (received !== "sent-by-newly-joined-peer") { + console.error(`✗ FAIL: receive mismatch: "${received}"`); + process.exit(1); + } + console.log("✓ join → connect → send → receive FLOW PASSED"); + process.exit(0); +} + +main().catch((e) => { + console.error("✗ FAIL:", e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/apps/cli/scripts/make-invite.ts b/apps/cli/scripts/make-invite.ts new file mode 100644 index 0000000..dcc2de1 --- /dev/null +++ b/apps/cli/scripts/make-invite.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +/** + * Build a test invite link from a seeded mesh (reads /tmp/cli-seed.json). + * Writes the link to stdout. + */ + +import { readFileSync } from "node:fs"; +import { encodeInviteLink } from "../src/invite/parse"; + +const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as { + meshId: string; +}; + +const link = encodeInviteLink({ + v: 1, + mesh_id: seed.meshId, + mesh_slug: "rt-join", + broker_url: process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws", + expires_at: Math.floor(Date.now() / 1000) + 3600, + mesh_root_key: "Y2xhdWRlbWVzaC10ZXN0LW1lc2gta2V5LWRldm9ubHk", + role: "member", +}); + +console.log(link); diff --git a/apps/cli/src/commands/join.ts b/apps/cli/src/commands/join.ts index 49132c4..b80552d 100644 --- a/apps/cli/src/commands/join.ts +++ b/apps/cli/src/commands/join.ts @@ -1,30 +1,90 @@ /** - * `claudemesh join ` — parse a mesh invite link and - * join the mesh. + * `claudemesh join ` — full join flow. * - * STUB: real invite-link parsing + keypair generation + broker - * enrollment lands in Step 17. For now this just validates the link - * shape and tells the user what's coming. + * 1. Parse + validate the ic://join/... link + * 2. Generate a fresh ed25519 keypair (libsodium) + * 3. POST /join to the broker → get member_id + * 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600) + * 5. Print success + * + * Signature verification + invite-token one-time-use land in Step 18. */ -export function runJoin(args: string[]): void { +import { parseInviteLink } from "../invite/parse"; +import { enrollWithBroker } from "../invite/enroll"; +import { generateKeypair } from "../crypto/keypair"; +import { loadConfig, saveConfig, getConfigPath } from "../state/config"; +import { hostname } from "node:os"; + +export async function runJoin(args: string[]): Promise { const link = args[0]; if (!link) { console.error("Usage: claudemesh join "); console.error(""); - console.error("Example: claudemesh join ic://join/BASE64URL..."); + console.error("Example: claudemesh join ic://join/eyJ2IjoxLC4uLn0"); process.exit(1); } - if (!link.startsWith("ic://join/")) { + + // 1. Parse. + let invite; + try { + invite = parseInviteLink(link); + } catch (e) { console.error( - `claudemesh: invalid invite link. Expected ic://join/... got "${link}"`, + `claudemesh: ${e instanceof Error ? e.message : String(e)}`, ); process.exit(1); } - console.log("claudemesh: join not yet implemented (Step 17)."); - console.log(` Invite link parsed: ${link.slice(0, 40)}...`); - console.log( - " Real flow will: verify sig, generate keypair, enroll member, persist to ~/.claudemesh/config.json", + const { payload } = invite; + console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`); + + // 2. Generate keypair. + const keypair = await generateKeypair(); + + // 3. Enroll with broker. + const displayName = `${hostname()}-${process.pid}`; + let enroll; + try { + enroll = await enrollWithBroker({ + brokerWsUrl: payload.broker_url, + meshId: payload.mesh_id, + peerPubkey: keypair.publicKey, + displayName, + role: payload.role, + }); + } catch (e) { + console.error( + `claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`, + ); + process.exit(1); + } + + // 4. Persist. + const config = loadConfig(); + config.meshes = config.meshes.filter( + (m) => m.slug !== payload.mesh_slug, ); - process.exit(0); + config.meshes.push({ + meshId: payload.mesh_id, + memberId: enroll.memberId, + slug: payload.mesh_slug, + name: payload.mesh_slug, + pubkey: keypair.publicKey, + secretKey: keypair.secretKey, + brokerUrl: payload.broker_url, + joinedAt: new Date().toISOString(), + }); + saveConfig(config); + + // 5. Report. + console.log(""); + console.log( + `✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`, + ); + console.log(` member id: ${enroll.memberId}`); + console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`); + console.log(` broker: ${payload.broker_url}`); + console.log(` config: ${getConfigPath()}`); + console.log(""); + console.log("Restart Claude Code to pick up the new mesh."); } diff --git a/apps/cli/src/crypto/keypair.ts b/apps/cli/src/crypto/keypair.ts new file mode 100644 index 0000000..3d614cd --- /dev/null +++ b/apps/cli/src/crypto/keypair.ts @@ -0,0 +1,36 @@ +/** + * Ed25519 keypair generation using libsodium. + * + * We use libsodium-wrappers even in Step 17 (pre-crypto) so the key + * format matches what Step 18's signing/encryption code will expect — + * no migration needed later. + */ + +import sodium from "libsodium-wrappers"; + +let ready = false; + +export async function ensureSodium(): Promise { + if (!ready) { + await sodium.ready; + ready = true; + } + return sodium; +} + +export interface Ed25519Keypair { + /** 32-byte public key, hex-encoded. */ + publicKey: string; + /** 64-byte secret key (seed || publicKey), hex-encoded. */ + secretKey: string; +} + +/** Generate a fresh ed25519 keypair. */ +export async function generateKeypair(): Promise { + const s = await ensureSodium(); + const kp = s.crypto_sign_keypair(); + return { + publicKey: s.to_hex(kp.publicKey), + secretKey: s.to_hex(kp.privateKey), + }; +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e47757b..9d9437c 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -48,7 +48,7 @@ async function main(): Promise { runInstall(); return; case "join": - runJoin(args); + await runJoin(args); return; case "list": runList(); diff --git a/apps/cli/src/invite/enroll.ts b/apps/cli/src/invite/enroll.ts new file mode 100644 index 0000000..5890839 --- /dev/null +++ b/apps/cli/src/invite/enroll.ts @@ -0,0 +1,56 @@ +/** + * Broker /join HTTP enrollment. + * + * Takes a parsed invite + freshly generated keypair, POSTs to the + * broker, returns the member_id. Converts the broker's WSS URL to + * HTTPS for the /join call (same host, different protocol). + */ + +export interface EnrollResult { + memberId: string; + alreadyMember: boolean; +} + +function wsToHttp(wsUrl: string): string { + // wss://host/ws → https://host + // ws://host:port/ws → http://host:port + const u = new URL(wsUrl); + const httpScheme = u.protocol === "wss:" ? "https:" : "http:"; + return `${httpScheme}//${u.host}`; +} + +export async function enrollWithBroker(args: { + brokerWsUrl: string; + meshId: string; + peerPubkey: string; + displayName: string; + role: "admin" | "member"; +}): Promise { + const base = wsToHttp(args.brokerWsUrl); + const res = await fetch(`${base}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mesh_id: args.meshId, + peer_pubkey: args.peerPubkey, + display_name: args.displayName, + role: args.role, + }), + signal: AbortSignal.timeout(10_000), + }); + const body = (await res.json()) as { + ok?: boolean; + memberId?: string; + error?: string; + alreadyMember?: boolean; + }; + if (!res.ok || !body.ok || !body.memberId) { + throw new Error( + `broker /join failed (${res.status}): ${body.error ?? "unknown"}`, + ); + } + return { + memberId: body.memberId, + alreadyMember: body.alreadyMember ?? false, + }; +} diff --git a/apps/cli/src/invite/parse.ts b/apps/cli/src/invite/parse.ts new file mode 100644 index 0000000..c1c3657 --- /dev/null +++ b/apps/cli/src/invite/parse.ts @@ -0,0 +1,81 @@ +/** + * Invite-link parser for claudemesh `ic://join/` links. + * + * v0.1.0: parses + shape-validates + checks expiry. Signature + * verification and one-time-use invite-token tracking land in Step 18. + */ + +import { z } from "zod"; + +const invitePayloadSchema = z.object({ + v: z.literal(1), + mesh_id: z.string().min(1), + mesh_slug: z.string().min(1), + broker_url: z.string().min(1), + expires_at: z.number().int().positive(), + mesh_root_key: z.string().min(1), + role: z.enum(["admin", "member"]), + signature: z.string().optional(), // ed25519 b64, validated in Step 18 +}); + +export type InvitePayload = z.infer; + +export interface ParsedInvite { + payload: InvitePayload; + raw: string; // the original ic://join/... string +} + +export function parseInviteLink(link: string): ParsedInvite { + if (!link.startsWith("ic://join/")) { + throw new Error( + `invalid invite link: expected prefix "ic://join/", got "${link.slice(0, 20)}…"`, + ); + } + const encoded = link.slice("ic://join/".length); + if (!encoded) throw new Error("invite link has no payload"); + + let json: string; + try { + json = Buffer.from(encoded, "base64url").toString("utf-8"); + } catch (e) { + throw new Error( + `invite link base64 decode failed: ${e instanceof Error ? e.message : e}`, + ); + } + + let obj: unknown; + try { + obj = JSON.parse(json); + } catch (e) { + throw new Error( + `invite link JSON parse failed: ${e instanceof Error ? e.message : e}`, + ); + } + + const parsed = invitePayloadSchema.safeParse(obj); + if (!parsed.success) { + throw new Error( + `invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`, + ); + } + + // Expiry check (unix seconds). + const nowSeconds = Math.floor(Date.now() / 1000); + if (parsed.data.expires_at < nowSeconds) { + throw new Error( + `invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`, + ); + } + + return { payload: parsed.data, raw: link }; +} + +/** + * Encode a payload back to an `ic://join/...` link. Used for testing + * + for building links server-side once we add that flow. + */ +export function encodeInviteLink(payload: InvitePayload): string { + const json = JSON.stringify(payload); + const encoded = Buffer.from(json, "utf-8").toString("base64url"); + return `ic://join/${encoded}`; +} diff --git a/apps/cli/src/state/config.ts b/apps/cli/src/state/config.ts index 8f34a82..96b0a1c 100644 --- a/apps/cli/src/state/config.ts +++ b/apps/cli/src/state/config.ts @@ -6,7 +6,13 @@ * and on every join/leave. */ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + chmodSync, +} from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { z } from "zod"; @@ -51,6 +57,12 @@ export function loadConfig(): Config { export function saveConfig(config: Config): void { mkdirSync(dirname(CONFIG_PATH), { recursive: true }); writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8"); + // Config holds ed25519 secret keys — restrict to owner read/write. + try { + chmodSync(CONFIG_PATH, 0o600); + } catch { + // Windows filesystems ignore chmod; that's fine. + } } export function getConfigPath(): string {