/** * relay.ts — Nostr relay write-policy API * * POST /api/relay/policy * Internal endpoint called exclusively by the relay-policy sidecar. * Protected by RELAY_POLICY_SECRET Bearer token. * * 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). */ 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(); const RELAY_POLICY_SECRET = process.env["RELAY_POLICY_SECRET"] ?? ""; const IS_PROD = process.env["NODE_ENV"] === "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.", ); } else { logger.warn("RELAY_POLICY_SECRET not set — /api/relay/policy accepts local-only requests (dev mode)"); } } // ── Types ───────────────────────────────────────────────────────────────────── type PolicyAction = "accept" | "reject" | "shadowReject"; interface NostrEventPayload { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string; } interface PolicyRequest { event: NostrEventPayload; receivedAt: number; sourceType: string; sourceInfo: string; } interface PolicyDecision { id: string; action: PolicyAction; msg: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── function rejectDecision(id: string, msg: string): PolicyDecision { return { id, action: "reject", msg }; } 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) => { res.json({ ok: true, secretConfigured: !!RELAY_POLICY_SECRET, info: "Relay policy active. write+elite → accept; write+non-elite → moderation queue; read/none → reject.", }); }); // ── Auth middleware ─────────────────────────────────────────────────────────── 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 false; } return true; } 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; const event = body.event; if (!event || typeof event !== "object") { res.status(400).json({ error: "Missing 'event' in request body" }); return; } const eventId = typeof event.id === "string" ? event.id : "unknown"; const pubkey = typeof event.pubkey === "string" ? event.pubkey : ""; const kind = typeof event.kind === "number" ? event.kind : -1; const decision = await evaluatePolicy(event, eventId, pubkey, kind); logger.info("relay policy decision", { eventId: eventId.slice(0, 8), pubkey: pubkey.slice(0, 8), kind, action: decision.action, sourceType: body.sourceType, }); res.json(decision); }); /** * Core write-policy evaluation. * * 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, eventId: string, pubkey: string, kind: number, ): Promise { if (!pubkey) { return rejectDecision(eventId, "missing pubkey"); } // ── Step 1: Check relay access ───────────────────────────────────────────── let accessLevel: string; try { accessLevel = await relayAccountService.getAccess(pubkey); } catch (err) { logger.error("relay-accounts lookup failed — defaulting to reject", { err }); return rejectDecision(eventId, "policy service error — try again later"); } if (accessLevel === "read") { return rejectDecision(eventId, "read-only access — write not permitted"); } 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. // On inject failure, return hard reject so the client knows to retry // (shadowReject would silently drop the event from the sender's perspective). const rawJson = JSON.stringify(rawEvent); const injectResult = await injectEvent(rawJson); if (!injectResult.ok) { logger.warn("elite event inject failed — returning reject so client can retry", { eventId: eventId.slice(0, 8), error: injectResult.error, }); return rejectDecision(eventId, "relay unavailable — please retry"); } 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;