Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions __tests__/api/admin/key.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
16 changes: 16 additions & 0 deletions app/api/admin/key/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
77 changes: 65 additions & 12 deletions components/admin/admin-console.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -64,7 +64,9 @@ export function AdminConsole({ agents, districts }: AdminConsoleProps) {
const [copied, setCopied] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<Plan>(plans[1])
const [tab, setTab] = useState<AdminTab>("overview")
const [demoKey] = useState(() => generateAdminApiKey())
const [adminKey, setAdminKey] = useState<string | null>(null)
const [isKeyLoading, setIsKeyLoading] = useState(false)
const [keyError, setKeyError] = useState<string | null>(null)

const activeAgents = agents.filter((agent) => agent.status === "active" || agent.status === "working")
const totalTasks = agents.reduce((sum, agent) => sum + agent.tasksCompleted, 0)
Expand All @@ -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 {
Expand Down Expand Up @@ -133,16 +163,39 @@ export function AdminConsole({ agents, districts }: AdminConsoleProps) {
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[10px] uppercase tracking-[0.32em] text-slate-500">Issued key</p>
<p className="mt-3 font-mono text-sm text-cyan-200 sm:text-base">{demoKey}</p>
{isKeyLoading ? (
<div className="mt-3 h-6 w-64 animate-pulse rounded bg-slate-800" aria-label="Loading admin API key" />
) : (
<input
readOnly
type={adminKey ? "text" : "password"}
value={adminKey ?? "osk_hidden_until_reveal"}
className="mt-3 w-full max-w-md rounded-lg border border-slate-800 bg-slate-900/70 px-3 py-2 font-mono text-sm text-cyan-200 outline-none sm:text-base"
aria-label="Admin API key"
/>
)}
{keyError ? <p className="mt-2 text-xs text-rose-300">{keyError}</p> : null}
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={revealAdminKey}
disabled={Boolean(adminKey) || isKeyLoading}
className="inline-flex items-center gap-2 rounded-full border border-slate-700 bg-slate-900 px-3 py-2 text-xs uppercase tracking-[0.2em] text-slate-200 transition hover:border-cyan-400/50 hover:text-cyan-200 disabled:cursor-not-allowed disabled:opacity-60"
>
<Eye className="h-3.5 w-3.5" />
{adminKey ? "Revealed" : "Reveal"}
</button>
<button
type="button"
onClick={handleCopy}
disabled={!adminKey}
className="inline-flex items-center gap-2 rounded-full border border-slate-700 bg-slate-900 px-3 py-2 text-xs uppercase tracking-[0.2em] text-slate-200 transition hover:border-cyan-400/50 hover:text-cyan-200 disabled:cursor-not-allowed disabled:opacity-60"
>
<Copy className="h-3.5 w-3.5" />
{copied ? "Copied" : "Copy"}
</button>
</div>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-full border border-slate-700 bg-slate-900 px-3 py-2 text-xs uppercase tracking-[0.2em] text-slate-200 transition hover:border-cyan-400/50 hover:text-cyan-200"
>
<Copy className="h-3.5 w-3.5" />
{copied ? "Copied" : "Copy"}
</button>
</div>

<div className="mt-5 space-y-3">
Expand Down
16 changes: 4 additions & 12 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading