task/33: Relay admin panel at /api/admin/relay
## 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 <title> ✓
- GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓
- Auth gate renders correctly in browser ✓
This commit is contained in:
@@ -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<number>`count(*)::int` })
|
||||
.from(relayEventQueue)
|
||||
.groupBy(relayEventQueue.status),
|
||||
db.select({ count: sql<number>`count(*)::int` }).from(relayAccounts),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(relayEventQueue)
|
||||
.where(
|
||||
gte(relayEventQueue.decidedAt, todayStart),
|
||||
),
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user