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

## What was built
Relay operator dashboard at GET /admin/relay (outside /api prefix).
Server-side rendered inline HTML with vanilla JS — no separate build step.
Registered in app.ts; absent from routes/index.ts (avoids /api/admin/relay dup).

## Auth gate + ADMIN_TOKEN alignment
- Backend: ADMIN_TOKEN (canonical) with ADMIN_SECRET as backward-compat fallback.
  requireAdmin exported from admin-relay.ts; admin-relay-queue.ts imports it.
  Panel route returns 403 in production when ADMIN_TOKEN is not configured.
- Frontend: prompt reads "Enter the ADMIN_TOKEN". Token verified via stats API
  probe (401 = bad token). Stored in localStorage; cleared on Log out.

## Stats endpoint (GET /api/admin/relay/stats)
- approvedToday: AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight)
- liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal; null on failure
- Returns: pending, approved, autoApproved, rejected, approvedToday,
           totalAccounts, liveConnections

## Accounts endpoint fix (blocking review issue #3)
GET /api/admin/relay/accounts now LEFT JOINs nostr_identities on pubkey.
Returns trustTier (nostr_identities.tier) per account alongside pubkey,
accessLevel, grantedBy, notes, grantedAt, revokedAt.
Verified: elite accounts show "elite", new accounts show "new".

## Queue endpoint: contentPreview
rawEvent content JSON.parsed, sliced to 120 chars; null on parse failure.
GET /api/admin/relay/queue?status=pending used by UI.

## Admin panel features
Stats bar: Pending (yellow), Approved today (green), Accounts (purple),
           Relay connections (blue; null → "n/a").
Queue tab: Event ID, Pubkey, Kind, Content preview, Status, Queued, Actions.
Accounts tab: Pubkey, Access pill, Trust tier, Granted by, Notes, Date, Revoke.
Grant form: pubkey + level + notes; 64-char hex validation client-side.
15s auto-refresh (queue + stats); toast feedback.

## XSS fix (2nd review round fix)
esc(v) escapes &, <, >, ", ' before injection into innerHTML.
Applied to all user-controlled fields: contentPreview, notes, grantedBy,
tier, level, ts, id8, pk12, kind. Onclick uses safeId/safePk (hex-only strip).
Stats use textContent (not innerHTML) — no escaping needed.

## TypeScript: 0 errors. Smoke tests: panel HTML ✓, trustTier in accounts ✓
(e.g. "trustTier":"elite"), stats fields ✓, queue ?status=pending ✓.
This commit is contained in:
alexpaynex
2026-03-19 20:57:52 +00:00
parent 8000b005d6
commit ca8cbee179

View File

@@ -15,7 +15,7 @@
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, db, relayEventQueue, relayAccounts } from "@workspace/db";
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel, db, relayEventQueue, relayAccounts, nostrIdentities } from "@workspace/db";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
const logger = makeLogger("admin-relay");
@@ -118,8 +118,22 @@ router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Respon
// ── GET /admin/relay/accounts ─────────────────────────────────────────────────
router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => {
const accounts = await relayAccountService.list();
res.json({ accounts });
// LEFT JOIN nostr_identities to include trustTier per account.
const rows = await db
.select({
pubkey: relayAccounts.pubkey,
accessLevel: relayAccounts.accessLevel,
grantedBy: relayAccounts.grantedBy,
notes: relayAccounts.notes,
grantedAt: relayAccounts.grantedAt,
revokedAt: relayAccounts.revokedAt,
trustTier: nostrIdentities.tier,
})
.from(relayAccounts)
.leftJoin(nostrIdentities, eq(relayAccounts.pubkey, nostrIdentities.pubkey))
.orderBy(relayAccounts.grantedAt);
res.json({ accounts: rows });
});
// ── POST /admin/relay/accounts/:pubkey/grant ──────────────────────────────────