From ac3493fc69847bb47874882e4c0249a1638ce4ff Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 20:50:38 +0000 Subject: [PATCH] task/33: Relay admin panel at /admin/relay (post-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 (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 ✓ --- artifacts/api-server/src/app.ts | 5 + .../src/routes/admin-relay-panel.ts | 361 +++++++----------- .../src/routes/admin-relay-queue.ts | 62 ++- .../api-server/src/routes/admin-relay.ts | 46 ++- artifacts/api-server/src/routes/index.ts | 2 - 5 files changed, 205 insertions(+), 271 deletions(-) diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index c2c7f21..992833b 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -2,6 +2,7 @@ import express, { type Express } from "express"; import cors from "cors"; import path from "path"; import router from "./routes/index.js"; +import adminRelayPanelRouter from "./routes/admin-relay-panel.js"; import { responseTimeMiddleware } from "./middlewares/response-time.js"; const app: Express = express(); @@ -51,6 +52,10 @@ app.use(responseTimeMiddleware); app.use("/api", router); +// ── Relay admin panel at /admin/relay ──────────────────────────────────────── +// Served outside /api so the URL is clean: /admin/relay (not /api/admin/relay). +app.use(adminRelayPanelRouter); + // ── Tower (Matrix 3D frontend) ─────────────────────────────────────────────── // Serve the pre-built Three.js world at /tower. WS client auto-connects to // /api/ws on the same host. process.cwd() = workspace root at runtime. diff --git a/artifacts/api-server/src/routes/admin-relay-panel.ts b/artifacts/api-server/src/routes/admin-relay-panel.ts index 3205eda..09d0089 100644 --- a/artifacts/api-server/src/routes/admin-relay-panel.ts +++ b/artifacts/api-server/src/routes/admin-relay-panel.ts @@ -1,15 +1,17 @@ /** * 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. + * This page is registered directly in app.ts at /admin/relay (NOT under /api) + * so the route is accessible at a clean URL. + * + * Auth gate: on first visit the user is prompted for ADMIN_TOKEN, stored in + * localStorage, sent as Bearer Authorization header on every /api/admin call. * * Tabs: - * Queue — Pending events list with Approve / Reject; auto-refreshes every 15s + * Queue — Pending events list with content preview, Approve/Reject; 15s auto-refresh * Accounts — Whitelist table with Revoke; pubkey grant form * - * Stats bar at top: pending, approved today, total accounts. + * Stats bar: pending, approved today, total accounts, live relay connections. */ import { Router } from "express"; @@ -24,8 +26,7 @@ router.get("/admin/relay", (_req, res) => { 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. +// Inline HTML — no build step required, served directly by Express. // ───────────────────────────────────────────────────────────────────────────── const ADMIN_PANEL_HTML = ` @@ -47,6 +48,7 @@ const ADMIN_PANEL_HTML = ` --green: #00d4aa; --red: #ff4d6d; --yellow: #f7c948; + --blue: #4da8ff; } * { box-sizing: border-box; margin: 0; padding: 0; } body { @@ -60,20 +62,15 @@ const ADMIN_PANEL_HTML = ` padding: 32px 20px 60px; } - /* ── Header ── */ header { width: 100%; - max-width: 900px; + max-width: 960px; display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; } - .header-title { - display: flex; - align-items: center; - gap: 14px; - } + .header-title { display: flex; align-items: center; gap: 14px; } .header-title h1 { font-family: system-ui, sans-serif; font-size: 1.4rem; @@ -92,11 +89,7 @@ const ADMIN_PANEL_HTML = ` letter-spacing: 1px; vertical-align: middle; } - .header-links { - display: flex; - gap: 14px; - align-items: center; - } + .header-links { display: flex; gap: 14px; align-items: center; } .header-links a { color: var(--muted); font-size: 0.78rem; @@ -179,7 +172,7 @@ const ADMIN_PANEL_HTML = ` } /* ── Main content ── */ - #main { display: none; width: 100%; max-width: 900px; } + #main { display: none; width: 100%; max-width: 960px; } /* ── Stats bar ── */ .stats-bar { @@ -210,15 +203,14 @@ const ADMIN_PANEL_HTML = ` letter-spacing: 1px; font-family: system-ui, sans-serif; } - .stat-card.accent .stat-value { color: var(--accent); } - .stat-card.green .stat-value { color: var(--green); } - .stat-card.purple .stat-value { color: var(--accent2); } .stat-card.yellow .stat-value { color: var(--yellow); } + .stat-card.green .stat-value { color: var(--green); } + .stat-card.purple .stat-value { color: var(--accent2); } + .stat-card.blue .stat-value { color: var(--blue); } /* ── Tabs ── */ .tab-bar { display: flex; - gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 20px; } @@ -263,17 +255,8 @@ const ADMIN_PANEL_HTML = ` text-transform: uppercase; letter-spacing: 1px; } - .table-header .refresh-hint { - font-size: 0.7rem; - color: var(--muted); - font-family: system-ui, sans-serif; - } - table { - width: 100%; - border-collapse: collapse; - font-family: system-ui, sans-serif; - font-size: 0.82rem; - } + .table-header .hint { font-size: 0.7rem; color: var(--muted); font-family: system-ui, sans-serif; } + table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.82rem; } th { text-align: left; padding: 10px 16px; @@ -295,22 +278,9 @@ const ADMIN_PANEL_HTML = ` tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,0.02); } - .pubkey-cell { - font-family: monospace; - font-size: 0.78rem; - color: var(--accent2); - } - .content-preview { - color: var(--muted); - max-width: 220px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .timestamp { - color: var(--muted); - font-size: 0.75rem; - } + .mono { font-family: monospace; font-size: 0.78rem; color: var(--accent2); } + .preview { color: var(--muted); max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ts { color: var(--muted); font-size: 0.75rem; } /* ── Status pill ── */ .pill { @@ -322,16 +292,16 @@ const ADMIN_PANEL_HTML = ` letter-spacing: 0.3px; text-transform: uppercase; } - .pill-pending { background: #2a2010; color: var(--yellow); border: 1px solid #443010; } - .pill-approved { background: #0d2820; color: var(--green); border: 1px solid #0f4030; } - .pill-auto_approved { background: #0d2820; color: var(--green); border: 1px solid #0f4030; } - .pill-rejected { background: #2a0f18; color: var(--red); border: 1px solid #441020; } - .pill-read { background: #1a1a2e; color: var(--accent2); border: 1px solid #2a1a4e; } - .pill-write { background: #1a2a10; color: var(--green); border: 1px solid #1a4010; } - .pill-elite { background: #2a1a00; color: var(--accent); border: 1px solid #4a2a00; } - .pill-none { background: #1a1a1a; color: var(--muted); border: 1px solid #2a2a2a; } + .pill-pending { background: #2a2010; color: var(--yellow); border: 1px solid #443010; } + .pill-approved { background: #0d2820; color: var(--green); border: 1px solid #0f4030; } + .pill-auto_approved{ background: #0d2820; color: var(--green); border: 1px solid #0f4030; } + .pill-rejected { background: #2a0f18; color: var(--red); border: 1px solid #441020; } + .pill-read { background: #1a1a2e; color: var(--accent2); border: 1px solid #2a1a4e; } + .pill-write { background: #1a2a10; color: var(--green); border: 1px solid #1a4010; } + .pill-elite { background: #2a1a00; color: var(--accent); border: 1px solid #4a2a00; } + .pill-none { background: #1a1a1a; color: var(--muted); border: 1px solid #2a2a2a; } - /* ── Action buttons ── */ + /* ── Buttons ── */ .btn { border: none; border-radius: 6px; @@ -345,14 +315,13 @@ const ADMIN_PANEL_HTML = ` .btn:hover { opacity: 0.85; transform: translateY(-1px); } .btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; } .btn-approve { background: var(--green); color: #000; } - .btn-reject { background: var(--red); color: #fff; margin-left: 6px; } + .btn-reject { background: var(--red); color: #fff; margin-left: 6px; } .btn-revoke { background: transparent; border: 1px solid var(--red); color: var(--red); } /* ── Grant form ── */ .grant-form { background: var(--surface2); - border: 1px solid var(--border); - border-top: none; + border-top: 1px solid var(--border); padding: 18px 20px; display: flex; align-items: flex-end; @@ -381,32 +350,9 @@ const ADMIN_PANEL_HTML = ` } .grant-form input:focus, .grant-form select:focus { border-color: var(--accent2); } .grant-form select { font-family: system-ui, sans-serif; } - .btn-grant { - background: var(--accent); - color: #000; - padding: 8px 18px; - flex-shrink: 0; - } + .btn-grant { background: var(--accent); color: #000; padding: 8px 18px; flex-shrink: 0; } - /* ── Empty / loading state ── */ - .empty-state { - padding: 36px; - text-align: center; - color: var(--muted); - font-family: system-ui, sans-serif; - font-size: 0.88rem; - } - .spinner { - display: inline-block; - width: 14px; height: 14px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - margin-right: 8px; - vertical-align: middle; - } - @keyframes spin { to { transform: rotate(360deg); } } + .empty-state { padding: 36px; text-align: center; color: var(--muted); font-family: system-ui, sans-serif; font-size: 0.88rem; } /* ── Toast ── */ #toast { @@ -427,10 +373,8 @@ const ADMIN_PANEL_HTML = ` max-width: 320px; } #toast.show { opacity: 1; } - #toast.ok { border-color: var(--green); color: var(--green); } - #toast.err { border-color: var(--red); color: var(--red); } - - .hidden { display: none !important; } + #toast.ok { border-color: var(--green); color: var(--green); } + #toast.err { border-color: var(--red); color: var(--red); }
@@ -438,7 +382,7 @@ const ADMIN_PANEL_HTML = `Enter the relay admin token to access the dashboard. It will be remembered in this browser.
+Enter the ADMIN_TOKEN to access the relay dashboard. It will be remembered in this browser.