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
233 lines
7.9 KiB
TypeScript
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;
|