diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index da97493..f45dc6b 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -669,6 +669,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { return; } + if (req.method === "POST" && req.url === "/cli/token") { + handleCliTokenGenerate(req, res, started); + return; + } + // Telegram connect token (rate-limited: 10 requests/hour per IP) if (req.method === "POST" && req.url === "/tg/token") { const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown"; @@ -4809,6 +4814,52 @@ async function handleCliSessionsList(req: IncomingMessage, res: ServerResponse, } } +/** POST /cli/token — generate a CLI token for paste-based auth. */ +async function handleCliTokenGenerate(req: IncomingMessage, res: ServerResponse, started: number): Promise { + let body: { user_id: string; email: string; name?: string; hostname?: string; platform?: string; arch?: string }; + try { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body; + } catch { + writeJson(res, 400, { error: "Invalid body" }); + return; + } + + if (!body.user_id || !body.email) { + writeJson(res, 400, { error: "user_id and email required" }); + return; + } + + try { + const now = Math.floor(Date.now() / 1000); + const token = await signCliJwt({ + sub: body.user_id, + email: body.email, + name: body.name, + type: "cli-token", + jti: crypto.randomUUID(), + iat: now, + exp: now + 30 * 24 * 60 * 60, + }); + + // Record session + await db.insert(cliSessionTable).values({ + userId: body.user_id, + hostname: body.hostname ?? "paste-token", + platform: body.platform ?? "unknown", + arch: body.arch ?? "unknown", + tokenHash: await hashToken(token), + }); + + writeJson(res, 200, { token }); + log.info("cli-token", { route: "POST /cli/token", user_id: body.user_id, latency_ms: Date.now() - started }); + } catch (e) { + log.error("cli-token", { error: e instanceof Error ? e.message : String(e) }); + writeJson(res, 500, { error: "Failed to generate token" }); + } +} + // --------------------------------------------------------------------------- // Skip starting the HTTP/WS server when running under vitest — tests import diff --git a/apps/web/src/app/[locale]/token/page.tsx b/apps/web/src/app/[locale]/token/page.tsx new file mode 100644 index 0000000..4ec63e9 --- /dev/null +++ b/apps/web/src/app/[locale]/token/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from "next/navigation"; +import { getSession } from "~/lib/auth/server"; +import { getMetadata } from "~/lib/metadata"; +import { TokenGenerator } from "./token-generator"; + +export const generateMetadata = getMetadata({ + title: "CLI Token", + description: "Generate a token to sign in to claudemesh CLI.", +}); + +export default async function TokenPage() { + const { user } = await getSession(); + + if (!user) { + return redirect(`/auth/login?redirectTo=${encodeURIComponent("/token")}`); + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/[locale]/token/token-generator.tsx b/apps/web/src/app/[locale]/token/token-generator.tsx new file mode 100644 index 0000000..ea43daf --- /dev/null +++ b/apps/web/src/app/[locale]/token/token-generator.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; + +interface Props { + userId: string; + userEmail: string; + userName: string; +} + +const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_HTTP_URL || "https://ic.claudemesh.com"; + +export function TokenGenerator({ userId, userEmail, userName }: Props) { + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(""); + + async function generate() { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/auth/cli/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: "Failed" })); + setError((body as { error?: string }).error ?? "Failed to generate token"); + return; + } + const { token: t } = (await res.json()) as { token: string }; + setToken(t); + } catch (e) { + setError(e instanceof Error ? e.message : "Network error"); + } finally { + setLoading(false); + } + } + + async function copyToken() { + if (!token) return; + try { + await navigator.clipboard.writeText(token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: select the text + const el = document.getElementById("cli-token"); + if (el) { + const range = document.createRange(); + range.selectNodeContents(el); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + } + } + } + + const btnBase = "w-full flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-[15px] font-medium transition-all"; + + return ( +
+ {/* Header */} +
+
+ + + + + + + +
+

CLI Token

+

+ Generate a token to sign in to claudemesh CLI. +
+ Paste it in your terminal when prompted. +

+
+ + {/* Signed in as */} +
+ Signed in as {userName} +
+ + {!token ? ( + <> + + {error &&

{error}

} + + ) : ( +
+ {/* Token display */} +
+
+              {token}
+            
+
+ + {/* Copy button */} + + + {/* Instructions */} +
+

Paste in your terminal:

+ + claudemesh login → option 3 → paste + +
+ + {/* Security note */} +

+ This token grants CLI access to your account. Don't share it. +
+ Valid for 30 days. Revoke anytime from Dashboard → Settings. +

+ + {/* Generate another */} + +
+ )} +
+ ); +} diff --git a/apps/web/src/app/api/auth/cli/token/route.ts b/apps/web/src/app/api/auth/cli/token/route.ts new file mode 100644 index 0000000..d2b8365 --- /dev/null +++ b/apps/web/src/app/api/auth/cli/token/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@turbostarter/auth/server"; + +const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, ""); + +export async function POST() { + const reqHeaders = new Headers(await headers()); + reqHeaders.set("x-client-platform", "web-server"); + const session = await auth.api.getSession({ headers: reqHeaders }); + + if (!session?.user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const brokerRes = await fetch(`${BROKER_URL}/cli/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user_id: session.user.id, + email: session.user.email, + name: session.user.name, + }), + }); + + const brokerBody = await brokerRes.json().catch(() => ({ error: "Broker error" })); + return NextResponse.json(brokerBody as Record, { status: brokerRes.status }); +}