/** * relay.ts — Nostr relay write-policy API * * 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. * * 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 } * * Bootstrap state (Task #36): * All events are rejected until the account whitelist is implemented. * The endpoint and decision contract are stable; future tasks extend the * logic without changing the API shape. */ import { Router, type Request, type Response } from "express"; import { makeLogger } from "../lib/logger.js"; const logger = makeLogger("relay-policy"); const router = Router(); const RELAY_POLICY_SECRET = process.env["RELAY_POLICY_SECRET"] ?? ""; // ── Types ───────────────────────────────────────────────────────────────────── type PolicyAction = "accept" | "reject" | "shadowReject"; interface NostrEvent { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string; } interface PolicyRequest { event: NostrEvent; receivedAt: number; sourceType: string; sourceInfo: string; } interface PolicyDecision { id: string; action: PolicyAction; msg: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── function reject(id: string, msg: string): PolicyDecision { return { id, action: "reject", msg }; } // ── POST /relay/policy ──────────────────────────────────────────────────────── router.post("/relay/policy", (req: Request, res: Response) => { // ── Authentication — Bearer token must match RELAY_POLICY_SECRET ────────── if (RELAY_POLICY_SECRET) { const authHeader = req.headers["authorization"] ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : ""; if (token !== RELAY_POLICY_SECRET) { // Use constant-time-ish comparison for secret matching res.status(401).json({ error: "Unauthorized" }); return; } } else { // No secret configured — warn and allow only from localhost/loopback 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 calls"); } // ── Validate request body ───────────────────────────────────────────────── 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; // ── Policy decision ─────────────────────────────────────────────────────── // // Bootstrap state: reject everything. // // This is intentional — the relay is deployed but closed until the account // whitelist (Task #37) is implemented. Once the whitelist route is live, // this function will: // 1. Check nostr_identities whitelist for pubkey // 2. Check event pre-approval queue for moderated content // 3. Return accept / shadowReject based on tier and moderation status // const decision = evaluatePolicy(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); }); /** * Evaluate the write policy for an incoming event. * * Bootstrap: all events rejected until whitelist is implemented (Task #37). * The function signature is stable — future tasks replace the body. */ function evaluatePolicy( eventId: string, pubkey: string, _kind: number, ): PolicyDecision { void pubkey; // will be used in Task #37 whitelist check return reject( eventId, "relay not yet open — whitelist pending (Task #37)", ); } export default router;