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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
3
infrastructure/relay-policy/.gitignore
vendored
Normal file
3
infrastructure/relay-policy/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
18
infrastructure/relay-policy/Dockerfile
Normal file
18
infrastructure/relay-policy/Dockerfile
Normal 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"]
|
||||
160
infrastructure/relay-policy/index.ts
Normal file
160
infrastructure/relay-policy/index.ts
Normal 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);
|
||||
});
|
||||
14
infrastructure/relay-policy/package.json
Normal file
14
infrastructure/relay-policy/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
infrastructure/relay-policy/plugin.sh
Executable file
36
infrastructure/relay-policy/plugin.sh
Executable 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
|
||||
16
infrastructure/relay-policy/tsconfig.json
Normal file
16
infrastructure/relay-policy/tsconfig.json
Normal 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"]
|
||||
}
|
||||
63
infrastructure/strfry.conf
Normal file
63
infrastructure/strfry.conf
Normal 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 20000–29999) — needed for NIP-04 DMs.
|
||||
rejectEphemeral = false
|
||||
}
|
||||
Reference in New Issue
Block a user