/** * 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"] ?? ""; const IS_PROD = process.env["NODE_ENV"] === "production"; // Production enforcement: RELAY_POLICY_SECRET must be set in production. // An unprotected relay policy endpoint in production allows any caller on the // network to whitelist events — a serious trust-system bypass. 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.", ); } else { logger.warn( "RELAY_POLICY_SECRET not set — /api/relay/policy accepts local-only requests (dev mode)", ); } } // ── 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 }; } // ── GET /relay/policy ───────────────────────────────────────────────────────── // Health + roundtrip probe. Returns the relay's current policy state and runs // a synthetic event through evaluatePolicy() so operators can verify the full // sidecar → API path with: curl https://alexanderwhitestone.com/api/relay/policy // // Not secret-gated — it contains no privileged information. router.get("/relay/policy", (_req: Request, res: Response) => { const probe = evaluatePolicy("0000000000000000000000000000000000000000000000000000000000000000", "probe", 1); res.json({ ok: true, secretConfigured: !!RELAY_POLICY_SECRET, bootstrapDecision: probe.action, bootstrapMsg: probe.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;