Pre-launch fix: every visible surface already keyed on slug, so
"name" was a parallel string that only existed to confuse users
on rename ("I renamed but nothing visible changed").
Now slug IS the identifier. claudemesh rename <old> <new> is the
whole rename surface. PATCH /api/cli/meshes/:slug body becomes
{ slug } and the route writes both columns to keep them in sync.
Mesh create derives slug from input.name and stores name = slug.
Pickers drop the (parens). The claudemesh slug verb shipped 30
min ago is removed — merged into rename.
The mesh.name DB column stays for now to avoid touching ~25
reader sites; a follow-up migration drops it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { eq } 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: { slug?: string };
|
|
try {
|
|
body = (await request.json()) as { slug?: string };
|
|
} catch {
|
|
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
|
}
|
|
const newSlug = body.slug?.trim();
|
|
if (!newSlug) {
|
|
return NextResponse.json({ error: "slug is required" }, { status: 400 });
|
|
}
|
|
// Slug regex matches the CLI's pre-flight check. Lowercase only,
|
|
// must start with alnum, may contain hyphens, 2-32 chars total.
|
|
// Slugs are NOT globally unique (mesh.id is canonical) — see schema
|
|
// comment on mesh.slug — so we don't enforce a uniqueness collision
|
|
// here. Local CLI configs key on slug, so the picker collides
|
|
// locally; that's the user's call.
|
|
if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(newSlug)) {
|
|
return NextResponse.json(
|
|
{ error: "slug must be 2-32 chars, lowercase alnum + hyphens, start with alnum" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Look up the mesh first so we can distinguish "doesn't exist"
|
|
// (404) from "exists but you don't own it" (403). The CLI was
|
|
// collapsing both into a bare "API error 404" — unhelpful when
|
|
// the user has multiple accounts and signs in to the wrong one.
|
|
const [existing] = await db
|
|
.select({ ownerUserId: mesh.ownerUserId })
|
|
.from(mesh)
|
|
.where(eq(mesh.slug, slug))
|
|
.limit(1);
|
|
|
|
if (!existing) {
|
|
return NextResponse.json(
|
|
{ error: `mesh "${slug}" not found` },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
if (existing.ownerUserId !== payload.sub) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `you are signed in as a different account than the owner of "${slug}". Run \`claudemesh logout && claudemesh login\` and pick the owning account.`,
|
|
},
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
// Soft-collapse: name and slug are a single concept user-facing,
|
|
// so we always sync name = slug. mesh.name column stays for now
|
|
// (avoids touching ~25 reader sites); a future migration drops it.
|
|
const [updated] = await db
|
|
.update(mesh)
|
|
.set({ slug: newSlug, name: newSlug })
|
|
.where(eq(mesh.slug, slug))
|
|
.returning({ slug: mesh.slug });
|
|
|
|
return NextResponse.json(updated);
|
|
}
|