feat(cli): invite-link parsing + join flow + keypair generation

End-to-end join: user runs `claudemesh join ic://join/<base64>` 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/<base64url(JSON)> 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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:36:32 +01:00
parent 39b914bdce
commit 758ea0e42c
8 changed files with 400 additions and 16 deletions

View File

@@ -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<EnrollResult> {
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,
};
}