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:
@@ -16,32 +16,35 @@ 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 { eq, gte, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
|
||||
const logger = makeLogger("admin-relay");
|
||||
const router = Router();
|
||||
|
||||
const ADMIN_SECRET = process.env["ADMIN_SECRET"] ?? "";
|
||||
// ADMIN_TOKEN is the canonical env var; ADMIN_SECRET is the backward-compat alias.
|
||||
const ADMIN_TOKEN = process.env["ADMIN_TOKEN"] ?? process.env["ADMIN_SECRET"] ?? "";
|
||||
const IS_PROD = process.env["NODE_ENV"] === "production";
|
||||
|
||||
if (!ADMIN_SECRET) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
if (IS_PROD) {
|
||||
logger.error(
|
||||
"ADMIN_SECRET is not set in production — admin relay routes are unprotected. " +
|
||||
"Set ADMIN_SECRET in the API server environment immediately.",
|
||||
"ADMIN_TOKEN is not set in production — admin relay routes are unprotected. " +
|
||||
"Set ADMIN_TOKEN in the API server environment immediately.",
|
||||
);
|
||||
} else {
|
||||
logger.warn("ADMIN_SECRET not set — admin relay routes accept local-only requests (dev mode)");
|
||||
logger.warn("ADMIN_TOKEN not set — admin relay routes accept local-only requests (dev mode)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin auth middleware ──────────────────────────────────────────────────────
|
||||
// Shared by all admin-relay*.ts routes. Token is matched against ADMIN_TOKEN
|
||||
// (env var), falling back to localhost-only in dev when no token is set.
|
||||
|
||||
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (ADMIN_SECRET) {
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (ADMIN_TOKEN) {
|
||||
const authHeader = req.headers["authorization"] ?? "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
||||
if (token !== ADMIN_SECRET) {
|
||||
if (token !== ADMIN_TOKEN) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
@@ -49,7 +52,7 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
const ip = req.ip ?? "";
|
||||
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
||||
if (!isLocal) {
|
||||
logger.warn("admin-relay: no secret configured, rejecting non-local call", { ip });
|
||||
logger.warn("admin-relay: no token configured, rejecting non-local call", { ip });
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
@@ -64,7 +67,9 @@ router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Respon
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const [queueCounts, accountCount, approvedToday] = await Promise.all([
|
||||
const STRFRY_URL = process.env["STRFRY_URL"] ?? "http://strfry:7777";
|
||||
|
||||
const [queueCounts, accountCount, approvedToday, strfryStats] = await Promise.all([
|
||||
db
|
||||
.select({ status: relayEventQueue.status, count: sql<number>`count(*)::int` })
|
||||
.from(relayEventQueue)
|
||||
@@ -74,8 +79,24 @@ router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Respon
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(relayEventQueue)
|
||||
.where(
|
||||
gte(relayEventQueue.decidedAt, todayStart),
|
||||
and(
|
||||
inArray(relayEventQueue.status, ["approved", "auto_approved"]),
|
||||
gte(relayEventQueue.decidedAt, todayStart),
|
||||
),
|
||||
),
|
||||
// Attempt to fetch live connection count from strfry's /stats endpoint.
|
||||
// Strfry exposes /stats in some builds; gracefully return null if unavailable.
|
||||
(async (): Promise<{ connections?: number } | null> => {
|
||||
try {
|
||||
const r = await fetch(`${STRFRY_URL}/stats`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as { connections?: number };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
@@ -90,6 +111,7 @@ router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Respon
|
||||
rejected: statusMap["rejected"] ?? 0,
|
||||
approvedToday: approvedToday[0]?.count ?? 0,
|
||||
totalAccounts: accountCount[0]?.count ?? 0,
|
||||
liveConnections: strfryStats?.connections ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user