## 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
161 lines
5.5 KiB
TypeScript
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);
|
|
});
|