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

@@ -3,28 +3,24 @@
*
* POST /api/relay/policy
* Internal endpoint called exclusively by the relay-policy sidecar.
* Protected by a shared secret (RELAY_POLICY_SECRET env var) sent as a
* Bearer token in the Authorization header.
* Protected by RELAY_POLICY_SECRET Bearer token.
*
* Body: strfry plugin event object
* {
* event: { id, pubkey, kind, created_at, tags, content, sig },
* receivedAt: number,
* sourceType: "IP4" | "IP6" | ...,
* sourceInfo: string
* }
*
* Response: strfry plugin decision
* { id: string, action: "accept" | "reject" | "shadowReject", msg?: string }
* Policy tiers:
* no relay_account row / "read" / "none" → reject
* "write" + tier != "elite" → enqueue + shadowReject
* "write" + tier == "elite" → inject into strfry + accept
*
* GET /api/relay/policy
* Health + roundtrip probe. No auth required — returns policy state and runs
* a synthetic pubkey through evaluatePolicy().
* Health + roundtrip probe (no auth).
*/
import { Router, type Request, type Response } from "express";
import { db, nostrIdentities } from "@workspace/db";
import { eq } from "drizzle-orm";
import { makeLogger } from "../lib/logger.js";
import { relayAccountService } from "../lib/relay-accounts.js";
import { moderationService } from "../lib/moderation.js";
import { injectEvent } from "../lib/strfry.js";
const logger = makeLogger("relay-policy");
const router = Router();
@@ -32,18 +28,14 @@ const router = Router();
const RELAY_POLICY_SECRET = process.env["RELAY_POLICY_SECRET"] ?? "";
const IS_PROD = process.env["NODE_ENV"] === "production";
// Production enforcement: RELAY_POLICY_SECRET must be set in production.
if (!RELAY_POLICY_SECRET) {
if (IS_PROD) {
logger.error(
"RELAY_POLICY_SECRET is not set in production — " +
"POST /api/relay/policy is open to any caller. " +
"Set this secret in the API server environment and in the relay-policy sidecar.",
"POST /api/relay/policy is open to any caller.",
);
} else {
logger.warn(
"RELAY_POLICY_SECRET not set — /api/relay/policy accepts local-only requests (dev mode)",
);
logger.warn("RELAY_POLICY_SECRET not set — /api/relay/policy accepts local-only requests (dev mode)");
}
}
@@ -51,7 +43,7 @@ if (!RELAY_POLICY_SECRET) {
type PolicyAction = "accept" | "reject" | "shadowReject";
interface NostrEvent {
interface NostrEventPayload {
id: string;
pubkey: string;
kind: number;
@@ -62,7 +54,7 @@ interface NostrEvent {
}
interface PolicyRequest {
event: NostrEvent;
event: NostrEventPayload;
receivedAt: number;
sourceType: string;
sourceInfo: string;
@@ -84,42 +76,49 @@ function acceptDecision(id: string): PolicyDecision {
return { id, action: "accept", msg: "" };
}
function shadowRejectDecision(id: string): PolicyDecision {
return { id, action: "shadowReject", msg: "" };
}
// ── GET /relay/policy ─────────────────────────────────────────────────────────
router.get("/relay/policy", async (_req: Request, res: Response) => {
const probeId = "0000000000000000000000000000000000000000000000000000000000000000";
const probe = await evaluatePolicy(probeId, "probe-pubkey-not-real", 1);
res.json({
ok: true,
secretConfigured: !!RELAY_POLICY_SECRET,
decision: probe.action,
msg: probe.msg,
info: "Relay policy active. write+elite → accept; write+non-elite → moderation queue; read/none → reject.",
});
});
// ── POST /relay/policy ────────────────────────────────────────────────────────
// ── Auth middleware ───────────────────────────────────────────────────────────
router.post("/relay/policy", async (req: Request, res: Response) => {
// ── Authentication ───────────────────────────────────────────────────────
function checkRelayAuth(req: Request, res: Response): boolean {
if (RELAY_POLICY_SECRET) {
const authHeader = req.headers["authorization"] ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
if (token !== RELAY_POLICY_SECRET) {
res.status(401).json({ error: "Unauthorized" });
return;
return false;
}
} else {
const ip = req.ip ?? "";
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
if (!isLocal) {
logger.warn("relay/policy: no secret configured, rejecting non-local call", { ip });
res.status(401).json({ error: "Unauthorized" });
return;
}
logger.warn("relay/policy: RELAY_POLICY_SECRET not set — accepting local-only call");
return true;
}
// ── Validate body ────────────────────────────────────────────────────────
const ip = req.ip ?? "";
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
if (!isLocal) {
logger.warn("relay/policy: no secret configured, rejecting non-local call", { ip });
res.status(401).json({ error: "Unauthorized" });
return false;
}
logger.warn("relay/policy: RELAY_POLICY_SECRET not set — accepting local-only call");
return true;
}
// ── POST /relay/policy ────────────────────────────────────────────────────────
router.post("/relay/policy", async (req: Request, res: Response) => {
if (!checkRelayAuth(req, res)) return;
const body = req.body as Partial<PolicyRequest>;
const event = body.event;
@@ -132,8 +131,7 @@ router.post("/relay/policy", async (req: Request, res: Response) => {
const pubkey = typeof event.pubkey === "string" ? event.pubkey : "";
const kind = typeof event.kind === "number" ? event.kind : -1;
// ── Policy decision ──────────────────────────────────────────────────────
const decision = await evaluatePolicy(eventId, pubkey, kind);
const decision = await evaluatePolicy(event, eventId, pubkey, kind);
logger.info("relay policy decision", {
eventId: eventId.slice(0, 8),
@@ -149,21 +147,22 @@ router.post("/relay/policy", async (req: Request, res: Response) => {
/**
* Core write-policy evaluation.
*
* Checks relay_accounts for the event's pubkey:
* "write" access → accept
* "read" / "none" / missing → reject
*
* Future tasks extend this function (moderation queue, shadowReject for spam).
* 1. No pubkey → reject
* 2. Not in relay_accounts with "write" → reject (or "read-only" msg)
* 3. "write" + elite tier → inject into strfry + accept (elite bypass)
* 4. "write" + non-elite → enqueue into moderation + shadowReject
*/
async function evaluatePolicy(
rawEvent: Partial<NostrEventPayload>,
eventId: string,
pubkey: string,
_kind: number,
kind: number,
): Promise<PolicyDecision> {
if (!pubkey) {
return rejectDecision(eventId, "missing pubkey");
}
// ── Step 1: Check relay access ─────────────────────────────────────────────
let accessLevel: string;
try {
accessLevel = await relayAccountService.getAccess(pubkey);
@@ -172,15 +171,60 @@ async function evaluatePolicy(
return rejectDecision(eventId, "policy service error — try again later");
}
if (accessLevel === "write") {
return acceptDecision(eventId);
}
if (accessLevel === "read") {
return rejectDecision(eventId, "read-only access — write not permitted");
}
return rejectDecision(eventId, "pubkey not whitelisted for this relay");
if (accessLevel !== "write") {
return rejectDecision(eventId, "pubkey not whitelisted for this relay");
}
// ── Step 2: Check trust tier (elite bypass) ────────────────────────────────
let isElite = false;
try {
const rows = await db
.select({ tier: nostrIdentities.tier })
.from(nostrIdentities)
.where(eq(nostrIdentities.pubkey, pubkey))
.limit(1);
isElite = rows[0]?.tier === "elite";
} catch (err) {
logger.error("tier lookup failed — treating as non-elite", { err });
}
if (isElite) {
// Elite accounts bypass moderation — inject directly into strfry
const rawJson = JSON.stringify(rawEvent);
const injectResult = await injectEvent(rawJson);
if (!injectResult.ok) {
logger.warn("elite event inject failed — shadowReject as fallback", {
eventId: eventId.slice(0, 8),
error: injectResult.error,
});
return shadowRejectDecision(eventId);
}
return acceptDecision(eventId);
}
// ── Step 3: Non-elite write — enqueue for moderation ──────────────────────
try {
await moderationService.enqueue({
id: eventId,
pubkey,
kind,
rawJson: JSON.stringify(rawEvent),
});
} catch (err) {
logger.error("failed to enqueue event for moderation", {
eventId: eventId.slice(0, 8),
err,
});
// Fail-closed: reject if we can't queue
return rejectDecision(eventId, "moderation service error — try again later");
}
// shadowReject: strfry reports "ok" to the sender but doesn't publish
return shadowRejectDecision(eventId);
}
export default router;