From c168081c7ef2b268bd2ea7eede51b94e4c416414 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 20:44:19 +0000 Subject: [PATCH] task/33: Relay admin panel at /api/admin/relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was built A full operator dashboard for the Timmy relay, served as server-side HTML from Express at GET /api/admin/relay — no build step, no separate frontend. Follows the existing ui.ts pattern with vanilla JS. ## New API endpoint GET /api/admin/relay/stats (added to admin-relay.ts): Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts } approvedToday counts events with decidedAt >= UTC midnight today. Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate. Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes). ## Admin panel (admin-relay-panel.ts → /api/admin/relay) No auth requirement on the page GET itself — auth happens client-side via JS. Auth gate: On first visit, user is prompted for ADMIN_TOKEN (password input). Token verified against GET /api/admin/relay/stats (401 = wrong token). Token stored in localStorage ('relay_admin_token'); loaded on boot. Logout clears localStorage and stops the 15s refresh timer. Token sent as Bearer Authorization header on every API call. Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), All-time queue (orange/accent). Queue tab: Fetches GET /api/admin/relay/queue, renders all events in a table. Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill, Queued timestamp, Approve/Reject action buttons (pending rows only). Auto-refreshes every 15 seconds alongside stats. Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject. Accounts tab: Fetches GET /api/admin/relay/accounts, renders whitelist table. Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke. Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm). Grant form at the bottom: pubkey input (64-char hex validation), access level select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant. Pill styling: pending=yellow, approved/auto_approved=green, rejected=red, read=purple, write=green, elite=orange, none=grey. Navigation links: ← Timmy UI, Workshop, Log out. ## Route registration import adminRelayPanelRouter added to routes/index.ts; router.use() registered between adminRelayQueueRouter and demoRouter. ## TypeScript: 0 errors. Smoke tests: - GET /api/admin/relay → 200 HTML with correct ✓ - GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓ - Auth gate renders correctly in browser ✓ --- .../src/routes/admin-relay-panel.ts | 782 ++++++++++++++++++ .../api-server/src/routes/admin-relay.ts | 44 +- artifacts/api-server/src/routes/index.ts | 2 + 3 files changed, 825 insertions(+), 3 deletions(-) create mode 100644 artifacts/api-server/src/routes/admin-relay-panel.ts diff --git a/artifacts/api-server/src/routes/admin-relay-panel.ts b/artifacts/api-server/src/routes/admin-relay-panel.ts new file mode 100644 index 0000000..3205eda --- /dev/null +++ b/artifacts/api-server/src/routes/admin-relay-panel.ts @@ -0,0 +1,782 @@ +/** + * admin-relay-panel.ts — Serves the relay admin dashboard HTML at /admin/relay. + * + * This is a self-contained vanilla-JS SPA served as inline HTML from Express. + * Auth gate: on first visit the user is prompted for ADMIN_TOKEN, which is + * stored in localStorage and sent as Bearer on every API call. + * + * Tabs: + * Queue — Pending events list with Approve / Reject; auto-refreshes every 15s + * Accounts — Whitelist table with Revoke; pubkey grant form + * + * Stats bar at top: pending, approved today, total accounts. + */ + +import { Router } from "express"; + +const router = Router(); + +router.get("/admin/relay", (_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.send(ADMIN_PANEL_HTML); +}); + +export default router; + +// ───────────────────────────────────────────────────────────────────────────── +// HTML is defined as a const so the file stays a valid TS module with no imports +// at runtime and no build step required. +// ───────────────────────────────────────────────────────────────────────────── + +const ADMIN_PANEL_HTML = `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"/> +<meta name="viewport" content="width=device-width, initial-scale=1.0"/> +<title>Relay Admin — Timmy Tower World + + + + + +
+

🔒 Admin Access

+

Enter the relay admin token to access the dashboard. It will be remembered in this browser.

+ + +
Incorrect token — please try again.
+
+ + +
+ +
+
+

⚡ Timmy Relay Admin OPERATOR

+
+ +
+ + +
+
+
+
Pending review
+
+
+
+
Approved today
+
+
+
+
Accounts
+
+
+
+
All-time queue
+
+
+ + +
+ + +
+ + +
+
+
+

Pending events

+ auto-refreshes every 15s +
+
+
+
+ + +
+
+
+

Relay accounts

+ whitelist +
+
+ +
+ + + + +
+
+
+ +
+ +
+ + + +`; diff --git a/artifacts/api-server/src/routes/admin-relay.ts b/artifacts/api-server/src/routes/admin-relay.ts index ff73892..4e33775 100644 --- a/artifacts/api-server/src/routes/admin-relay.ts +++ b/artifacts/api-server/src/routes/admin-relay.ts @@ -1,12 +1,13 @@ /** - * admin-relay.ts — Admin endpoints for the relay account whitelist. + * 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/accounts — list all relay accounts + * 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 */ @@ -14,7 +15,8 @@ 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 } from "@workspace/db"; +import { RELAY_ACCESS_LEVELS, type RelayAccessLevel, db, relayEventQueue, relayAccounts } from "@workspace/db"; +import { eq, gte, sql } from "drizzle-orm"; const logger = makeLogger("admin-relay"); const router = Router(); @@ -55,6 +57,42 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void { 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 [queueCounts, accountCount, approvedToday] = 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( + gte(relayEventQueue.decidedAt, todayStart), + ), + ]); + + 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, + }); +}); + // ── GET /admin/relay/accounts ───────────────────────────────────────────────── router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => { diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index ed68a82..0c19192 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -15,6 +15,7 @@ import estimateRouter from "./estimate.js"; import relayRouter from "./relay.js"; import adminRelayRouter from "./admin-relay.js"; import adminRelayQueueRouter from "./admin-relay-queue.js"; +import adminRelayPanelRouter from "./admin-relay-panel.js"; const router: IRouter = Router(); @@ -28,6 +29,7 @@ router.use(identityRouter); router.use(relayRouter); router.use(adminRelayRouter); router.use(adminRelayQueueRouter); +router.use(adminRelayPanelRouter); router.use(demoRouter); router.use(testkitRouter); router.use(uiRouter);