task/33: Relay admin panel at /admin/relay (post-review fixes)

## What was built
Relay operator dashboard at GET /admin/relay (clean URL, not under /api).
Served as inline vanilla-JS HTML from Express, no build step.

## Routing
admin-relay-panel.ts imported in app.ts and mounted directly via app.use()
BEFORE the /tower static middleware — so /admin/relay is the canonical URL.
Removed from routes/index.ts to avoid /api/admin/relay duplication.

## Auth (env var aligned: ADMIN_TOKEN)
- Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET
  for backward compatibility. requireAdmin exported for reuse in queue router.
- admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts
- Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token',
  token stored after successful /api/admin/relay/stats 401 probe.

## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes:
1. approvedToday: now filters AND(status IN ('approved','auto_approved'),
   decidedAt >= UTC midnight today). Previously counted all statuses.
2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout.
   Returns null gracefully when strfry is unavailable (dev/non-Docker).
3. Drizzle imports updated: and(), inArray() added.

## Queue endpoint: contentPreview added
GET /api/admin/relay/queue response now includes contentPreview (string|null):
  JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure.

## Admin panel features
Stats bar (4 metric cards): Pending review (yellow), Approved today (green),
Accounts (purple), Relay connections (blue — null → "n/a" in UI).

Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec).
Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions.
Approve/Reject buttons; 15s auto-refresh; toast feedback.

Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form
(pubkey + access level + notes, 64-char hex validation before POST).

Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer.

## Smoke tests (all pass, TypeScript 0 errors)
GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓
GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓
Queue ?status=pending filter ✓; contentPreview in queue response ✓
This commit is contained in:
alexpaynex
2026-03-19 20:50:38 +00:00
parent c168081c7e
commit ac3493fc69
5 changed files with 205 additions and 271 deletions

View File

@@ -9,43 +9,16 @@
* POST /api/admin/relay/queue/:eventId/reject — admin reject
*/
import { Router, type Request, type Response, type NextFunction } from "express";
import { Router, type Request, type Response } from "express";
import { db, relayEventQueue, type QueueStatus, QUEUE_STATUSES } from "@workspace/db";
import { eq } from "drizzle-orm";
import { makeLogger } from "../lib/logger.js";
import { moderationService } from "../lib/moderation.js";
import { requireAdmin } from "./admin-relay.js";
const logger = makeLogger("admin-relay-queue");
const router = Router();
const ADMIN_SECRET = process.env["ADMIN_SECRET"] ?? "";
const IS_PROD = process.env["NODE_ENV"] === "production";
if (!ADMIN_SECRET && IS_PROD) {
logger.error("ADMIN_SECRET not set in production — admin relay queue routes are unprotected");
}
// ── Admin auth middleware ─────────────────────────────────────────────────────
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (ADMIN_SECRET) {
const authHeader = req.headers["authorization"] ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
if (token !== ADMIN_SECRET) {
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) {
res.status(401).json({ error: "Unauthorized" });
return;
}
}
next();
}
// ── GET /admin/relay/queue ────────────────────────────────────────────────────
// Query param: ?status=pending|approved|rejected|auto_approved
// Default: returns all statuses.
@@ -73,16 +46,27 @@ router.get("/admin/relay/queue", requireAdmin, async (req: Request, res: Respons
res.json({
total: rows.length,
events: rows.map((r) => ({
eventId: r.eventId,
pubkey: r.pubkey,
kind: r.kind,
status: r.status,
reviewedBy: r.reviewedBy,
reviewReason: r.reviewReason,
createdAt: r.createdAt,
decidedAt: r.decidedAt,
})),
events: rows.map((r) => {
// Parse content preview from rawEvent JSON; gracefully degrade on parse failure.
let contentPreview: string | null = null;
try {
const parsed = JSON.parse(r.rawEvent ?? "{}") as { content?: string };
contentPreview = parsed.content?.slice(0, 120) ?? null;
} catch {
contentPreview = null;
}
return {
eventId: r.eventId,
pubkey: r.pubkey,
kind: r.kind,
status: r.status,
contentPreview,
reviewedBy: r.reviewedBy,
reviewReason: r.reviewReason,
createdAt: r.createdAt,
decidedAt: r.decidedAt,
};
}),
});
});