Some checks failed
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>
106 lines
3.3 KiB
TypeScript
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.");
|
|
}
|