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>
90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
/**
|
|
* Local persistent config — ~/.claudemesh/config.json
|
|
*
|
|
* Stores: joined meshes, per-mesh identity keys (ed25519 keypairs),
|
|
* last-seen broker URL. Loaded on CLI start, on MCP server start,
|
|
* and on every join/leave.
|
|
*/
|
|
|
|
import {
|
|
readFileSync,
|
|
writeFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
chmodSync,
|
|
} from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
import { env } from "../env";
|
|
|
|
export interface JoinedMesh {
|
|
meshId: string;
|
|
memberId: string;
|
|
slug: string;
|
|
name: string;
|
|
pubkey: string; // ed25519 hex (32 bytes = 64 chars)
|
|
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 {
|
|
name: string;
|
|
role?: string;
|
|
}
|
|
|
|
export interface Config {
|
|
version: 1;
|
|
meshes: JoinedMesh[];
|
|
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
|
role?: string; // per-session role tag (display + hello)
|
|
groups?: GroupEntry[];
|
|
messageMode?: "push" | "inbox" | "off";
|
|
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
|
}
|
|
|
|
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
|
|
export function loadConfig(): Config {
|
|
if (!existsSync(CONFIG_PATH)) {
|
|
return { version: 1, meshes: [] };
|
|
}
|
|
try {
|
|
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || !Array.isArray(parsed.meshes)) {
|
|
return { version: 1, meshes: [] };
|
|
}
|
|
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
|
} catch (e) {
|
|
throw new Error(
|
|
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function saveConfig(config: Config): void {
|
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
// Config holds ed25519 secret keys — restrict to owner read/write.
|
|
try {
|
|
chmodSync(CONFIG_PATH, 0o600);
|
|
} catch {
|
|
// Windows filesystems ignore chmod; that's fine.
|
|
}
|
|
}
|
|
|
|
export function getConfigPath(): string {
|
|
return CONFIG_PATH;
|
|
}
|