This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
token-gated-economy/artifacts/api-server/src/routes/relay.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

148 lines
5.1 KiB
TypeScript

/**
* 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;