Files
claudemesh/apps/cli/src/commands/join.ts
Alejandro Gutiérrez af8f8ed1f9
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
Release / Publish multi-arch images (push) Has been cancelled
feat: v0.1.10 — per-session ephemeral keypairs
Each WS connection generates its own ed25519 keypair (sessionPubkey)
sent in the hello handshake. The broker stores it on the presence
row and uses it for message routing + list_peers. This gives every
`claudemesh launch` a unique crypto identity without burning invite
uses — member auth stays permanent, session identity is ephemeral.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:14:33 +01:00

106 lines
3.3 KiB
TypeScript

/**
* `claudemesh join <invite-link>` — full join flow.
*
* 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.
*/
import { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "../env";
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-url-or-token>");
console.error("");
console.error(
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
);
process.exit(1);
}
// 1. Parse + verify signature client-side.
let invite;
try {
invite = await parseInviteLink(link);
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
const { payload, token } = 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,
inviteToken: token,
invitePayload: payload,
peerPubkey: keypair.publicKey,
displayName,
});
} 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,
);
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);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
try {
mkdirSync(dirname(inviteFile), { recursive: true });
writeFileSync(inviteFile, link, "utf-8");
} catch {
// Non-fatal — launch will fall back to shared identity.
}
// 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.");
}