fix(cli+web): claudemesh rename via inline-JWT route (v1.19.1)
The /api/my/meshes/:slug PATCH route was never implemented and better-auth's enforceAuth middleware can't validate the CLI's device-code JWT (signed with CLI_SYNC_SECRET, not a better-auth session). Adds /api/cli/meshes/:slug on the web app — verifies the HS256 JWT inline, scopes the rename to (slug, ownerUserId). CLI now calls the new path. Mirrors the cli-sync-token pattern. Closes the "API error 401: Unauthorized" hit after a successful claudemesh login. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.19.0",
|
"version": "1.19.1",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ export async function createMesh(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renameMesh(token: string, slug: string, newName: string) {
|
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 }>({
|
return request<{ slug: string; name: string }>({
|
||||||
path: `/api/my/meshes/${slug}`,
|
path: `/api/cli/meshes/${slug}`,
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { name: newName },
|
body: { name: newName },
|
||||||
token,
|
token,
|
||||||
|
|||||||
111
apps/web/src/app/api/cli/meshes/[slug]/route.ts
Normal file
111
apps/web/src/app/api/cli/meshes/[slug]/route.ts
Normal file
@@ -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<CliJwtPayload | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -298,6 +298,17 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
|
|||||||
default returns last 30d). CLI: omitting `--mesh` on each
|
default returns last 30d). CLI: omitting `--mesh` on each
|
||||||
verb routes through the matching aggregator. *Shipped
|
verb routes through the matching aggregator. *Shipped
|
||||||
2026-05-03 in CLI v1.16.0.*
|
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** —
|
- **v0.6.0 — `claudemesh file share / get` + same-host fast path** —
|
||||||
CLI parity for the file-sharing surface that was already on the
|
CLI parity for the file-sharing surface that was already on the
|
||||||
broker side (HTTP `/upload`, WS `get_file` / `list_files`) but
|
broker side (HTTP `/upload`, WS `get_file` / `list_files`) but
|
||||||
|
|||||||
Reference in New Issue
Block a user