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:
alexpaynex
2026-03-19 20:02:00 +00:00
parent 0b3a701933
commit cdd97922d5
11 changed files with 547 additions and 0 deletions

View File

@@ -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);

View 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;

View File

@@ -66,6 +66,58 @@ services:
networks:
- node-net
# ── Nostr relay ──────────────────────────────────────────────────────────────
relay-policy:
# Write-policy sidecar: receives strfry plugin decisions, forwards to API server.
# Started before strfry so the plugin script can immediately reach it.
build:
context: ./relay-policy
dockerfile: Dockerfile
container_name: relay-policy
restart: unless-stopped
environment:
- PORT=3080
# Base URL of the Timmy API server (no trailing slash).
# Example: https://alexanderwhitestone.com
- RELAY_API_URL=${RELAY_API_URL:-}
# Shared secret — must match RELAY_POLICY_SECRET in the API server's env.
- RELAY_POLICY_SECRET=${RELAY_POLICY_SECRET:-}
networks:
- node-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3080/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
strfry:
# strfry — high-performance Nostr relay written in C++.
# All inbound events pass through the relay-policy sidecar before storage.
image: ghcr.io/hoytech/strfry:latest
container_name: strfry
restart: unless-stopped
depends_on:
relay-policy:
condition: service_healthy
volumes:
- strfry_data:/data/strfry-db
- ./strfry.conf:/etc/strfry.conf:ro
# Plugin bridge script: reads events from strfry stdin, calls relay-policy HTTP.
- ./relay-policy/plugin.sh:/usr/local/bin/relay-policy-plugin:ro
ports:
- "7777:7777"
command: ["strfry", "--config=/etc/strfry.conf", "relay"]
networks:
- node-net
healthcheck:
test: ["CMD", "sh", "-c", "echo >/dev/tcp/localhost/7777"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
networks:
node-net:
driver: bridge
@@ -89,3 +141,12 @@ volumes:
type: none
o: bind
device: /data/lnbits
strfry_data:
# Persistent event database — survives container restarts and upgrades.
# Operator must create /data/strfry on the VPS before first launch:
# mkdir -p /data/strfry && chmod 700 /data/strfry
driver: local
driver_opts:
type: none
o: bind
device: /data/strfry

View File

@@ -318,6 +318,29 @@ CONF
bash "$INFRA_DIR/sweep.sh"
;;
relay:logs)
echo -e "${CYAN}Tailing strfry relay logs (Ctrl-C to stop)...${NC}"
docker compose logs -f --tail=100 strfry relay-policy
;;
relay:restart)
echo -e "${CYAN}Restarting Nostr relay services...${NC}"
docker compose restart relay-policy
sleep 2
docker compose restart strfry
echo -e "${GREEN}Done — relay services restarted.${NC}"
docker compose ps relay-policy strfry
;;
relay:status)
echo -e "\n${CYAN}── Nostr relay ───────────────────────────────${NC}"
docker compose ps relay-policy strfry
echo -e "\n${CYAN}── relay-policy health ───────────────────────${NC}"
docker exec relay-policy wget -qO- http://localhost:3080/health 2>/dev/null \
| (command -v jq >/dev/null 2>&1 && jq . || cat) \
|| echo "relay-policy not ready"
;;
help|*)
echo -e "\n${CYAN}Timmy Node operations:${NC}"
echo ""
@@ -335,6 +358,10 @@ CONF
echo " bash ops.sh configure-sweep — interactively set address, thresholds, frequency"
echo " bash ops.sh run-sweep — run sweep immediately (outside of cron schedule)"
echo ""
echo " bash ops.sh relay:logs — tail strfry + relay-policy logs"
echo " bash ops.sh relay:restart — restart relay-policy then strfry (safe order)"
echo " bash ops.sh relay:status — show relay container status + health"
echo ""
;;
esac

View File

@@ -0,0 +1,3 @@
node_modules/
dist/
package-lock.json

View File

@@ -0,0 +1,18 @@
## relay-policy sidecar — multi-stage build
##
## Stage 1: compile TypeScript to ES modules
## Stage 2: minimal runtime image (no dev deps, no compiler)
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY index.ts ./
RUN npx tsc
# ── runtime ──────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist/index.js ./index.js
EXPOSE 3080
CMD ["node", "index.js"]

View File

@@ -0,0 +1,160 @@
/**
* 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);
});

View File

@@ -0,0 +1,14 @@
{
"name": "relay-policy",
"version": "1.0.0",
"description": "strfry write-policy sidecar for Timmy's sovereign Nostr relay",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
##
## relay-policy-plugin — strfry write-policy plugin
##
## strfry starts this script once and feeds it JSON lines on stdin (one per
## event). The script forwards each line to the relay-policy HTTP sidecar and
## echoes the sidecar's JSON decision to stdout. If the sidecar is unavailable
## the event is rejected with a safe fallback so the relay does not accept
## unapproved events during a transient outage.
##
## stdin format: {"event":{...},"receivedAt":N,"sourceType":"...","sourceInfo":"..."}
## stdout format: {"id":"<event-id>","action":"accept|reject|shadowReject","msg":"..."}
##
RELAY_POLICY_URL="${RELAY_POLICY_URL:-http://relay-policy:3080/decide}"
while IFS= read -r line; do
# Extract event id for the fallback response — pure bash, no external tools.
event_id=$(printf '%s' "$line" \
| grep -o '"id":"[^"]*"' \
| head -1 \
| sed 's/"id":"//; s/"//')
decision=$(printf '%s' "$line" \
| curl -sf --max-time 5 \
-X POST "$RELAY_POLICY_URL" \
-H "Content-Type: application/json" \
--data-binary @- 2>/dev/null)
if [[ -z "$decision" ]]; then
printf '{"id":"%s","action":"reject","msg":"policy service unavailable"}\n' \
"${event_id:-unknown}"
else
printf '%s\n' "$decision"
fi
done

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"lib": ["ES2022"]
},
"include": ["index.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,63 @@
##
## strfry.conf — Timmy's sovereign Nostr relay
##
## All events pass through the relay-policy sidecar before being accepted.
## No event is stored without explicit approval from the API server.
##
db = "/data/strfry-db"
relay {
bind = "0.0.0.0"
port = 7777
info {
name = "Timmy Relay"
description = "Timmy's sovereign Nostr relay — whitelist-only"
pubkey = ""
contact = ""
icon = ""
}
# Maximum WebSocket payload size (bytes).
# 131072 = 128 KiB — generous for NIP-09 deletions but not abusable.
maxWebsocketPayloadSize = 131072
autoPingSeconds = 55
enableTcpKeepalive = false
queryTimesliceBudgetMicroseconds = 10000
maxFilterLimit = 500
maxSubsPerConnection = 20
writePolicy {
# Plugin receives JSON lines on stdin, writes decision lines to stdout.
# The plugin script bridges each event to the relay-policy HTTP sidecar.
plugin = "/usr/local/bin/relay-policy-plugin"
}
compression {
enabled = true
slidingWindow = true
}
logging {
dumpInAll = false
dumpInEvents = false
dumpInReqs = false
dbScanPerf = false
quiet = false
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 1
negentropy = 2
}
# Accept events up to 64 KiB — NIP-01 compliant maximum.
maxEventSize = 65536
# Allow ephemeral events (kinds 2000029999) — needed for NIP-04 DMs.
rejectEphemeral = false
}