164 lines
5.2 KiB
TypeScript
164 lines
5.2 KiB
TypeScript
|
|
/**
|
||
|
|
* admin-relay-queue.ts — Admin endpoints for the event moderation queue.
|
||
|
|
*
|
||
|
|
* Protected by ADMIN_SECRET Bearer token (same pattern as admin-relay.ts).
|
||
|
|
*
|
||
|
|
* Routes:
|
||
|
|
* GET /api/admin/relay/queue — list queue (filterable by status)
|
||
|
|
* POST /api/admin/relay/queue/:eventId/approve — admin approve
|
||
|
|
* POST /api/admin/relay/queue/:eventId/reject — admin reject
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { Router, type Request, type Response, type NextFunction } from "express";
|
||
|
|
import { db, relayEventQueue, type QueueStatus, QUEUE_STATUSES } from "@workspace/db";
|
||
|
|
import { eq } from "drizzle-orm";
|
||
|
|
import { makeLogger } from "../lib/logger.js";
|
||
|
|
import { moderationService } from "../lib/moderation.js";
|
||
|
|
|
||
|
|
const logger = makeLogger("admin-relay-queue");
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
const ADMIN_SECRET = process.env["ADMIN_SECRET"] ?? "";
|
||
|
|
const IS_PROD = process.env["NODE_ENV"] === "production";
|
||
|
|
|
||
|
|
if (!ADMIN_SECRET && IS_PROD) {
|
||
|
|
logger.error("ADMIN_SECRET not set in production — admin relay queue routes are unprotected");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Admin auth middleware ─────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||
|
|
if (ADMIN_SECRET) {
|
||
|
|
const authHeader = req.headers["authorization"] ?? "";
|
||
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
||
|
|
if (token !== ADMIN_SECRET) {
|
||
|
|
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) {
|
||
|
|
res.status(401).json({ error: "Unauthorized" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
next();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── GET /admin/relay/queue ────────────────────────────────────────────────────
|
||
|
|
// Query param: ?status=pending|approved|rejected|auto_approved
|
||
|
|
// Default: returns all statuses.
|
||
|
|
|
||
|
|
router.get("/admin/relay/queue", requireAdmin, async (req: Request, res: Response) => {
|
||
|
|
const statusParam = req.query["status"] as string | undefined;
|
||
|
|
|
||
|
|
if (statusParam && !QUEUE_STATUSES.includes(statusParam as QueueStatus)) {
|
||
|
|
res.status(400).json({
|
||
|
|
error: `Invalid status '${statusParam}'. Must be one of: ${QUEUE_STATUSES.join(", ")}`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rows = statusParam
|
||
|
|
? await db
|
||
|
|
.select()
|
||
|
|
.from(relayEventQueue)
|
||
|
|
.where(eq(relayEventQueue.status, statusParam as QueueStatus))
|
||
|
|
.orderBy(relayEventQueue.createdAt)
|
||
|
|
: await db
|
||
|
|
.select()
|
||
|
|
.from(relayEventQueue)
|
||
|
|
.orderBy(relayEventQueue.createdAt);
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
total: rows.length,
|
||
|
|
events: rows.map((r) => ({
|
||
|
|
eventId: r.eventId,
|
||
|
|
pubkey: r.pubkey,
|
||
|
|
kind: r.kind,
|
||
|
|
status: r.status,
|
||
|
|
reviewedBy: r.reviewedBy,
|
||
|
|
reviewReason: r.reviewReason,
|
||
|
|
createdAt: r.createdAt,
|
||
|
|
decidedAt: r.decidedAt,
|
||
|
|
})),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── POST /admin/relay/queue/:eventId/approve ──────────────────────────────────
|
||
|
|
|
||
|
|
router.post(
|
||
|
|
"/admin/relay/queue/:eventId/approve",
|
||
|
|
requireAdmin,
|
||
|
|
async (req: Request, res: Response) => {
|
||
|
|
const { eventId } = req.params as { eventId: string };
|
||
|
|
const body = req.body as { reason?: string };
|
||
|
|
const reason = body.reason ?? "admin approval";
|
||
|
|
|
||
|
|
const rows = await db
|
||
|
|
.select({ status: relayEventQueue.status })
|
||
|
|
.from(relayEventQueue)
|
||
|
|
.where(eq(relayEventQueue.eventId, eventId))
|
||
|
|
.limit(1);
|
||
|
|
|
||
|
|
if (!rows[0]) {
|
||
|
|
res.status(404).json({ error: "Event not found in queue" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (rows[0].status === "approved" || rows[0].status === "auto_approved") {
|
||
|
|
res.status(409).json({ error: "Event already approved" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await moderationService.decide(eventId, "approved", reason, "admin");
|
||
|
|
|
||
|
|
logger.info("admin approved queued event", {
|
||
|
|
eventId: eventId.slice(0, 8),
|
||
|
|
reason,
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({ ok: true, eventId, status: "approved", reason });
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// ── POST /admin/relay/queue/:eventId/reject ───────────────────────────────────
|
||
|
|
|
||
|
|
router.post(
|
||
|
|
"/admin/relay/queue/:eventId/reject",
|
||
|
|
requireAdmin,
|
||
|
|
async (req: Request, res: Response) => {
|
||
|
|
const { eventId } = req.params as { eventId: string };
|
||
|
|
const body = req.body as { reason?: string };
|
||
|
|
const reason = body.reason ?? "admin rejection";
|
||
|
|
|
||
|
|
const rows = await db
|
||
|
|
.select({ status: relayEventQueue.status })
|
||
|
|
.from(relayEventQueue)
|
||
|
|
.where(eq(relayEventQueue.eventId, eventId))
|
||
|
|
.limit(1);
|
||
|
|
|
||
|
|
if (!rows[0]) {
|
||
|
|
res.status(404).json({ error: "Event not found in queue" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (rows[0].status === "rejected") {
|
||
|
|
res.status(409).json({ error: "Event already rejected" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await moderationService.decide(eventId, "rejected", reason, "admin");
|
||
|
|
|
||
|
|
logger.info("admin rejected queued event", {
|
||
|
|
eventId: eventId.slice(0, 8),
|
||
|
|
reason,
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({ ok: true, eventId, status: "rejected", reason });
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
export default router;
|