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:
56
apps/cli/src/invite/enroll.ts
Normal file
56
apps/cli/src/invite/enroll.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
81
apps/cli/src/invite/parse.ts
Normal file
81
apps/cli/src/invite/parse.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Invite-link parser for claudemesh `ic://join/<base64url(JSON)>` 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<typeof invitePayloadSchema>;
|
||||
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user