/** * admin-relay.ts — Admin endpoints for the relay account whitelist + stats. * * All routes are protected by ADMIN_SECRET env var (Bearer token). * If ADMIN_SECRET is not set, the routes reject all requests in production * and accept only from localhost in development. * * Routes: * GET /api/admin/relay/stats — event counts + account total * GET /api/admin/relay/accounts — list all relay accounts * POST /api/admin/relay/accounts/:pubkey/grant — grant access to a pubkey * POST /api/admin/relay/accounts/:pubkey/revoke — revoke access from a pubkey */ import { Router, type Request, type Response, type NextFunction } from "express"; import { makeLogger } from "../lib/logger.js"; import { relayAccountService } from "../lib/relay-accounts.js"; import { RELAY_ACCESS_LEVELS, type RelayAccessLevel, db, relayEventQueue, relayAccounts, nostrIdentities } from "@workspace/db"; import { and, eq, gte, inArray, sql } from "drizzle-orm"; const logger = makeLogger("admin-relay"); const router = Router(); // ADMIN_TOKEN is the canonical env var; ADMIN_SECRET is the backward-compat alias. const ADMIN_TOKEN = process.env["ADMIN_TOKEN"] ?? process.env["ADMIN_SECRET"] ?? ""; const IS_PROD = process.env["NODE_ENV"] === "production"; if (!ADMIN_TOKEN) { if (IS_PROD) { logger.error( "ADMIN_TOKEN is not set in production — admin relay routes are unprotected. " + "Set ADMIN_TOKEN in the API server environment immediately.", ); } else { logger.warn("ADMIN_TOKEN not set — admin relay routes accept local-only requests (dev mode)"); } } // ── Admin auth middleware ────────────────────────────────────────────────────── // Shared by all admin-relay*.ts routes. Token is matched against ADMIN_TOKEN // (env var), falling back to localhost-only in dev when no token is set. export function requireAdmin(req: Request, res: Response, next: NextFunction): void { if (ADMIN_TOKEN) { const authHeader = req.headers["authorization"] ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : ""; if (token !== ADMIN_TOKEN) { res.status(401).json({ error: "Unauthorized" }); return; } } else { const ip = req.ip ?? ""; const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; if (!isLocal) { logger.warn("admin-relay: no token configured, rejecting non-local call", { ip }); res.status(401).json({ error: "Unauthorized" }); return; } } next(); } // ── GET /admin/relay/stats ──────────────────────────────────────────────────── // Returns event counts by status for today + all-time pending, plus account total. router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Response) => { const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); const STRFRY_URL = process.env["STRFRY_URL"] ?? "http://strfry:7777"; const [queueCounts, accountCount, approvedToday, strfryStats] = await Promise.all([ db .select({ status: relayEventQueue.status, count: sql`count(*)::int` }) .from(relayEventQueue) .groupBy(relayEventQueue.status), db.select({ count: sql`count(*)::int` }).from(relayAccounts), db .select({ count: sql`count(*)::int` }) .from(relayEventQueue) .where( and( inArray(relayEventQueue.status, ["approved", "auto_approved"]), gte(relayEventQueue.decidedAt, todayStart), ), ), // Attempt to fetch live connection count from strfry's /stats endpoint. // Strfry exposes /stats in some builds; gracefully return null if unavailable. (async (): Promise<{ connections?: number } | null> => { try { const r = await fetch(`${STRFRY_URL}/stats`, { signal: AbortSignal.timeout(2000), }); if (!r.ok) return null; return (await r.json()) as { connections?: number }; } catch { return null; } })(), ]); const statusMap: Record = {}; for (const row of queueCounts) { statusMap[row.status] = row.count; } res.json({ pending: statusMap["pending"] ?? 0, approved: statusMap["approved"] ?? 0, autoApproved: statusMap["auto_approved"] ?? 0, rejected: statusMap["rejected"] ?? 0, approvedToday: approvedToday[0]?.count ?? 0, totalAccounts: accountCount[0]?.count ?? 0, liveConnections: strfryStats?.connections ?? null, }); }); // ── GET /admin/relay/accounts ───────────────────────────────────────────────── router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => { // LEFT JOIN nostr_identities to include trustTier per account. const rows = await db .select({ pubkey: relayAccounts.pubkey, accessLevel: relayAccounts.accessLevel, grantedBy: relayAccounts.grantedBy, notes: relayAccounts.notes, grantedAt: relayAccounts.grantedAt, revokedAt: relayAccounts.revokedAt, trustTier: nostrIdentities.tier, }) .from(relayAccounts) .leftJoin(nostrIdentities, eq(relayAccounts.pubkey, nostrIdentities.pubkey)) .orderBy(relayAccounts.grantedAt); res.json({ accounts: rows }); }); // ── POST /admin/relay/accounts/:pubkey/grant ────────────────────────────────── router.post( "/admin/relay/accounts/:pubkey/grant", requireAdmin, async (req: Request, res: Response) => { const { pubkey } = req.params as { pubkey: string }; if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) { res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" }); return; } const body = req.body as { level?: string; notes?: string }; const level = (body.level ?? "write").toLowerCase() as RelayAccessLevel; if (!RELAY_ACCESS_LEVELS.includes(level)) { res.status(400).json({ error: `Invalid access level '${level}'. Must be one of: ${RELAY_ACCESS_LEVELS.join(", ")}`, }); return; } const notes = typeof body.notes === "string" ? body.notes : "admin grant"; await relayAccountService.grant(pubkey, level, notes, "manual"); logger.info("admin granted relay access", { pubkey: pubkey.slice(0, 8), level, notes, }); res.json({ ok: true, pubkey, accessLevel: level, notes }); }, ); // ── POST /admin/relay/accounts/:pubkey/revoke ───────────────────────────────── router.post( "/admin/relay/accounts/:pubkey/revoke", requireAdmin, async (req: Request, res: Response) => { const { pubkey } = req.params as { pubkey: string }; if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) { res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" }); return; } const body = req.body as { reason?: string }; const reason = typeof body.reason === "string" ? body.reason : "admin revoke"; await relayAccountService.revoke(pubkey, reason); logger.info("admin revoked relay access", { pubkey: pubkey.slice(0, 8), reason, }); res.json({ ok: true, pubkey, revokedAt: new Date().toISOString(), reason }); }, ); export default router;