## 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 ✓.
204 lines
7.6 KiB
TypeScript
204 lines
7.6 KiB
TypeScript
/**
|
|
* admin-relay.ts — Admin endpoints for the relay account whitelist + stats.
|
|
*
|
|
* All routes are protected by ADMIN_SECRET env var (Bearer token).
|
|
* If ADMIN_SECRET is not set, the routes reject all requests in production
|
|
* and accept only from localhost in development.
|
|
*
|
|
* Routes:
|
|
* GET /api/admin/relay/stats — event counts + account total
|
|
* GET /api/admin/relay/accounts — list all relay accounts
|
|
* POST /api/admin/relay/accounts/:pubkey/grant — grant access to a pubkey
|
|
* POST /api/admin/relay/accounts/:pubkey/revoke — revoke access from a pubkey
|
|
*/
|
|
|
|
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, nostrIdentities } from "@workspace/db";
|
|
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
|
|
|
const logger = makeLogger("admin-relay");
|
|
const router = Router();
|
|
|
|
// 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_TOKEN) {
|
|
if (IS_PROD) {
|
|
logger.error(
|
|
"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_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.
|
|
|
|
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_TOKEN) {
|
|
res.status(401).json({ error: "Unauthorized" });
|
|
return;
|
|
}
|
|
} else {
|
|
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 token configured, rejecting non-local call", { ip });
|
|
res.status(401).json({ error: "Unauthorized" });
|
|
return;
|
|
}
|
|
}
|
|
next();
|
|
}
|
|
|
|
// ── GET /admin/relay/stats ────────────────────────────────────────────────────
|
|
// Returns event counts by status for today + all-time pending, plus account total.
|
|
|
|
router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Response) => {
|
|
const todayStart = new Date();
|
|
todayStart.setUTCHours(0, 0, 0, 0);
|
|
|
|
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)
|
|
.groupBy(relayEventQueue.status),
|
|
db.select({ count: sql<number>`count(*)::int` }).from(relayAccounts),
|
|
db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(relayEventQueue)
|
|
.where(
|
|
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> = {};
|
|
for (const row of queueCounts) {
|
|
statusMap[row.status] = row.count;
|
|
}
|
|
|
|
res.json({
|
|
pending: statusMap["pending"] ?? 0,
|
|
approved: statusMap["approved"] ?? 0,
|
|
autoApproved: statusMap["auto_approved"] ?? 0,
|
|
rejected: statusMap["rejected"] ?? 0,
|
|
approvedToday: approvedToday[0]?.count ?? 0,
|
|
totalAccounts: accountCount[0]?.count ?? 0,
|
|
liveConnections: strfryStats?.connections ?? null,
|
|
});
|
|
});
|
|
|
|
// ── GET /admin/relay/accounts ─────────────────────────────────────────────────
|
|
|
|
router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => {
|
|
// 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 ──────────────────────────────────
|
|
|
|
router.post(
|
|
"/admin/relay/accounts/:pubkey/grant",
|
|
requireAdmin,
|
|
async (req: Request, res: Response) => {
|
|
const { pubkey } = req.params as { pubkey: string };
|
|
|
|
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
|
|
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
|
|
return;
|
|
}
|
|
|
|
const body = req.body as { level?: string; notes?: string };
|
|
const level = (body.level ?? "write").toLowerCase() as RelayAccessLevel;
|
|
|
|
if (!RELAY_ACCESS_LEVELS.includes(level)) {
|
|
res.status(400).json({
|
|
error: `Invalid access level '${level}'. Must be one of: ${RELAY_ACCESS_LEVELS.join(", ")}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const notes = typeof body.notes === "string" ? body.notes : "admin grant";
|
|
|
|
await relayAccountService.grant(pubkey, level, notes, "manual");
|
|
|
|
logger.info("admin granted relay access", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
level,
|
|
notes,
|
|
});
|
|
|
|
res.json({ ok: true, pubkey, accessLevel: level, notes });
|
|
},
|
|
);
|
|
|
|
// ── POST /admin/relay/accounts/:pubkey/revoke ─────────────────────────────────
|
|
|
|
router.post(
|
|
"/admin/relay/accounts/:pubkey/revoke",
|
|
requireAdmin,
|
|
async (req: Request, res: Response) => {
|
|
const { pubkey } = req.params as { pubkey: string };
|
|
|
|
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
|
|
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
|
|
return;
|
|
}
|
|
|
|
const body = req.body as { reason?: string };
|
|
const reason = typeof body.reason === "string" ? body.reason : "admin revoke";
|
|
|
|
await relayAccountService.revoke(pubkey, reason);
|
|
|
|
logger.info("admin revoked relay access", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
reason,
|
|
});
|
|
|
|
res.json({ ok: true, pubkey, revokedAt: new Date().toISOString(), reason });
|
|
},
|
|
);
|
|
|
|
export default router;
|