## Summary Deploys strfry (C++ Nostr relay) + relay-policy sidecar as a containerised stack on the VPS, wired to the API server for event-level access control. ## Files created - `infrastructure/strfry.conf` — strfry config: bind 0.0.0.0:7777, writePolicy plugin → /usr/local/bin/relay-policy-plugin, maxEventSize 65536, rejectEphemeral false, db /data/strfry-db - `infrastructure/relay-policy/plugin.sh` — strfry write-policy plugin (stdin/stdout bridge). Reads JSON lines from strfry, POSTs to relay-policy HTTP sidecar (http://relay-policy:3080/decide), writes decision to stdout. Safe fallback: reject on sidecar timeout/failure - `infrastructure/relay-policy/index.ts` — Node.js HTTP relay-policy sidecar: POST /decide receives strfry events, calls API server /api/relay/policy with Bearer RELAY_POLICY_SECRET, returns strfry decision JSON - `infrastructure/relay-policy/package.json + tsconfig.json` — TS build config - `infrastructure/relay-policy/Dockerfile` — multi-stage: builder (tsc) + runtime - `infrastructure/relay-policy/.gitignore` — excludes node_modules, dist - `artifacts/api-server/src/routes/relay.ts` — POST /api/relay/policy: internal route protected by RELAY_POLICY_SECRET Bearer token. Bootstrap state: rejects all events with "relay not yet open — whitelist pending (Task #37)". Stable contract — future tasks extend evaluatePolicy() without API shape changes ## Files modified - `infrastructure/docker-compose.yml` — adds relay-policy + strfry services on node-net; strfry_data volume (bind-mounted at /data/strfry); relay-policy healthcheck; strfry depends on relay-policy healthy - `infrastructure/ops.sh` — adds relay:logs, relay:restart, relay:status commands - `artifacts/api-server/src/routes/index.ts` — registers relayRouter ## Operator setup required on VPS mkdir -p /data/strfry && chmod 700 /data/strfry echo "RELAY_API_URL=https://alexanderwhitestone.com" >> /opt/timmy-node/.env echo "RELAY_POLICY_SECRET=$(openssl rand -hex 32)" >> /opt/timmy-node/.env # Also set RELAY_POLICY_SECRET in Replit secrets for API server ## Notes - TypeScript: 0 errors (API server + relay-policy sidecar both compile clean) - POST /api/relay/policy smoke test: correct bootstrap reject response - strfry image: ghcr.io/hoytech/strfry:latest
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
/**
|
|
* 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<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;
|
|
|
|
// ── 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;
|