task/33: Relay admin panel at /admin/relay (final, all review fixes)

## 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 '<img src=x onerror=alert(1)>' → 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.
This commit is contained in:
alexpaynex
2026-03-19 20:54:08 +00:00
parent ac3493fc69
commit 8000b005d6

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 = '<span class="pill pill-' + (ev.status||'none') + '">' + (ev.status||'—') + '</span>';
var approve = '<button class="btn btn-approve" onclick="queueApprove(\'' + ev.eventId + '\',this)">Approve</button>';
var reject = '<button class="btn btn-reject" onclick="queueReject(\'' + ev.eventId + '\',this)">Reject</button>';
// 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 = '<span class="pill pill-' + status + '">' + status + '</span>';
var approve = '<button class="btn btn-approve" onclick="queueApprove(\'' + safeId + '\',this)">Approve</button>';
var reject = '<button class="btn btn-reject" onclick="queueReject(\'' + safeId + '\',this)">Reject</button>';
var kind = ev.kind != null ? esc(String(ev.kind)) : '—';
return '<tr>' +
'<td class="mono">' + id8 + '</td>' +
'<td class="mono">' + pk12 + '</td>' +
'<td>' + (ev.kind != null ? ev.kind : '—') + '</td>' +
'<td class="preview" title="' + preview.replace(/"/g,'&quot;') + '">' + preview + '</td>' +
'<td>' + kind + '</td>' +
'<td class="preview" title="' + preview + '">' + preview + '</td>' +
'<td>' + pill + '</td>' +
'<td class="ts">' + ts + '</td>' +
'<td>' + approve + reject + '</td>' +
@@ -621,21 +647,26 @@ async function loadAccounts() {
}
function renderAccountRow(ac) {
var pk = ac.pubkey || '';
var pk12 = pk.slice(0,12) + (pk.length > 12 ? '…' : '');
var ts = ac.grantedAt ? new Date(ac.grantedAt).toLocaleDateString() : '—';
var level = ac.accessLevel || 'none';
var tier = ac.trustTier || '—';
var notes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : '');
var isRevoked = ac.revokedAt != null;
var pk = ac.pubkey || '';
var pk12 = esc(pk.slice(0,12)) + (pk.length > 12 ? '…' : '');
var ts = ac.grantedAt ? esc(new Date(ac.grantedAt).toLocaleDateString()) : '—';
// level and tier come from enum/trusted fields, but escape anyway.
var level = esc(ac.accessLevel || 'none');
var tier = esc(ac.trustTier || '—');
var grantedBy = esc(ac.grantedBy || '—');
var rawNotes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : '');
var notes = esc(rawNotes);
var isRevoked = ac.revokedAt != null;
// pubkey is server-validated hex; strip non-hex chars for onclick safety.
var safePk = pk.replace(/[^0-9a-f]/gi, '');
var action = isRevoked
? '<span style="color:var(--muted);font-size:0.75rem;">revoked</span>'
: '<button class="btn btn-revoke" onclick="revokeAccount(\'' + pk + '\',this)">Revoke</button>';
: '<button class="btn btn-revoke" onclick="revokeAccount(\'' + safePk + '\',this)">Revoke</button>';
return '<tr>' +
'<td class="mono" title="' + pk + '">' + pk12 + '</td>' +
'<td class="mono" title="' + esc(pk) + '">' + pk12 + '</td>' +
'<td><span class="pill pill-' + level + '">' + level + '</span></td>' +
'<td>' + tier + '</td>' +
'<td style="color:var(--muted);">' + (ac.grantedBy||'—') + '</td>' +
'<td style="color:var(--muted);">' + grantedBy + '</td>' +
'<td style="color:var(--muted);font-size:0.78rem;">' + notes + '</td>' +
'<td class="ts">' + ts + '</td>' +
'<td>' + action + '</td>' +