Files
timmy-tower/artifacts/api-server/src/routes/relay.ts
alexpaynex f5c2c7e8c2 Improve handling of failed moderation bypasses for elite accounts
Update relay.ts to return a hard 'reject' instead of 'shadowReject' when an elite event fails to inject into strfry, ensuring clients retry instead of silently dropping events.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ddd878c8-77fd-4ad2-852d-2644c94b18da
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
2026-03-19 20:38:51 +00:00

233 lines
7.9 KiB
TypeScript

/**
* 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<PolicyRequest>;
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<NostrEventPayload>,
eventId: string,
pubkey: string,
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);
} 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;