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.
This commit is contained in:
alexpaynex
2026-03-19 20:35:39 +00:00
parent 01374375fb
commit a95fd76ebd
8 changed files with 661 additions and 53 deletions

View File

@@ -11,3 +11,4 @@ export * from "./free-tier-grants";
export * from "./timmy-nostr-events";
export * from "./nostr-trust-vouches";
export * from "./relay-accounts";
export * from "./relay-event-queue";

View File

@@ -0,0 +1,44 @@
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core";
import { nostrIdentities } from "./nostr-identities";
// ── Status + reviewer types ───────────────────────────────────────────────────
export const QUEUE_STATUSES = ["pending", "approved", "rejected", "auto_approved"] as const;
export type QueueStatus = (typeof QUEUE_STATUSES)[number];
export const QUEUE_REVIEWERS = ["timmy_ai", "admin"] as const;
export type QueueReviewer = (typeof QUEUE_REVIEWERS)[number];
// ── relay_event_queue ─────────────────────────────────────────────────────────
// Holds every event submitted by whitelisted (non-elite) accounts.
// Events wait here as "pending" until Timmy AI or an admin approves/rejects.
// On approval the API server injects the event into strfry via HTTP import.
// Elite accounts bypass this table entirely.
export const relayEventQueue = pgTable("relay_event_queue", {
eventId: text("event_id").primaryKey(),
pubkey: text("pubkey")
.notNull()
.references(() => nostrIdentities.pubkey, { onDelete: "cascade" }),
kind: integer("kind").notNull(),
// Full raw NIP-01 event JSON, stored as text so it can be forwarded to strfry
rawEvent: text("raw_event").notNull(),
status: text("status")
.$type<QueueStatus>()
.notNull()
.default("pending"),
// "timmy_ai" or "admin" — null until a decision is made
reviewedBy: text("reviewed_by").$type<QueueReviewer>(),
reviewReason: text("review_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
decidedAt: timestamp("decided_at", { withTimezone: true }),
});
export type RelayEventQueueRow = typeof relayEventQueue.$inferSelect;