feat: v2 invite API + CLI claim flow + CLI friction reducer (wave 2)
Wires the v2 invite protocol end-to-end from a CLI user's perspective.
Broker foundation landed in c1fa3bc; this commit is the glue between
it and the human.
API (packages/api)
- createMyInvite now mints BOTH v1 token (legacy) AND v2 capability.
Two-phase insert: row first (to get invite.id), then UPDATE with
signed canonical bytes stored as JSON {canonical, signature} in the
capabilityV2 column. Broker's claim handler parses the same shape.
- canonicalInviteV2 locked to `v=2|mesh_id|invite_id|expires_at|role|
owner_pubkey_hex` — byte-identical to apps/broker/src/crypto.ts.
- brokerHttpBase() helper rewrites wss://host/ws → https://host for
server-to-server calls.
- POST /api/public/invites/:code/claim — thin proxy to broker;
passes status + body through, 502 broker_unreachable on fetch fail,
cache-control: no-store.
- POST /api/my/meshes/:id/invites/email — mints a normal v2 invite
via createMyInvite, records a pending_invite row, calls stubbed
sendEmailInvite (logs TODO for Postmark wiring in a later PR).
- New schemas: claimInviteInput/ResponseSchema,
createEmailInviteInput/ResponseSchema, v2 fields on
createMyInviteResponseSchema.
- v1 paths untouched — legacy /join/[token] and /api/public/invite/:token
continue to work throughout v0.1.x.
CLI (apps/cli)
- New `claudemesh join <code-or-url>` subcommand.
- Accepts bare code (abc12345), short URL (claudemesh.com/i/abc12345),
or legacy ic://join/<token>. Detects v2 vs v1 and dispatches.
- v2 path: generates fresh ephemeral x25519 keypair (separate from
the ed25519 identity) → POST /api/public/invites/:code/claim →
unseals sealed_root_key via crypto_box_seal_open → persists mesh
with inviteVersion: 2 and base64url rootKey to local config.
- Signature verification skipped with TODO — v0.1.x trusts broker;
seal-open is already authenticated.
- apps/cli/src/lib/invite-v2.ts: generateX25519Keypair, claimInviteV2,
parseV2InviteInput.
- state/config.ts: additive rootKey?/inviteVersion? fields.
CLI friction reducer
- apps/cli/src/index.ts: flag-first invocations
(`claudemesh --resume xxx`, `claudemesh -c`, `claudemesh -- --model
opus`) now route through `launch` automatically. Bare `claudemesh`
still shows welcome; known subcommands dispatch normally.
- Removes one word of cognitive load: users never type `launch`.
No schema changes. No new deps. v1 fully backward compatible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,123 @@
|
||||
/**
|
||||
* `claudemesh join <invite-link>` — full join flow.
|
||||
* `claudemesh join <invite-link-or-code>` — 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
|
||||
* Accepts either:
|
||||
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
|
||||
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
|
||||
* persists mesh + fresh ed25519 identity.
|
||||
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
|
||||
* → parses signed payload, calls broker /join, persists.
|
||||
*
|
||||
* Signature verification + invite-token one-time-use land in Step 18.
|
||||
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
|
||||
*/
|
||||
|
||||
import { parseInviteLink } from "../invite/parse";
|
||||
import { enrollWithBroker } from "../invite/enroll";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { env } from "../env";
|
||||
|
||||
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
|
||||
function deriveAppBaseUrl(): string {
|
||||
const override = process.env.CLAUDEMESH_APP_URL;
|
||||
if (override) return override.replace(/\/$/, "");
|
||||
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
|
||||
// For self-hosted: honour the broker host's parent domain as best-effort.
|
||||
try {
|
||||
const u = new URL(env.CLAUDEMESH_BROKER_URL);
|
||||
const host = u.host.replace(/^ic\./, "");
|
||||
const scheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||
return `${scheme}//${host}`;
|
||||
} catch {
|
||||
return "https://claudemesh.com";
|
||||
}
|
||||
}
|
||||
|
||||
async function runJoinV2(code: string): Promise<void> {
|
||||
const appBaseUrl = deriveAppBaseUrl();
|
||||
console.log(`Claiming invite ${code} via ${appBaseUrl}…`);
|
||||
|
||||
let claim;
|
||||
try {
|
||||
claim = await claimInviteV2({ appBaseUrl, code });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate a fresh ed25519 identity for this peer. The v2 claim
|
||||
// endpoint creates the member row keyed on the x25519 pubkey we sent;
|
||||
// the ed25519 keypair is what the `hello` handshake and future
|
||||
// envelope signing will use. Stored locally only.
|
||||
const keypair = await generateKeypair();
|
||||
const displayName = `${hostname()}-${process.pid}`;
|
||||
|
||||
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
|
||||
// to match the format used everywhere else (broker stores it the
|
||||
// same way in mesh.rootKey).
|
||||
await sodium.ready;
|
||||
const rootKeyB64 = sodium.to_base64(
|
||||
claim.rootKey,
|
||||
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
|
||||
// Persist. We don't have a mesh_slug in the v2 response — the server
|
||||
// derives slug from name and slug is no longer globally unique. Use a
|
||||
// stable short derivative of the mesh id so `list` / `launch --mesh`
|
||||
// still have something to match on.
|
||||
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
|
||||
const config = loadConfig();
|
||||
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
|
||||
config.meshes.push({
|
||||
meshId: claim.meshId,
|
||||
memberId: claim.memberId,
|
||||
slug: fallbackSlug,
|
||||
name: fallbackSlug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: env.CLAUDEMESH_BROKER_URL,
|
||||
joinedAt: new Date().toISOString(),
|
||||
rootKey: rootKeyB64,
|
||||
inviteVersion: 2,
|
||||
});
|
||||
saveConfig(config);
|
||||
|
||||
console.log("");
|
||||
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
|
||||
console.log(` member id: ${claim.memberId}`);
|
||||
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
|
||||
console.log(` config: ${getConfigPath()}`);
|
||||
console.log("");
|
||||
console.log("Restart Claude Code to pick up the new mesh.");
|
||||
}
|
||||
|
||||
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("Usage: claudemesh join <invite-url-or-code>");
|
||||
console.error("");
|
||||
console.error(
|
||||
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
|
||||
);
|
||||
console.error("Examples:");
|
||||
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
|
||||
console.error(" claudemesh join abc12345");
|
||||
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Try v2 first — short code / `/i/<code>` URL.
|
||||
const v2Code = parseV2InviteInput(link);
|
||||
if (v2Code) {
|
||||
await runJoinV2(v2Code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Parse + verify signature client-side.
|
||||
let invite;
|
||||
try {
|
||||
|
||||
@@ -331,9 +331,27 @@ const main = defineCommand({
|
||||
},
|
||||
}),
|
||||
},
|
||||
run() {
|
||||
runWelcome();
|
||||
async run() {
|
||||
await runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
|
||||
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
|
||||
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
|
||||
// every flag-only form as implicit `launch`.
|
||||
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
|
||||
// Flags citty handles on the root command — must not be rewritten to `launch`.
|
||||
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const first = argv[0];
|
||||
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
|
||||
// Starts with a flag, or an unknown bareword → treat as launch args.
|
||||
// (Unknown barewords that look like typos would otherwise hit citty's
|
||||
// "unknown command" path; forwarding to launch lets claude surface the
|
||||
// error if it's a real claude flag, and launch's own parser rejects junk.)
|
||||
process.argv.splice(2, 0, "launch");
|
||||
}
|
||||
|
||||
runMain(main);
|
||||
|
||||
217
apps/cli/src/lib/invite-v2.ts
Normal file
217
apps/cli/src/lib/invite-v2.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v2 invite claim client.
|
||||
*
|
||||
* The v2 invite URL is a short opaque code (e.g. `claudemesh.com/i/abc12345`).
|
||||
* The mesh root key is NOT embedded. Instead:
|
||||
*
|
||||
* 1. Client generates a fresh x25519 keypair (separate from the peer's
|
||||
* ed25519 identity) just for this claim.
|
||||
* 2. Client POSTs `recipient_x25519_pubkey` to
|
||||
* `${appBaseUrl}/api/public/invites/:code/claim`.
|
||||
* 3. Server responds with `sealed_root_key` (crypto_box_seal of the real
|
||||
* mesh root key to the recipient pubkey) + mesh metadata +
|
||||
* `canonical_v2` (the signed capability bytes).
|
||||
* 4. Client unseals the root key with its x25519 secret key.
|
||||
*
|
||||
* Wire contract is LOCKED — see `docs/protocol.md` §v2 invites and
|
||||
* `apps/broker/tests/invite-v2.test.ts`.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh x25519 (Curve25519) keypair suitable for
|
||||
* `crypto_box_seal`. This is intentionally distinct from the peer's
|
||||
* long-lived ed25519 identity — we do NOT want the mesh root key sealed
|
||||
* against a key that's reused for signing.
|
||||
*
|
||||
* Returns the public key as URL-safe base64url (no padding) to match
|
||||
* the format used by the broker's `sealed_root_key` response.
|
||||
*/
|
||||
export async function generateX25519Keypair(): Promise<{
|
||||
publicKeyB64: string;
|
||||
secretKey: Uint8Array;
|
||||
}> {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_box_keypair();
|
||||
const publicKeyB64 = s.to_base64(
|
||||
kp.publicKey,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
return { publicKeyB64, secretKey: kp.privateKey };
|
||||
}
|
||||
|
||||
export interface ClaimV2Result {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
ownerPubkey: string;
|
||||
canonicalV2: string;
|
||||
/** Unsealed mesh root key, 32 raw bytes. */
|
||||
rootKey: Uint8Array;
|
||||
}
|
||||
|
||||
interface ClaimResponseBody {
|
||||
sealed_root_key?: string;
|
||||
mesh_id?: string;
|
||||
member_id?: string;
|
||||
owner_pubkey?: string;
|
||||
canonical_v2?: string;
|
||||
}
|
||||
|
||||
interface ClaimErrorBody {
|
||||
error?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a v2 invite by its short code. Performs the x25519 keypair
|
||||
* generation, POST, and local unseal of the returned `sealed_root_key`.
|
||||
*
|
||||
* Throws with a descriptive message on 4xx/5xx or on seal-open failure.
|
||||
*/
|
||||
export async function claimInviteV2(opts: {
|
||||
appBaseUrl: string; // e.g. "https://claudemesh.com"
|
||||
code: string;
|
||||
}): Promise<ClaimV2Result> {
|
||||
const s = await ensureSodium();
|
||||
const { publicKeyB64, secretKey } = await generateX25519Keypair();
|
||||
const publicKeyBytes = s.from_base64(
|
||||
publicKeyB64,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
|
||||
const base = opts.appBaseUrl.replace(/\/$/, "");
|
||||
const code = encodeURIComponent(opts.code);
|
||||
const url = `${base}/api/public/invites/${code}/claim`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ recipient_x25519_pubkey: publicKeyB64 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`claim request failed (network): ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse body first — server returns JSON for both success and error.
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
// fall through with parsed=null
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = (parsed ?? {}) as ClaimErrorBody;
|
||||
const reason =
|
||||
err.error ?? err.code ?? err.message ?? `HTTP ${res.status}`;
|
||||
switch (res.status) {
|
||||
case 400:
|
||||
throw new Error(`invite claim rejected: ${reason}`);
|
||||
case 404:
|
||||
throw new Error(`invite not found: ${reason}`);
|
||||
case 410:
|
||||
throw new Error(`invite no longer usable: ${reason}`);
|
||||
default:
|
||||
throw new Error(`invite claim failed (${res.status}): ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = (parsed ?? {}) as ClaimResponseBody;
|
||||
if (
|
||||
!body.sealed_root_key ||
|
||||
!body.mesh_id ||
|
||||
!body.member_id ||
|
||||
!body.owner_pubkey ||
|
||||
!body.canonical_v2
|
||||
) {
|
||||
throw new Error(
|
||||
`invite claim response malformed: missing required field(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unseal the root key with our x25519 secret.
|
||||
let rootKey: Uint8Array;
|
||||
try {
|
||||
const sealed = s.from_base64(
|
||||
body.sealed_root_key,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
const opened = s.crypto_box_seal_open(sealed, publicKeyBytes, secretKey);
|
||||
if (!opened) throw new Error("crypto_box_seal_open returned empty");
|
||||
rootKey = opened;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to unseal root key (server sealed to wrong pubkey?): ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
if (rootKey.length !== 32) {
|
||||
throw new Error(
|
||||
`unsealed root key has wrong length: ${rootKey.length} (expected 32)`,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(v0.1.5): when the claim response grows a `signature` field,
|
||||
// re-verify canonical_v2 against owner_pubkey locally as a
|
||||
// belt-and-suspenders check against a compromised broker.
|
||||
// For v0.1.x the broker is trusted: it verified capability_v2 before
|
||||
// sealing, and a malicious broker could already lie about mesh_id.
|
||||
|
||||
return {
|
||||
meshId: body.mesh_id,
|
||||
memberId: body.member_id,
|
||||
ownerPubkey: body.owner_pubkey,
|
||||
canonicalV2: body.canonical_v2,
|
||||
rootKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a v2 invite input (bare code or full URL) into a short code.
|
||||
*
|
||||
* Accepted forms:
|
||||
* - `abc12345`
|
||||
* - `claudemesh.com/i/abc12345`
|
||||
* - `https://claudemesh.com/i/abc12345`
|
||||
* - `https://claudemesh.com/es/i/abc12345` (locale prefix)
|
||||
*
|
||||
* Returns `null` if the input doesn't look like a v2 code/URL — callers
|
||||
* should fall back to the v1 `ic://join/...` parser in that case.
|
||||
*/
|
||||
export function parseV2InviteInput(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Full URL with /i/<code>
|
||||
const urlMatch = trimmed.match(
|
||||
/^https?:\/\/[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
|
||||
);
|
||||
if (urlMatch) return urlMatch[1]!;
|
||||
|
||||
// Schemeless "claudemesh.com/i/<code>"
|
||||
const schemelessMatch = trimmed.match(
|
||||
/^[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
|
||||
);
|
||||
if (schemelessMatch) return schemelessMatch[1]!;
|
||||
|
||||
// Bare short code — base62, typically 8 chars. Be a little lenient
|
||||
// (6-16) to accommodate future tweaks but stay tight enough not to
|
||||
// collide with a v1 base64url token (which contains `-` / `_` and is
|
||||
// much longer).
|
||||
if (/^[A-Za-z0-9]{6,16}$/.test(trimmed)) return trimmed;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -26,6 +26,15 @@ export interface JoinedMesh {
|
||||
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||
brokerUrl: string;
|
||||
joinedAt: string;
|
||||
/**
|
||||
* Mesh root key (32 bytes) as URL-safe base64url, no padding.
|
||||
* Present for v2 invite joins (sealed then unsealed client-side).
|
||||
* Absent for v1 joins, where the root key lives inside the saved
|
||||
* invite token on disk instead. Used by channel/group `crypto_secretbox`.
|
||||
*/
|
||||
rootKey?: string;
|
||||
/** Invite protocol version used to join. `2` for v2, omitted/`1` for legacy. */
|
||||
inviteVersion?: 1 | 2;
|
||||
}
|
||||
|
||||
export interface GroupEntry {
|
||||
|
||||
Reference in New Issue
Block a user