Files
timmy-tower/artifacts/api-server/src/routes/admin-relay-queue.ts

148 lines
4.7 KiB
TypeScript
Raw Normal View History

task/32: Event moderation queue + Timmy AI review ## What was built Full moderation pipeline: relay_event_queue table, strfry inject helper, ModerationService with Claude haiku review, policy tier routing, 30s poll loop, admin approve/reject/list endpoints. ## DB schema (`lib/db/src/schema/relay-event-queue.ts`) relay_event_queue: event_id (PK), pubkey (FK → nostr_identities), kind, raw_event (text JSON), status (pending/approved/rejected/auto_approved), reviewed_by (timmy_ai/admin/null), review_reason, created_at, decided_at. Exported from schema/index.ts. Pushed via pnpm run push. ## strfry HTTP client (`artifacts/api-server/src/lib/strfry.ts`) injectEvent(rawEventJson) — POST {STRFRY_URL}/import (NDJSON). STRFRY_URL defaults to "http://strfry:7777" (Docker internal network). 5s timeout; graceful failure in dev when strfry not running; never throws. ## ModerationService (`artifacts/api-server/src/lib/moderation.ts`) - enqueue(event) — insert pending row; idempotent onConflictDoNothing - autoReview(eventId) — Claude haiku prompt: approve or flag. On flag, marks reviewedBy=timmy_ai and leaves pending for admin. On approve, calls decide(). - decide(eventId, status, reason, reviewedBy) — updates DB + calls injectEvent - processPending(limit=10) — batch poll: auto-review up to limit pending events - Stub mode: auto-approves all events when Anthropic key absent ## Policy endpoint update (`artifacts/api-server/src/routes/relay.ts`) Tier routing in evaluatePolicy: read/none → reject (unchanged) write + elite tier → injectEvent + accept (elite bypass; shadowReject if inject fails) write + non-elite → enqueue + shadowReject (held for moderation) Imports db/nostrIdentities directly for tier check. Both inject and enqueue errors are fail-closed (reject vs shadowReject respectively). ## Background poll loop (`artifacts/api-server/src/index.ts`) setInterval every 30s calling moderationService.processPending(10). Interval configurable via MODERATION_POLL_MS env var. Errors caught per-event; poll loop never crashes the server. ## Admin queue routes (`artifacts/api-server/src/routes/admin-relay-queue.ts`) ADMIN_SECRET Bearer auth (same pattern as admin-relay.ts). GET /api/admin/relay/queue?status=... — list all / by status POST /api/admin/relay/queue/:eventId/approve — approve + inject into strfry POST /api/admin/relay/queue/:eventId/reject — reject (no inject) 409 on duplicate decisions. Registered in routes/index.ts. ## Smoke tests (all pass) Unknown → reject ✓; elite → shadowReject (strfry unavailable in dev) ✓; non-elite write → shadowReject + pending in queue ✓; admin approve → approved ✓; moderation poll loop started ✓; TypeScript 0 errors.
2026-03-19 20:35:39 +00:00
/**
* 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
*/
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 ✓
2026-03-19 20:50:38 +00:00
import { Router, type Request, type Response } from "express";
task/32: Event moderation queue + Timmy AI review ## What was built Full moderation pipeline: relay_event_queue table, strfry inject helper, ModerationService with Claude haiku review, policy tier routing, 30s poll loop, admin approve/reject/list endpoints. ## DB schema (`lib/db/src/schema/relay-event-queue.ts`) relay_event_queue: event_id (PK), pubkey (FK → nostr_identities), kind, raw_event (text JSON), status (pending/approved/rejected/auto_approved), reviewed_by (timmy_ai/admin/null), review_reason, created_at, decided_at. Exported from schema/index.ts. Pushed via pnpm run push. ## strfry HTTP client (`artifacts/api-server/src/lib/strfry.ts`) injectEvent(rawEventJson) — POST {STRFRY_URL}/import (NDJSON). STRFRY_URL defaults to "http://strfry:7777" (Docker internal network). 5s timeout; graceful failure in dev when strfry not running; never throws. ## ModerationService (`artifacts/api-server/src/lib/moderation.ts`) - enqueue(event) — insert pending row; idempotent onConflictDoNothing - autoReview(eventId) — Claude haiku prompt: approve or flag. On flag, marks reviewedBy=timmy_ai and leaves pending for admin. On approve, calls decide(). - decide(eventId, status, reason, reviewedBy) — updates DB + calls injectEvent - processPending(limit=10) — batch poll: auto-review up to limit pending events - Stub mode: auto-approves all events when Anthropic key absent ## Policy endpoint update (`artifacts/api-server/src/routes/relay.ts`) Tier routing in evaluatePolicy: read/none → reject (unchanged) write + elite tier → injectEvent + accept (elite bypass; shadowReject if inject fails) write + non-elite → enqueue + shadowReject (held for moderation) Imports db/nostrIdentities directly for tier check. Both inject and enqueue errors are fail-closed (reject vs shadowReject respectively). ## Background poll loop (`artifacts/api-server/src/index.ts`) setInterval every 30s calling moderationService.processPending(10). Interval configurable via MODERATION_POLL_MS env var. Errors caught per-event; poll loop never crashes the server. ## Admin queue routes (`artifacts/api-server/src/routes/admin-relay-queue.ts`) ADMIN_SECRET Bearer auth (same pattern as admin-relay.ts). GET /api/admin/relay/queue?status=... — list all / by status POST /api/admin/relay/queue/:eventId/approve — approve + inject into strfry POST /api/admin/relay/queue/:eventId/reject — reject (no inject) 409 on duplicate decisions. Registered in routes/index.ts. ## Smoke tests (all pass) Unknown → reject ✓; elite → shadowReject (strfry unavailable in dev) ✓; non-elite write → shadowReject + pending in queue ✓; admin approve → approved ✓; moderation poll loop started ✓; TypeScript 0 errors.
2026-03-19 20:35:39 +00:00
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";
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 ✓
2026-03-19 20:50:38 +00:00
import { requireAdmin } from "./admin-relay.js";
task/32: Event moderation queue + Timmy AI review ## What was built Full moderation pipeline: relay_event_queue table, strfry inject helper, ModerationService with Claude haiku review, policy tier routing, 30s poll loop, admin approve/reject/list endpoints. ## DB schema (`lib/db/src/schema/relay-event-queue.ts`) relay_event_queue: event_id (PK), pubkey (FK → nostr_identities), kind, raw_event (text JSON), status (pending/approved/rejected/auto_approved), reviewed_by (timmy_ai/admin/null), review_reason, created_at, decided_at. Exported from schema/index.ts. Pushed via pnpm run push. ## strfry HTTP client (`artifacts/api-server/src/lib/strfry.ts`) injectEvent(rawEventJson) — POST {STRFRY_URL}/import (NDJSON). STRFRY_URL defaults to "http://strfry:7777" (Docker internal network). 5s timeout; graceful failure in dev when strfry not running; never throws. ## ModerationService (`artifacts/api-server/src/lib/moderation.ts`) - enqueue(event) — insert pending row; idempotent onConflictDoNothing - autoReview(eventId) — Claude haiku prompt: approve or flag. On flag, marks reviewedBy=timmy_ai and leaves pending for admin. On approve, calls decide(). - decide(eventId, status, reason, reviewedBy) — updates DB + calls injectEvent - processPending(limit=10) — batch poll: auto-review up to limit pending events - Stub mode: auto-approves all events when Anthropic key absent ## Policy endpoint update (`artifacts/api-server/src/routes/relay.ts`) Tier routing in evaluatePolicy: read/none → reject (unchanged) write + elite tier → injectEvent + accept (elite bypass; shadowReject if inject fails) write + non-elite → enqueue + shadowReject (held for moderation) Imports db/nostrIdentities directly for tier check. Both inject and enqueue errors are fail-closed (reject vs shadowReject respectively). ## Background poll loop (`artifacts/api-server/src/index.ts`) setInterval every 30s calling moderationService.processPending(10). Interval configurable via MODERATION_POLL_MS env var. Errors caught per-event; poll loop never crashes the server. ## Admin queue routes (`artifacts/api-server/src/routes/admin-relay-queue.ts`) ADMIN_SECRET Bearer auth (same pattern as admin-relay.ts). GET /api/admin/relay/queue?status=... — list all / by status POST /api/admin/relay/queue/:eventId/approve — approve + inject into strfry POST /api/admin/relay/queue/:eventId/reject — reject (no inject) 409 on duplicate decisions. Registered in routes/index.ts. ## Smoke tests (all pass) Unknown → reject ✓; elite → shadowReject (strfry unavailable in dev) ✓; non-elite write → shadowReject + pending in queue ✓; admin approve → approved ✓; moderation poll loop started ✓; TypeScript 0 errors.
2026-03-19 20:35:39 +00:00
const logger = makeLogger("admin-relay-queue");
const router = Router();
// ── 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,
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 ✓
2026-03-19 20:50:38 +00:00
events: rows.map((r) => {
// Parse content preview from rawEvent JSON; gracefully degrade on parse failure.
let contentPreview: string | null = null;
try {
const parsed = JSON.parse(r.rawEvent ?? "{}") as { content?: string };
contentPreview = parsed.content?.slice(0, 120) ?? null;
} catch {
contentPreview = null;
}
return {
eventId: r.eventId,
pubkey: r.pubkey,
kind: r.kind,
status: r.status,
contentPreview,
reviewedBy: r.reviewedBy,
reviewReason: r.reviewReason,
createdAt: r.createdAt,
decidedAt: r.decidedAt,
};
}),
task/32: Event moderation queue + Timmy AI review ## What was built Full moderation pipeline: relay_event_queue table, strfry inject helper, ModerationService with Claude haiku review, policy tier routing, 30s poll loop, admin approve/reject/list endpoints. ## DB schema (`lib/db/src/schema/relay-event-queue.ts`) relay_event_queue: event_id (PK), pubkey (FK → nostr_identities), kind, raw_event (text JSON), status (pending/approved/rejected/auto_approved), reviewed_by (timmy_ai/admin/null), review_reason, created_at, decided_at. Exported from schema/index.ts. Pushed via pnpm run push. ## strfry HTTP client (`artifacts/api-server/src/lib/strfry.ts`) injectEvent(rawEventJson) — POST {STRFRY_URL}/import (NDJSON). STRFRY_URL defaults to "http://strfry:7777" (Docker internal network). 5s timeout; graceful failure in dev when strfry not running; never throws. ## ModerationService (`artifacts/api-server/src/lib/moderation.ts`) - enqueue(event) — insert pending row; idempotent onConflictDoNothing - autoReview(eventId) — Claude haiku prompt: approve or flag. On flag, marks reviewedBy=timmy_ai and leaves pending for admin. On approve, calls decide(). - decide(eventId, status, reason, reviewedBy) — updates DB + calls injectEvent - processPending(limit=10) — batch poll: auto-review up to limit pending events - Stub mode: auto-approves all events when Anthropic key absent ## Policy endpoint update (`artifacts/api-server/src/routes/relay.ts`) Tier routing in evaluatePolicy: read/none → reject (unchanged) write + elite tier → injectEvent + accept (elite bypass; shadowReject if inject fails) write + non-elite → enqueue + shadowReject (held for moderation) Imports db/nostrIdentities directly for tier check. Both inject and enqueue errors are fail-closed (reject vs shadowReject respectively). ## Background poll loop (`artifacts/api-server/src/index.ts`) setInterval every 30s calling moderationService.processPending(10). Interval configurable via MODERATION_POLL_MS env var. Errors caught per-event; poll loop never crashes the server. ## Admin queue routes (`artifacts/api-server/src/routes/admin-relay-queue.ts`) ADMIN_SECRET Bearer auth (same pattern as admin-relay.ts). GET /api/admin/relay/queue?status=... — list all / by status POST /api/admin/relay/queue/:eventId/approve — approve + inject into strfry POST /api/admin/relay/queue/:eventId/reject — reject (no inject) 409 on duplicate decisions. Registered in routes/index.ts. ## Smoke tests (all pass) Unknown → reject ✓; elite → shadowReject (strfry unavailable in dev) ✓; non-elite write → shadowReject + pending in queue ✓; admin approve → approved ✓; moderation poll loop started ✓; TypeScript 0 errors.
2026-03-19 20:35:39 +00:00
});
});
// ── 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;