Files
timmy-tower/infrastructure/relay-policy/index.ts
alexpaynex cdd97922d5 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
2026-03-19 20:02:00 +00:00

161 lines
5.5 KiB
TypeScript

/**
* relay-policy — Nostr relay write-policy sidecar
*
* Receives strfry plugin decision requests (one JSON object per HTTP POST to
* /decide) and forwards them to the API server's /api/relay/policy endpoint.
* Translates the API server's structured response into the flat decision JSON
* that strfry's plugin protocol expects.
*
* Environment:
* PORT — HTTP port to listen on (default: 3080)
* RELAY_API_URL — base URL of the API server, no trailing slash
* e.g. https://alexanderwhitestone.com
* RELAY_POLICY_SECRET — shared secret; sent as Bearer token to the API
* so the /api/relay/policy route can verify origin
*
* strfry plugin stdin/stdout protocol:
* Input per event: {"event":{...},"receivedAt":N,"sourceType":"...","sourceInfo":"..."}
* Output per event: {"id":"<event-id>","action":"accept|reject|shadowReject","msg":"..."}
*/
import * as http from "http";
const PORT = parseInt(process.env["PORT"] ?? "3080", 10);
const API_URL = (process.env["RELAY_API_URL"] ?? "").replace(/\/$/, "");
const SECRET = process.env["RELAY_POLICY_SECRET"] ?? "";
if (!API_URL) {
console.error("[relay-policy] RELAY_API_URL is not set — rejecting all events");
}
if (!SECRET) {
console.warn("[relay-policy] RELAY_POLICY_SECRET is not set — all API calls will be unauthenticated");
}
// ── Decision types ────────────────────────────────────────────────────────────
type StrfryAction = "accept" | "reject" | "shadowReject";
interface StrfryInput {
event: {
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
};
receivedAt: number;
sourceType: string;
sourceInfo: string;
}
interface StrfryDecision {
id: string;
action: StrfryAction;
msg: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function rejectDecision(id: string, msg: string): StrfryDecision {
return { id, action: "reject", msg };
}
/**
* Forward the strfry event payload to the API server and return its decision.
* Falls back to reject if the API call fails or times out.
*/
async function fetchPolicyDecision(payload: StrfryInput): Promise<StrfryDecision> {
const eventId = payload.event?.id ?? "unknown";
if (!API_URL) {
return rejectDecision(eventId, "relay-policy: no API URL configured");
}
const url = `${API_URL}/api/relay/policy`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (SECRET) {
headers["Authorization"] = `Bearer ${SECRET}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
console.error(`[relay-policy] API returned ${res.status} for event ${eventId.slice(0, 8)}`);
return rejectDecision(eventId, `policy API error: HTTP ${res.status}`);
}
const json = await res.json() as { action?: StrfryAction; msg?: string };
const action: StrfryAction =
json.action === "accept" || json.action === "shadowReject" ? json.action : "reject";
return { id: eventId, action, msg: json.msg ?? "" };
} catch (err) {
clearTimeout(timer);
const reason = err instanceof Error ? err.message : String(err);
console.warn(`[relay-policy] policy fetch failed for ${eventId.slice(0, 8)}: ${reason}`);
return rejectDecision(eventId, `policy fetch failed: ${reason}`);
}
}
// ── HTTP server ───────────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
const url = req.url ?? "/";
const method = req.method ?? "GET";
// Health-check
if (method === "GET" && url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, apiUrl: API_URL || null }));
return;
}
// Decision endpoint — called by relay-policy-plugin shell script
if (method === "POST" && url === "/decide") {
let body = "";
req.on("data", chunk => { body += chunk; });
req.on("end", async () => {
let payload: StrfryInput;
try {
payload = JSON.parse(body) as StrfryInput;
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify(rejectDecision("unknown", "invalid JSON")));
return;
}
const decision = await fetchPolicyDecision(payload);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(decision));
});
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[relay-policy] listening on :${PORT}`);
console.log(`[relay-policy] API URL: ${API_URL || "(not set — all events will be rejected)"}`);
});
server.on("error", (err) => {
console.error("[relay-policy] server error:", err);
process.exit(1);
});