task/30: Sovereign Nostr relay infrastructure (strfry)
## 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
This commit is contained in:
@@ -12,6 +12,7 @@ import metricsRouter from "./metrics.js";
|
||||
import worldRouter from "./world.js";
|
||||
import identityRouter from "./identity.js";
|
||||
import estimateRouter from "./estimate.js";
|
||||
import relayRouter from "./relay.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -22,6 +23,7 @@ router.use(estimateRouter);
|
||||
router.use(bootstrapRouter);
|
||||
router.use(sessionsRouter);
|
||||
router.use(identityRouter);
|
||||
router.use(relayRouter);
|
||||
router.use(demoRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
|
||||
147
artifacts/api-server/src/routes/relay.ts
Normal file
147
artifacts/api-server/src/routes/relay.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user