Commit Graph

2 Commits

Author SHA1 Message Date
alexpaynex
ac3493fc69 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
alexpaynex
a95fd76ebd 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