diff --git a/apps/cli/package.json b/apps/cli/package.json index b3aefad..7a2e325 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.19.0", + "version": "1.19.1", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/services/api/my.ts b/apps/cli/src/services/api/my.ts index f93a04b..c6afc1a 100644 --- a/apps/cli/src/services/api/my.ts +++ b/apps/cli/src/services/api/my.ts @@ -20,8 +20,11 @@ export async function createMesh( } export async function renameMesh(token: string, slug: string, newName: string) { + // Routed through /api/cli/* (not /api/my/*) because the CLI JWT + // can't authenticate against the better-auth-protected myRouter. + // The /api/cli/meshes/:slug route validates the JWT inline. return request<{ slug: string; name: string }>({ - path: `/api/my/meshes/${slug}`, + path: `/api/cli/meshes/${slug}`, method: "PATCH", body: { name: newName }, token, diff --git a/apps/web/src/app/api/cli/meshes/[slug]/route.ts b/apps/web/src/app/api/cli/meshes/[slug]/route.ts new file mode 100644 index 0000000..a247cc8 --- /dev/null +++ b/apps/web/src/app/api/cli/meshes/[slug]/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { eq, and } from "drizzle-orm"; + +import { db } from "@turbostarter/db/server"; +import { mesh } from "@turbostarter/db/schema/mesh"; + +/** + * `PATCH /api/cli/meshes/:slug` — rename a mesh from the CLI. + * + * The `myRouter` (Hono) at `/api/my/meshes/*` runs `enforceAuth`, which + * calls `auth.api.getSession()` — that only honours better-auth cookies. + * The CLI's JWT (issued by `/api/auth/cli/device-code/[code]/approve`) + * is a custom HS256 token signed with `CLI_SYNC_SECRET`, so it can't + * authenticate against `/api/my/*`. Until better-auth gets a bearer + * plugin wired up, CLI-only mutations live under `/api/cli/*` and + * validate the JWT inline — same pattern as `/api/cli-sync-token`. + */ + +interface CliJwtPayload { + sub: string; + email?: string; + type?: string; + exp?: number; +} + +function base64UrlDecode(input: string): Uint8Array { + const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4)); + const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/"); + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +async function verifyCliJwt(token: string, secret: string): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [headerB64, payloadB64, sigB64] = parts; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + const ok = await crypto.subtle.verify( + "HMAC", + key, + base64UrlDecode(sigB64!), + encoder.encode(`${headerB64}.${payloadB64}`), + ); + if (!ok) return null; + try { + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64!))) as CliJwtPayload; + if (payload.exp && Date.now() / 1000 > payload.exp) return null; + return payload; + } catch { + return null; + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const auth = request.headers.get("authorization"); + if (!auth?.startsWith("Bearer ")) { + return NextResponse.json({ error: "missing bearer token" }, { status: 401 }); + } + const token = auth.slice("Bearer ".length).trim(); + const secret = process.env.CLI_SYNC_SECRET; + if (!secret) { + return NextResponse.json({ error: "server not configured" }, { status: 500 }); + } + const payload = await verifyCliJwt(token, secret); + if (!payload) { + return NextResponse.json({ error: "invalid or expired token" }, { status: 401 }); + } + + const { slug } = await params; + let body: { name?: string }; + try { + body = (await request.json()) as { name?: string }; + } catch { + return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); + } + const newName = body.name?.trim(); + if (!newName) { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + if (newName.length > 80) { + return NextResponse.json({ error: "name too long (max 80 chars)" }, { status: 400 }); + } + + // Only the owner can rename. The slug is the public identifier; we + // match (slug, ownerUserId) to scope the update. + const [updated] = await db + .update(mesh) + .set({ name: newName }) + .where(and(eq(mesh.slug, slug), eq(mesh.ownerUserId, payload.sub))) + .returning({ slug: mesh.slug, name: mesh.name }); + + if (!updated) { + return NextResponse.json( + { error: "mesh not found or you are not the owner" }, + { status: 404 }, + ); + } + return NextResponse.json(updated); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 899171c..977ccaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -298,6 +298,17 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. default returns last 30d). CLI: omitting `--mesh` on each verb routes through the matching aggregator. *Shipped 2026-05-03 in CLI v1.16.0.* +- **v0.6.1 — `claudemesh rename` actually works** — adds the + missing endpoint `PATCH /api/cli/meshes/:slug` on the web app. + Lives under `/api/cli/*` (not `/api/my/*`) because the CLI's + device-code JWT is signed with `CLI_SYNC_SECRET` and can't + authenticate against better-auth's `enforceAuth` middleware — + the new route validates the JWT inline using the same HMAC-SHA256 + pattern as `/api/cli-sync-token`. Owner-only (matches on + `mesh.slug` AND `mesh.ownerUserId`). CLI calls the new path + instead of the old `/api/my/meshes/:slug`. Closes the + "API error 401: Unauthorized" the user hit after a successful + `claudemesh login`. *Shipped 2026-05-03 in CLI v1.19.1 + web.* - **v0.6.0 — `claudemesh file share / get` + same-host fast path** — CLI parity for the file-sharing surface that was already on the broker side (HTTP `/upload`, WS `get_file` / `list_files`) but