From 8000b005d64403397999fba526cd0479342a69f7 Mon Sep 17 00:00:00 2001
From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com>
Date: Thu, 19 Mar 2026 20:54:08 +0000
Subject: [PATCH] task/33: Relay admin panel at /admin/relay (final, all review
fixes)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was built
Relay operator dashboard at GET /admin/relay (outside /api, clean URL).
Server-side rendered inline HTML with vanilla JS, no separate build step.
## Route registration
admin-relay-panel.ts imported in app.ts and mounted via app.use() after /api
and before /tower. Route not in routes/index.ts (would be /api/admin/relay).
## Auth gate + env var alignment
Backend: ADMIN_TOKEN is canonical env var; falls back to ADMIN_SECRET for
compat. ADMIN_TOKEN exported as requireAdmin from admin-relay.ts; admin-relay-
queue.ts imports it instead of duplicating. Panel route returns 403 in
production when ADMIN_TOKEN is not configured (gate per spec).
Frontend: prompt reads "Enter the ADMIN_TOKEN". Token verified by calling
/api/admin/relay/stats; 401 → error; success → localStorage + showMain().
## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes from 1st review round:
1. approvedToday: AND(status IN (approved, auto_approved), decidedAt >= UTC midnight)
2. liveConnections: fetch STRFRY_URL/stats, 2s AbortSignal timeout, null on fail
3. Returns: pending, approved, autoApproved, rejected, approvedToday,
totalAccounts, liveConnections (null when strfry unavailable)
## Queue endpoint: contentPreview field
rawEvent content JSON.parsed and sliced to 120 chars; null on parse failure.
GET /api/admin/relay/queue?status=pending used by UI (pending-only, per spec).
## Admin panel features
Stats bar (4 cards): Pending (yellow), Approved today (green),
Accounts (purple), Relay connections (blue; null → "n/a").
Queue tab: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions.
Accounts tab: whitelist table, Revoke (with confirm), Grant form.
15s auto-refresh on queue + stats. Toast feedback on all actions.
Navigation: ← Timmy UI, Workshop, Log out.
## XSS fix (blocking issue from 2nd review round)
Central esc(v) function: replaces &, <, >, ", ' with HTML entities.
Applied to ALL user-controlled values in renderQueueRow and renderAccountRow:
contentPreview, notes, grantedBy, tier, level, ts, id8, pk12, kind.
onclick handlers use safeId/safePk: hex chars stripped to [0-9a-f] only.
Verified: event with content '' → contentPreview
returned as raw JSON string; frontend esc() blocks execution in innerHTML.
## TypeScript: 0 errors. Smoke tests: panel HTML ✓, stats fields ✓,
queue pending-filter ✓, contentPreview ✓, production gate logic verified.
---
.../src/routes/admin-relay-panel.ts | 73 +++++++++++++------
1 file changed, 52 insertions(+), 21 deletions(-)
diff --git a/artifacts/api-server/src/routes/admin-relay-panel.ts b/artifacts/api-server/src/routes/admin-relay-panel.ts
index 09d0089..55d626e 100644
--- a/artifacts/api-server/src/routes/admin-relay-panel.ts
+++ b/artifacts/api-server/src/routes/admin-relay-panel.ts
@@ -14,11 +14,21 @@
* Stats bar: pending, approved today, total accounts, live relay connections.
*/
-import { Router } from "express";
+import { Router, type Request, type Response } from "express";
const router = Router();
-router.get("/admin/relay", (_req, res) => {
+// Gate: in production the panel returns 403 when ADMIN_TOKEN is not configured.
+// In development (no token set), the auth gate still renders and the API uses
+// localhost-only fallback, so ops can still access the panel locally.
+const ADMIN_TOKEN = process.env["ADMIN_TOKEN"] ?? process.env["ADMIN_SECRET"] ?? "";
+const IS_PROD = process.env["NODE_ENV"] === "production";
+
+router.get("/admin/relay", (_req: Request, res: Response) => {
+ if (IS_PROD && !ADMIN_TOKEN) {
+ res.status(403).send("Relay admin panel disabled: ADMIN_TOKEN is not configured.");
+ return;
+ }
res.setHeader("Content-Type", "text/html");
res.send(ADMIN_PANEL_HTML);
});
@@ -563,19 +573,35 @@ async function loadQueue() {
}
}
+// esc() — HTML-escape a value before injection into innerHTML.
+// Prevents XSS from any user-controlled field (contentPreview, notes, pubkey, etc.).
+function esc(v) {
+ return String(v == null ? '' : v)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
function renderQueueRow(ev) {
- var id8 = ev.eventId ? ev.eventId.slice(0,8) + '…' : '—';
- var pk12 = ev.pubkey ? ev.pubkey.slice(0,12) + '…' : '—';
- var preview = ev.contentPreview ? ev.contentPreview : '(empty)';
- var ts = ev.createdAt ? new Date(ev.createdAt).toLocaleString() : '—';
- var pill = '' + (ev.status||'—') + '';
- var approve = '';
- var reject = '';
+ // eventId and pubkey are server-validated hex strings, but still escape defensively.
+ var id8 = ev.eventId ? esc(ev.eventId.slice(0,8)) + '…' : '—';
+ var pk12 = ev.pubkey ? esc(ev.pubkey.slice(0,12)) + '…' : '—';
+ var preview = esc(ev.contentPreview || '(empty)');
+ var ts = ev.createdAt ? esc(new Date(ev.createdAt).toLocaleString()) : '—';
+ var status = esc(ev.status || 'none');
+ // onclick uses hex event IDs validated server-side — still sanitise to be safe.
+ var safeId = (ev.eventId || '').replace(/[^0-9a-f]/gi, '');
+ var pill = '' + status + '';
+ var approve = '';
+ var reject = '';
+ var kind = ev.kind != null ? esc(String(ev.kind)) : '—';
return '