diff --git a/apps/cli/package.json b/apps/cli/package.json index e8aa02c..53fb361 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.1.7", + "version": "0.1.8", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/env.ts b/apps/cli/src/env.ts index 9dddba4..331343e 100644 --- a/apps/cli/src/env.ts +++ b/apps/cli/src/env.ts @@ -1,27 +1,23 @@ -import { z } from "zod"; - /** * CLI environment config. * * Read once at startup. Overridable via env vars so users can point * at a self-hosted broker or a staging instance without rebuilding. */ -const envSchema = z.object({ - CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"), - CLAUDEMESH_CONFIG_DIR: z.string().optional(), - CLAUDEMESH_DEBUG: z.coerce.boolean().default(false), -}); -export type CliEnv = z.infer; +export interface CliEnv { + CLAUDEMESH_BROKER_URL: string; + CLAUDEMESH_CONFIG_DIR: string | undefined; + CLAUDEMESH_DEBUG: boolean; +} export function loadEnv(): CliEnv { - const parsed = envSchema.safeParse(process.env); - if (!parsed.success) { - console.error("[claudemesh] invalid environment:"); - console.error(z.treeifyError(parsed.error)); - process.exit(1); - } - return parsed.data; + return { + CLAUDEMESH_BROKER_URL: + process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws", + CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined, + CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true", + }; } export const env = loadEnv(); diff --git a/apps/cli/src/invite/parse.ts b/apps/cli/src/invite/parse.ts index 583ad15..0e8945d 100644 --- a/apps/cli/src/invite/parse.ts +++ b/apps/cli/src/invite/parse.ts @@ -5,22 +5,19 @@ * verification and one-time-use invite-token tracking land in Step 18. */ -import { z } from "zod"; import { ensureSodium } from "../crypto/keypair"; -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"]), - owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i), - signature: z.string().regex(/^[0-9a-f]{128}$/i), -}); - -export type InvitePayload = z.infer; +export interface InvitePayload { + v: 1; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; + signature: string; +} export interface ParsedInvite { payload: InvitePayload; @@ -28,6 +25,21 @@ export interface ParsedInvite { token: string; // base64url(JSON) — DB lookup key (everything after ic://join/) } +function validatePayload(obj: unknown): InvitePayload { + if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object"); + const o = obj as Record; + if (o.v !== 1) throw new Error("invite payload: v must be 1"); + if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required"); + if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required"); + if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required"); + if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number"); + if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required"); + if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member"); + if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars"); + if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars"); + return o as unknown as InvitePayload; +} + /** Canonical invite bytes — must match broker's canonicalInvite(). */ export function canonicalInvite(p: { v: number; @@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise { ); } - 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("; ")}`, - ); - } + const payload = validatePayload(obj); // Expiry check (unix seconds). const nowSeconds = Math.floor(Date.now() / 1000); - if (parsed.data.expires_at < nowSeconds) { + if (payload.expires_at < nowSeconds) { throw new Error( - `invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`, + `invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`, ); } // Verify the ed25519 signature against the embedded owner_pubkey. - // Client-side verification gives immediate feedback on tampered - // links; broker re-verifies authoritatively on /join. const s = await ensureSodium(); const canonical = canonicalInvite({ - v: parsed.data.v, - mesh_id: parsed.data.mesh_id, - mesh_slug: parsed.data.mesh_slug, - broker_url: parsed.data.broker_url, - expires_at: parsed.data.expires_at, - mesh_root_key: parsed.data.mesh_root_key, - role: parsed.data.role, - owner_pubkey: parsed.data.owner_pubkey, + v: payload.v, + mesh_id: payload.mesh_id, + mesh_slug: payload.mesh_slug, + broker_url: payload.broker_url, + expires_at: payload.expires_at, + mesh_root_key: payload.mesh_root_key, + role: payload.role, + owner_pubkey: payload.owner_pubkey, }); const sigOk = (() => { try { return s.crypto_sign_verify_detached( - s.from_hex(parsed.data.signature), + s.from_hex(payload.signature), s.from_string(canonical), - s.from_hex(parsed.data.owner_pubkey), + s.from_hex(payload.owner_pubkey), ); } catch { return false; @@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise { throw new Error("invite signature invalid (link tampered?)"); } - return { payload: parsed.data, raw: link, token: encoded }; + return { payload, raw: link, token: encoded }; } /** @@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string { /** * Sign and assemble an invite payload → ic://join/... link. - * The canonical bytes (everything except signature) are signed with - * the mesh owner's ed25519 secret key. */ export async function buildSignedInvite(args: { v: 1; diff --git a/apps/cli/src/state/config.ts b/apps/cli/src/state/config.ts index 96b0a1c..8defe9f 100644 --- a/apps/cli/src/state/config.ts +++ b/apps/cli/src/state/config.ts @@ -15,38 +15,38 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; -import { z } from "zod"; import { env } from "../env"; -const joinedMeshSchema = z.object({ - meshId: z.string(), - memberId: z.string(), - slug: z.string(), - name: z.string(), - pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars) - secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars) - brokerUrl: z.string(), - joinedAt: z.string(), -}); +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; +} -const configSchema = z.object({ - version: z.literal(1).default(1), - meshes: z.array(joinedMeshSchema).default([]), -}); - -export type JoinedMesh = z.infer; -export type Config = z.infer; +export interface Config { + version: 1; + meshes: JoinedMesh[]; +} 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 configSchema.parse({ version: 1, meshes: [] }); + return { version: 1, meshes: [] }; } try { const raw = readFileSync(CONFIG_PATH, "utf-8"); - return configSchema.parse(JSON.parse(raw)); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.meshes)) { + return { version: 1, meshes: [] }; + } + return { version: 1, meshes: parsed.meshes }; } catch (e) { throw new Error( `Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,