diff --git a/__tests__/api/admin/key.test.ts b/__tests__/api/admin/key.test.ts new file mode 100644 index 00000000..6b8e3efb --- /dev/null +++ b/__tests__/api/admin/key.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { GET } from "@/app/api/admin/key/route" + +describe("GET /api/admin/key", () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("returns 401 without a valid bearer token", async () => { + vi.stubEnv("ADMIN_API_KEY", "osk_test_admin_key") + + const res = await GET(new Request("http://localhost/api/admin/key")) + const body = await res.json() + + expect(res.status).toBe(401) + expect(body).toEqual({ ok: false, error: "Unauthorized" }) + }) + + it("returns the current server admin API key for authenticated requests", async () => { + vi.stubEnv("ADMIN_API_KEY", "osk_test_admin_key") + + const res = await GET(new Request("http://localhost/api/admin/key", { + headers: { Authorization: "Bearer osk_test_admin_key" }, + })) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ ok: true, key: "osk_test_admin_key" }) + expect(res.headers.get("Cache-Control")).toBe("no-store") + }) +}) diff --git a/app/api/admin/key/route.ts b/app/api/admin/key/route.ts new file mode 100644 index 00000000..52fbc561 --- /dev/null +++ b/app/api/admin/key/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server" +import { getAdminApiKey } from "@/lib/admin-api-key" +import { isAuthorized } from "@/lib/auth" + +export const dynamic = "force-dynamic" + +export async function GET(req: Request) { + if (!isAuthorized(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }) + } + + return NextResponse.json( + { ok: true, key: getAdminApiKey() }, + { headers: { "Cache-Control": "no-store" } }, + ) +} diff --git a/components/admin/admin-console.tsx b/components/admin/admin-console.tsx index f9a99e24..bc14b166 100644 --- a/components/admin/admin-console.tsx +++ b/components/admin/admin-console.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useMemo, useState, type ReactNode } from "react" -import { Activity, AlertTriangle, Check, Cloud, Code2, Copy, Cpu, Download, ExternalLink, Fingerprint, ReceiptText, History, KeyRound, Layers3, ListChecks, RadioTower, Rocket, Server, Shield, Terminal, Wallet } from "lucide-react" +import { Activity, AlertTriangle, Check, Cloud, Code2, Copy, Cpu, Download, ExternalLink, Eye, Fingerprint, ReceiptText, History, KeyRound, Layers3, ListChecks, RadioTower, Rocket, Server, Shield, Terminal, Wallet } from "lucide-react" import type { District, MoltbotAgent } from "@/lib/types" import { PassportPanel } from "@/components/admin/passport-panel" import { buildVercelDeployUrl } from "@/lib/vercel-deploy-url" @@ -64,7 +64,9 @@ export function AdminConsole({ agents, districts }: AdminConsoleProps) { const [copied, setCopied] = useState(false) const [selectedPlan, setSelectedPlan] = useState(plans[1]) const [tab, setTab] = useState("overview") - const [demoKey] = useState(() => generateAdminApiKey()) + const [adminKey, setAdminKey] = useState(null) + const [isKeyLoading, setIsKeyLoading] = useState(false) + const [keyError, setKeyError] = useState(null) const activeAgents = agents.filter((agent) => agent.status === "active" || agent.status === "working") const totalTasks = agents.reduce((sum, agent) => sum + agent.tasksCompleted, 0) @@ -87,9 +89,37 @@ export function AdminConsole({ agents, districts }: AdminConsoleProps) { } }) + const revealAdminKey = async () => { + if (adminKey || isKeyLoading) { + return + } + + setIsKeyLoading(true) + setKeyError(null) + + try { + const response = await fetch("/api/admin/key", { cache: "no-store" }) + const data = await response.json().catch(() => ({})) as { key?: string; error?: string } + + if (!response.ok || !data.key) { + throw new Error(data.error || "Failed to load admin API key") + } + + setAdminKey(data.key) + } catch (error) { + setKeyError(error instanceof Error ? error.message : "Failed to load admin API key") + } finally { + setIsKeyLoading(false) + } + } + const handleCopy = async () => { + if (!adminKey) { + return + } + try { - await navigator.clipboard.writeText(demoKey) + await navigator.clipboard.writeText(adminKey) setCopied(true) window.setTimeout(() => setCopied(false), 1600) } catch { @@ -133,16 +163,39 @@ export function AdminConsole({ agents, districts }: AdminConsoleProps) {

Issued key

-

{demoKey}

+ {isKeyLoading ? ( +
+ ) : ( + + )} + {keyError ?

{keyError}

: null} +
+
+ +
-
diff --git a/lib/auth.ts b/lib/auth.ts index f0172085..3745f2b3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,23 +1,15 @@ +import { getAdminApiKey } from "@/lib/admin-api-key" + /** * Authentication utility for Open Stellar API routes. - * Uses the MOLTBOT_GATEWAY_TOKEN for bearer authentication. + * Uses the server-side ADMIN_API_KEY value for bearer authentication. */ - export function isAuthorized(req: Request): boolean { - const token = process.env.MOLTBOT_GATEWAY_TOKEN - if (!token) { - // If no token is configured, we allow it in development but this should be set in prod. - // Based on the user's security concern, we should probably require it. - // However, looking at other routes (like cron), they allow if secret is missing. - // Given the "security concern" framing, I will require it if it's expected. - return false - } - const authHeader = req.headers.get("authorization") if (!authHeader || !authHeader.startsWith("Bearer ")) { return false } const bearerToken = authHeader.substring(7) - return bearerToken === token + return bearerToken === getAdminApiKey() }