From fb7a84aed60cc09ca2362310005256572cece104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:35:21 +0100 Subject: [PATCH] feat: v2 invite API + CLI claim flow + CLI friction reducer (wave 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` subcommand. - Accepts bare code (abc12345), short URL (claudemesh.com/i/abc12345), or legacy ic://join/. 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) --- apps/cli/src/commands/join.ts | 110 +++++++++-- apps/cli/src/index.ts | 22 ++- apps/cli/src/lib/invite-v2.ts | 217 +++++++++++++++++++++ apps/cli/src/state/config.ts | 9 + packages/api/src/modules/mesh/mutations.ts | 175 ++++++++++++++++- packages/api/src/modules/mesh/router.ts | 27 +++ packages/api/src/modules/public/router.ts | 58 ++++++ packages/api/src/schema/mesh-user.ts | 47 +++++ 8 files changed, 651 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/lib/invite-v2.ts diff --git a/apps/cli/src/commands/join.ts b/apps/cli/src/commands/join.ts index 96ebec5..f51e67a 100644 --- a/apps/cli/src/commands/join.ts +++ b/apps/cli/src/commands/join.ts @@ -1,35 +1,123 @@ /** - * `claudemesh join ` — full join flow. + * `claudemesh join ` — 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/` or bare `` + * → POSTs to /api/public/invites/:code/claim, unseals root_key, + * persists mesh + fresh ed25519 identity. + * - v1 legacy invite: `ic://join/` or `https://.../join/` + * → 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 { + 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 { const link = args[0]; if (!link) { - console.error("Usage: claudemesh join "); + console.error("Usage: claudemesh join "); 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/` URL. + const v2Code = parseV2InviteInput(link); + if (v2Code) { + await runJoinV2(v2Code); + return; + } + // 1. Parse + verify signature client-side. let invite; try { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5718fa0..aa3a293 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 ` (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); diff --git a/apps/cli/src/lib/invite-v2.ts b/apps/cli/src/lib/invite-v2.ts new file mode 100644 index 0000000..4094504 --- /dev/null +++ b/apps/cli/src/lib/invite-v2.ts @@ -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 { + 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 { + 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/ + const urlMatch = trimmed.match( + /^https?:\/\/[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/, + ); + if (urlMatch) return urlMatch[1]!; + + // Schemeless "claudemesh.com/i/" + 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; +} diff --git a/apps/cli/src/state/config.ts b/apps/cli/src/state/config.ts index 1f94842..e6d1409 100644 --- a/apps/cli/src/state/config.ts +++ b/apps/cli/src/state/config.ts @@ -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 { diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index a80e50b..546fedf 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -3,10 +3,11 @@ import { randomBytes } from "node:crypto"; import sodium from "libsodium-wrappers"; import { and, eq, isNull } from "@turbostarter/db"; -import { invite, mesh, meshMember } from "@turbostarter/db/schema"; +import { invite, mesh, meshMember, pendingInvite } from "@turbostarter/db/schema"; import { db } from "@turbostarter/db/server"; import type { + CreateEmailInviteInput, CreateMyInviteInput, CreateMyMeshInput, } from "../../schema"; @@ -32,6 +33,40 @@ const canonicalInvite = (p: { }): string => `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`; +/** + * v2 canonical invite bytes — format is LOCKED and MUST match + * `canonicalInviteV2` in apps/broker/src/crypto.ts exactly. The broker + * recomputes this on every claim and compares byte-for-byte against the + * signed `capabilityV2.canonical` stored on the invite row. Any drift + * between this string and the broker's version produces `bad_signature`. + * + * No root_key and no broker_url: the v2 protocol moves the root_key out + * of the URL and the broker is the authority for where the key lives. + */ +const canonicalInviteV2 = (p: { + mesh_id: string; + invite_id: string; + expires_at: number; // unix seconds + role: "admin" | "member"; + owner_pubkey: string; // hex +}): string => + `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`; + +/** + * Derive the broker's HTTP base URL from the configured WebSocket URL. + * `wss://host/ws` → `https://host`, `ws://host/ws` → `http://host`. + * The claim endpoint lives at `${base}/invites/:code/claim`. + */ +export const brokerHttpBase = (): string => { + const wsUrl = BROKER_URL; + const httpUrl = wsUrl + .replace(/^wss:\/\//, "https://") + .replace(/^ws:\/\//, "http://") + .replace(/\/ws\/?$/, "") + .replace(/\/$/, ""); + return httpUrl; +}; + let sodiumReady = false; const ensureSodium = async (): Promise => { if (!sodiumReady) { @@ -260,6 +295,10 @@ export const createMyInvite = async ({ role: input.role, expiresAt, createdBy: userId, + // v2 starts here — capabilityV2 is backfilled below in a second + // UPDATE because the canonical bytes depend on invite.id which + // we only know post-insert. + version: 2, }) .returning({ id: invite.id, @@ -282,6 +321,34 @@ export const createMyInvite = async ({ throw new Error("Could not allocate a unique invite code — retry."); } + // --- v2 capability: sign canonical bytes that include the invite id --- + // The broker recomputes these exact bytes on claim and verifies the + // signature against mesh.ownerPubkey. Stored shape is the JSON literal + // the broker expects in `invite.capabilityV2`: + // { "canonical": "v=2|...", "signature": "" } + // We reuse the existing `capabilityV2` text column — no schema change. + const canonicalV2 = canonicalInviteV2({ + mesh_id: meshRow.id, + invite_id: created.id, + expires_at: expiresAtSec, + role: input.role, + owner_pubkey: meshRow.ownerPubkey, + }); + const signatureV2 = s.to_hex( + s.crypto_sign_detached( + s.from_string(canonicalV2), + s.from_hex(meshRow.ownerSecretKey), + ), + ); + const capabilityV2Json = JSON.stringify({ + canonical: canonicalV2, + signature: signatureV2, + }); + await db + .update(invite) + .set({ capabilityV2: capabilityV2Json }) + .where(eq(invite.id, created.id)); + const appBase = APP_URL.replace(/\/$/, ""); return { id: created.id, @@ -294,5 +361,111 @@ export const createMyInvite = async ({ // Prefer this when sharing. See spec for why this is NOT a capability // boundary (the long token still carries the root_key). shortUrl: created.code ? `${appBase}/i/${created.code}` : null, + // v2 surface: safe to share (no root_key, no secrets). + version: 2 as const, + canonicalV2, + ownerPubkey: meshRow.ownerPubkey, }; }; + +// --------------------------------------------------------------------- +// Email invites (v2 only) +// --------------------------------------------------------------------- + +/** + * Send a mesh invite by email. Mints a normal v2 invite (same short code + * path as `createMyInvite`), then records a `pending_invite` row tying + * `(mesh, email)` to the underlying invite code. Delivery goes through + * the email provider if one is wired; otherwise we log a TODO and + * return success so the rest of the flow is testable end-to-end. + * + * The email body contains `${APP_URL}/i/${code}` — the exact same short + * URL that link-shares use. No new user-visible surface. + */ +export const createEmailInvite = async ({ + userId, + meshId, + input, +}: { + userId: string; + meshId: string; + input: CreateEmailInviteInput; +}) => { + // Reuse createMyInvite — all authz, signing, and short-code collision + // logic lives there. We only add the pending_invite row + email send. + const minted = await createMyInvite({ + userId, + meshId, + input: { + role: input.role, + maxUses: input.maxUses, + expiresInDays: input.expiresInDays, + }, + }); + + if (!minted.code) { + // Should never happen — createMyInvite always allocates a code now. + throw new Error("Could not mint an email invite (no short code)."); + } + + const [pending] = await db + .insert(pendingInvite) + .values({ + meshId, + email: input.email, + code: minted.code, + createdBy: userId, + }) + .returning({ id: pendingInvite.id }); + + if (!pending) { + throw new Error("Could not record pending invite row."); + } + + const appBase = APP_URL.replace(/\/$/, ""); + const shortUrl = `${appBase}/i/${minted.code}`; + + // Fire-and-forget-ish send. Failures are logged but do NOT roll back + // the invite — the admin can copy the short URL from the dashboard. + await sendEmailInvite({ + to: input.email, + shortUrl, + inviterUserId: userId, + meshId, + }); + + return { + pendingInviteId: pending.id, + code: minted.code, + email: input.email, + shortUrl, + expiresAt: minted.expiresAt, + }; +}; + +/** + * Deliver the email that carries a `claudemesh.com/i/{code}` short URL. + * + * TODO: wire this to the turbostarter Postmark provider. The email + * package exposes `sendEmail` via a template system; adding a new + * template file lives in `packages/email/**` which is out of scope for + * this wave. For now we log the intended send so the upstream mutation + * resolves cleanly and the rest of the flow is integration-testable. + */ +const sendEmailInvite = async (params: { + to: string; + shortUrl: string; + inviterUserId: string; + meshId: string; +}): Promise => { + // eslint-disable-next-line no-console + console.warn( + "[claudemesh] TODO: wire email invite to Postmark provider", + { + to: params.to, + shortUrl: params.shortUrl, + inviterUserId: params.inviterUserId, + meshId: params.meshId, + }, + ); +}; diff --git a/packages/api/src/modules/mesh/router.ts b/packages/api/src/modules/mesh/router.ts index 354e3eb..7c0a752 100644 --- a/packages/api/src/modules/mesh/router.ts +++ b/packages/api/src/modules/mesh/router.ts @@ -4,6 +4,7 @@ import type { User } from "@turbostarter/auth"; import { enforceAuth, validate } from "../../middleware"; import { + createEmailInviteInputSchema, createMyInviteInputSchema, createMyMeshInputSchema, getMyMeshesInputSchema, @@ -11,6 +12,7 @@ import { import { archiveMyMesh, + createEmailInvite, createMyInvite, createMyMesh, leaveMyMesh, @@ -89,6 +91,31 @@ export const myRouter = new Hono() } }, ) + .post( + "/meshes/:id/invites/email", + validate("json", createEmailInviteInputSchema), + async (c) => { + const user = c.var.user; + try { + const result = await createEmailInvite({ + userId: user.id, + meshId: c.req.param("id"), + input: c.req.valid("json"), + }); + return c.json(result); + } catch (e) { + return c.json( + { + error: + e instanceof Error + ? e.message + : "Failed to send email invite.", + }, + 400, + ); + } + }, + ) .post("/meshes/:id/archive", async (c) => { const user = c.var.user; try { diff --git a/packages/api/src/modules/public/router.ts b/packages/api/src/modules/public/router.ts index 71f6843..6d8b5fd 100644 --- a/packages/api/src/modules/public/router.ts +++ b/packages/api/src/modules/public/router.ts @@ -12,6 +12,10 @@ import { } from "@turbostarter/db/schema"; import { db } from "@turbostarter/db/server"; +import { validate } from "../../middleware"; +import { claimInviteInputSchema } from "../../schema"; +import { brokerHttpBase } from "../mesh/mutations"; + /** * Unauthed public stats for the landing page counter. * @@ -255,6 +259,60 @@ export const publicRouter = new Hono() } return c.json({ found: true as const, token: row.token }); }) + /** + * v2 invite claim — proxies straight to the broker. + * + * The broker owns all claim logic (signature verification, atomic + * used_count increment, crypto_box_seal of the root key to the + * recipient pubkey). The API layer only forwards the request and + * mirrors the broker's status + body so CLI/web clients can speak + * a single contract regardless of which host serves the claim. + * + * Error codes are the broker's: 400 malformed|bad_signature, + * 404 not_found, 410 expired|revoked|exhausted. + */ + .post( + "/invites/:code/claim", + validate("json", claimInviteInputSchema), + async (c) => { + c.header("cache-control", "no-store"); + const code = c.req.param("code"); + const body = c.req.valid("json"); + const url = `${brokerHttpBase()}/invites/${encodeURIComponent(code)}/claim`; + try { + const resp = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + recipient_x25519_pubkey: body.recipient_x25519_pubkey, + }), + }); + // Pass through status and body verbatim; broker already shapes + // the error envelope the way the spec documents. + const text = await resp.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: "upstream_malformed" }; + } + // Hono's c.json only accepts a subset of status codes; cast + // through ContentfulStatusCode for the passthrough. + return c.json( + parsed as Record, + resp.status as 200 | 400 | 404 | 410 | 500, + ); + } catch (e) { + return c.json( + { + error: "broker_unreachable", + detail: e instanceof Error ? e.message : String(e), + }, + 502, + ); + } + }, + ) .get("/stats", async (c) => { const now = Date.now(); if (cachedStats && cachedStats.expiresAt > now) { diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts index be8461f..5f14a0a 100644 --- a/packages/api/src/schema/mesh-user.ts +++ b/packages/api/src/schema/mesh-user.ts @@ -130,9 +130,56 @@ export const createMyInviteResponseSchema = z.object({ joinUrl: z.string(), shortUrl: z.string().nullable(), expiresAt: z.coerce.date(), + // v2 fields — present on every new invite. v1-only rows will return + // these as undefined on the legacy list endpoint; new rows always set + // them because createMyInvite now mints v2 capabilities by default. + version: z.literal(2).optional(), + canonicalV2: z.string().optional(), + ownerPubkey: z.string().optional(), }); export type CreateMyInviteResponse = z.infer; +// --------------------------------------------------------------------- +// Email invites +// --------------------------------------------------------------------- + +export const createEmailInviteInputSchema = z.object({ + email: z.string().email(), + role: meshRoleEnum.default("member"), + maxUses: z.number().int().min(1).max(1000).default(1), + expiresInDays: z.number().int().min(1).max(365).default(7), +}); +export type CreateEmailInviteInput = z.infer; + +export const createEmailInviteResponseSchema = z.object({ + pendingInviteId: z.string(), + code: z.string(), + email: z.string(), + shortUrl: z.string(), + expiresAt: z.coerce.date(), +}); +export type CreateEmailInviteResponse = z.infer< + typeof createEmailInviteResponseSchema +>; + +// --------------------------------------------------------------------- +// v2 invite claim (public, proxies to broker) +// --------------------------------------------------------------------- + +export const claimInviteInputSchema = z.object({ + recipient_x25519_pubkey: z.string().min(32), +}); +export type ClaimInviteInput = z.infer; + +export const claimInviteResponseSchema = z.object({ + sealed_root_key: z.string(), + mesh_id: z.string(), + member_id: z.string(), + owner_pubkey: z.string(), + canonical_v2: z.string(), +}); +export type ClaimInviteResponse = z.infer; + // --------------------------------------------------------------------- // List my invites (pending + sent) // ---------------------------------------------------------------------