task/31: Relay account whitelist + trust-gated access
## What was built
Full relay access control system: relay_accounts table, RelayAccountService,
trust hook integration, live policy enforcement, admin CRUD API, Timmy seed.
## DB change
`lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table:
pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE),
access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"),
granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`.
`lib/db/src/schema/index.ts` — exports relay-accounts.
## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`)
- getAccess(pubkey) → RelayAccessLevel (none if missing or revoked)
- grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK
- revoke(pubkey, reason) — sets revokedAt, access_level → none
- syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants
- list(opts) — returns all accounts, optionally filtered to active
- Tier→access map: new=none, established/trusted/elite=write (env-overridable)
## Trust hook (`artifacts/api-server/src/lib/trust.ts`)
recordSuccess + recordFailure both call syncFromTrustTier after writing tier.
Failure is caught + logged (non-blocking — trust flow never fails on relay error).
## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`)
evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey).
"write" → accept; "read"/"none"/missing → reject with clear message.
DB error → reject with "policy service error" (safe fail-closed).
## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`)
ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod).
GET /api/admin/relay/accounts — list all accounts
POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body)
POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body)
pubkey validation: must be 64-char lowercase hex.
## Startup seed (`artifacts/api-server/src/index.ts`)
On every startup: grants Timmy's own pubkeyHex "write" access ("manual").
Idempotent upsert — safe across restarts.
## Smoke test results (all pass)
- Timmy pubkey → accept ✓; unknown pubkey → reject ✓
- Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓
- TypeScript: 0 errors in API server + lib/db
This commit is contained in:
@@ -4,6 +4,7 @@ import { attachWebSocketServer } from "./routes/events.js";
|
||||
import { rootLogger } from "./lib/logger.js";
|
||||
import { timmyIdentityService } from "./lib/timmy-identity.js";
|
||||
import { startEngagementEngine } from "./lib/engagement.js";
|
||||
import { relayAccountService } from "./lib/relay-accounts.js";
|
||||
|
||||
const rawPort = process.env["PORT"];
|
||||
|
||||
@@ -30,4 +31,17 @@ server.listen(port, () => {
|
||||
rootLogger.info("ws url", { url: `wss://${domain}/api/ws` });
|
||||
}
|
||||
startEngagementEngine();
|
||||
|
||||
// Seed Timmy's own pubkey with elite relay access on every startup.
|
||||
// This is idempotent — upsert is safe to run multiple times.
|
||||
relayAccountService
|
||||
.grant(timmyIdentityService.pubkeyHex, "write", "Timmy's own pubkey — elite access", "manual")
|
||||
.then(() =>
|
||||
rootLogger.info("relay: Timmy's pubkey seeded with write access", {
|
||||
pubkey: timmyIdentityService.pubkeyHex.slice(0, 8),
|
||||
}),
|
||||
)
|
||||
.catch((err) =>
|
||||
rootLogger.warn("relay: failed to seed Timmy's pubkey", { err }),
|
||||
);
|
||||
});
|
||||
|
||||
183
artifacts/api-server/src/lib/relay-accounts.ts
Normal file
183
artifacts/api-server/src/lib/relay-accounts.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* relay-accounts.ts — Relay account whitelist + access management.
|
||||
*
|
||||
* Trust tier → access level defaults (env-overridable):
|
||||
* new → none (RELAY_ACCESS_NEW, default "none")
|
||||
* established → write (RELAY_ACCESS_ESTABLISHED, default "write")
|
||||
* trusted → write (RELAY_ACCESS_TRUSTED, default "write")
|
||||
* elite → write (RELAY_ACCESS_ELITE, default "write")
|
||||
*
|
||||
* Only "write" access generates an "accept" from the relay policy.
|
||||
* "read" is reserved for future read-gated relays.
|
||||
* "none" = default deny.
|
||||
*/
|
||||
|
||||
import { db, nostrIdentities, relayAccounts } from "@workspace/db";
|
||||
import type { RelayAccessLevel, TrustTier } from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("relay-accounts");
|
||||
|
||||
// ── Tier → access level mapping ───────────────────────────────────────────────
|
||||
|
||||
function envAccess(name: string, fallback: RelayAccessLevel): RelayAccessLevel {
|
||||
const v = process.env[name]?.toLowerCase();
|
||||
if (v === "write" || v === "read" || v === "none") return v;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const TIER_ACCESS: Record<TrustTier, RelayAccessLevel> = {
|
||||
new: envAccess("RELAY_ACCESS_NEW", "none"),
|
||||
established: envAccess("RELAY_ACCESS_ESTABLISHED", "write"),
|
||||
trusted: envAccess("RELAY_ACCESS_TRUSTED", "write"),
|
||||
elite: envAccess("RELAY_ACCESS_ELITE", "write"),
|
||||
};
|
||||
|
||||
// ── RelayAccountService ───────────────────────────────────────────────────────
|
||||
|
||||
export class RelayAccountService {
|
||||
/**
|
||||
* Returns the current access level for a pubkey.
|
||||
* If the pubkey has no relay_accounts row, OR has a revoked row → "none".
|
||||
*/
|
||||
async getAccess(pubkey: string): Promise<RelayAccessLevel> {
|
||||
const rows = await db
|
||||
.select({ accessLevel: relayAccounts.accessLevel, revokedAt: relayAccounts.revokedAt })
|
||||
.from(relayAccounts)
|
||||
.where(eq(relayAccounts.pubkey, pubkey))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row || row.revokedAt !== null) return "none";
|
||||
return row.accessLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant (or upgrade) relay access for a pubkey.
|
||||
* If the pubkey does not exist in nostr_identities it is upserted first
|
||||
* so the FK constraint is satisfied.
|
||||
*/
|
||||
async grant(
|
||||
pubkey: string,
|
||||
level: RelayAccessLevel,
|
||||
reason: string,
|
||||
grantedBy: "manual" | "auto-tier" = "manual",
|
||||
): Promise<void> {
|
||||
// Ensure FK target exists
|
||||
await db
|
||||
.insert(nostrIdentities)
|
||||
.values({ pubkey })
|
||||
.onConflictDoNothing();
|
||||
|
||||
await db
|
||||
.insert(relayAccounts)
|
||||
.values({
|
||||
pubkey,
|
||||
accessLevel: level,
|
||||
grantedBy,
|
||||
grantedAt: new Date(),
|
||||
revokedAt: null,
|
||||
notes: reason,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: relayAccounts.pubkey,
|
||||
set: {
|
||||
accessLevel: level,
|
||||
grantedBy,
|
||||
grantedAt: new Date(),
|
||||
revokedAt: null,
|
||||
notes: reason,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("relay access granted", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
level,
|
||||
grantedBy,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke relay access for a pubkey.
|
||||
* Sets revokedAt and resets access_level to "none".
|
||||
* No-op if pubkey has no row.
|
||||
*/
|
||||
async revoke(pubkey: string, reason?: string): Promise<void> {
|
||||
await db
|
||||
.update(relayAccounts)
|
||||
.set({
|
||||
accessLevel: "none",
|
||||
revokedAt: new Date(),
|
||||
notes: reason ?? null,
|
||||
})
|
||||
.where(eq(relayAccounts.pubkey, pubkey));
|
||||
|
||||
logger.info("relay access revoked", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync relay access from the current trust tier.
|
||||
* Only updates rows that were auto-granted (grantedBy = "auto-tier").
|
||||
* Manually-granted rows are never downgraded by this method.
|
||||
* Upgrades always apply regardless of grantedBy.
|
||||
*/
|
||||
async syncFromTrustTier(pubkey: string, tier: TrustTier): Promise<void> {
|
||||
const targetLevel = TIER_ACCESS[tier];
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(relayAccounts)
|
||||
.where(eq(relayAccounts.pubkey, pubkey))
|
||||
.limit(1);
|
||||
|
||||
const existing = rows[0];
|
||||
|
||||
if (!existing) {
|
||||
// No row yet — only create one if the tier earns access
|
||||
if (targetLevel !== "none") {
|
||||
await this.grant(pubkey, targetLevel, `auto: tier promotion to ${tier}`, "auto-tier");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual grants are never downgraded by auto-tier sync
|
||||
if (existing.grantedBy === "manual" && existing.revokedAt === null) {
|
||||
const ACCESS_RANK: Record<RelayAccessLevel, number> = { none: 0, read: 1, write: 2 };
|
||||
if (ACCESS_RANK[targetLevel] <= ACCESS_RANK[existing.accessLevel]) {
|
||||
return; // manual grant already at equal or better level
|
||||
}
|
||||
// Upgrade the manual row (manual stays manual — it's a promotion, not a demotion)
|
||||
await this.grant(pubkey, targetLevel, `auto-upgrade from tier ${tier}`, "manual");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-tier row — sync to current tier's target level
|
||||
if (targetLevel !== existing.accessLevel || existing.revokedAt !== null) {
|
||||
await this.grant(
|
||||
pubkey,
|
||||
targetLevel,
|
||||
`auto: tier ${tier} → ${targetLevel}`,
|
||||
"auto-tier",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all relay accounts (for admin UI).
|
||||
* Optionally filter to active (non-revoked) rows only.
|
||||
*/
|
||||
async list(opts: { activeOnly?: boolean } = {}): Promise<typeof relayAccounts.$inferSelect[]> {
|
||||
const rows = await db.select().from(relayAccounts).orderBy(relayAccounts.grantedAt);
|
||||
if (opts.activeOnly) {
|
||||
return rows.filter((r) => r.revokedAt === null && r.accessLevel !== "none");
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
export const relayAccountService = new RelayAccountService();
|
||||
@@ -2,6 +2,7 @@ import { createHmac, randomBytes } from "crypto";
|
||||
import { db, nostrIdentities, type NostrIdentity, type TrustTier } from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { makeLogger } from "./logger.js";
|
||||
import { relayAccountService } from "./relay-accounts.js";
|
||||
|
||||
const logger = makeLogger("trust");
|
||||
|
||||
@@ -155,6 +156,11 @@ export class TrustService {
|
||||
newTier,
|
||||
satsCost,
|
||||
});
|
||||
|
||||
// Sync relay access whenever the tier may have changed
|
||||
relayAccountService.syncFromTrustTier(pubkey, newTier).catch((err) =>
|
||||
logger.warn("relay sync failed after success", { pubkey: pubkey.slice(0, 8), err }),
|
||||
);
|
||||
}
|
||||
|
||||
// Called after a failed, rejected, or abusive interaction.
|
||||
@@ -183,6 +189,11 @@ export class TrustService {
|
||||
newTier,
|
||||
reason,
|
||||
});
|
||||
|
||||
// Sync relay access on tier change (may revoke write on repeated failures)
|
||||
relayAccountService.syncFromTrustTier(pubkey, newTier).catch((err) =>
|
||||
logger.warn("relay sync failed after failure", { pubkey: pubkey.slice(0, 8), err }),
|
||||
);
|
||||
}
|
||||
|
||||
// Issue a signed identity token for a verified pubkey.
|
||||
|
||||
129
artifacts/api-server/src/routes/admin-relay.ts
Normal file
129
artifacts/api-server/src/routes/admin-relay.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* admin-relay.ts — Admin endpoints for the relay account whitelist.
|
||||
*
|
||||
* All routes are protected by ADMIN_SECRET env var (Bearer token).
|
||||
* If ADMIN_SECRET is not set, the routes reject all requests in production
|
||||
* and accept only from localhost in development.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/admin/relay/accounts — list all relay accounts
|
||||
* POST /api/admin/relay/accounts/:pubkey/grant — grant access to a pubkey
|
||||
* POST /api/admin/relay/accounts/:pubkey/revoke — revoke access from a pubkey
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
import { relayAccountService } from "../lib/relay-accounts.js";
|
||||
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel } from "@workspace/db";
|
||||
|
||||
const logger = makeLogger("admin-relay");
|
||||
const router = Router();
|
||||
|
||||
const ADMIN_SECRET = process.env["ADMIN_SECRET"] ?? "";
|
||||
const IS_PROD = process.env["NODE_ENV"] === "production";
|
||||
|
||||
if (!ADMIN_SECRET) {
|
||||
if (IS_PROD) {
|
||||
logger.error(
|
||||
"ADMIN_SECRET is not set in production — admin relay routes are unprotected. " +
|
||||
"Set ADMIN_SECRET in the API server environment immediately.",
|
||||
);
|
||||
} else {
|
||||
logger.warn("ADMIN_SECRET not set — admin relay routes accept local-only requests (dev mode)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin auth middleware ──────────────────────────────────────────────────────
|
||||
|
||||
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (ADMIN_SECRET) {
|
||||
const authHeader = req.headers["authorization"] ?? "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
||||
if (token !== ADMIN_SECRET) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const ip = req.ip ?? "";
|
||||
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
||||
if (!isLocal) {
|
||||
logger.warn("admin-relay: no secret configured, rejecting non-local call", { ip });
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ── GET /admin/relay/accounts ─────────────────────────────────────────────────
|
||||
|
||||
router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => {
|
||||
const accounts = await relayAccountService.list();
|
||||
res.json({ accounts });
|
||||
});
|
||||
|
||||
// ── POST /admin/relay/accounts/:pubkey/grant ──────────────────────────────────
|
||||
|
||||
router.post(
|
||||
"/admin/relay/accounts/:pubkey/grant",
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const { pubkey } = req.params as { pubkey: string };
|
||||
|
||||
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
|
||||
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { level?: string; notes?: string };
|
||||
const level = (body.level ?? "write").toLowerCase() as RelayAccessLevel;
|
||||
|
||||
if (!RELAY_ACCESS_LEVELS.includes(level)) {
|
||||
res.status(400).json({
|
||||
error: `Invalid access level '${level}'. Must be one of: ${RELAY_ACCESS_LEVELS.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = typeof body.notes === "string" ? body.notes : "admin grant";
|
||||
|
||||
await relayAccountService.grant(pubkey, level, notes, "manual");
|
||||
|
||||
logger.info("admin granted relay access", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
level,
|
||||
notes,
|
||||
});
|
||||
|
||||
res.json({ ok: true, pubkey, accessLevel: level, notes });
|
||||
},
|
||||
);
|
||||
|
||||
// ── POST /admin/relay/accounts/:pubkey/revoke ─────────────────────────────────
|
||||
|
||||
router.post(
|
||||
"/admin/relay/accounts/:pubkey/revoke",
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const { pubkey } = req.params as { pubkey: string };
|
||||
|
||||
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
|
||||
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { reason?: string };
|
||||
const reason = typeof body.reason === "string" ? body.reason : "admin revoke";
|
||||
|
||||
await relayAccountService.revoke(pubkey, reason);
|
||||
|
||||
logger.info("admin revoked relay access", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
reason,
|
||||
});
|
||||
|
||||
res.json({ ok: true, pubkey, revokedAt: new Date().toISOString(), reason });
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -13,6 +13,7 @@ import worldRouter from "./world.js";
|
||||
import identityRouter from "./identity.js";
|
||||
import estimateRouter from "./estimate.js";
|
||||
import relayRouter from "./relay.js";
|
||||
import adminRelayRouter from "./admin-relay.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -24,6 +25,7 @@ router.use(bootstrapRouter);
|
||||
router.use(sessionsRouter);
|
||||
router.use(identityRouter);
|
||||
router.use(relayRouter);
|
||||
router.use(adminRelayRouter);
|
||||
router.use(demoRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* 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.
|
||||
* GET /api/relay/policy
|
||||
* Health + roundtrip probe. No auth required — returns policy state and runs
|
||||
* a synthetic pubkey through evaluatePolicy().
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
import { relayAccountService } from "../lib/relay-accounts.js";
|
||||
|
||||
const logger = makeLogger("relay-policy");
|
||||
const router = Router();
|
||||
@@ -33,8 +33,6 @@ const RELAY_POLICY_SECRET = process.env["RELAY_POLICY_SECRET"] ?? "";
|
||||
const IS_PROD = process.env["NODE_ENV"] === "production";
|
||||
|
||||
// Production enforcement: RELAY_POLICY_SECRET must be set in production.
|
||||
// An unprotected relay policy endpoint in production allows any caller on the
|
||||
// network to whitelist events — a serious trust-system bypass.
|
||||
if (!RELAY_POLICY_SECRET) {
|
||||
if (IS_PROD) {
|
||||
logger.error(
|
||||
@@ -78,41 +76,39 @@ interface PolicyDecision {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function reject(id: string, msg: string): PolicyDecision {
|
||||
function rejectDecision(id: string, msg: string): PolicyDecision {
|
||||
return { id, action: "reject", msg };
|
||||
}
|
||||
|
||||
// ── GET /relay/policy ─────────────────────────────────────────────────────────
|
||||
// Health + roundtrip probe. Returns the relay's current policy state and runs
|
||||
// a synthetic event through evaluatePolicy() so operators can verify the full
|
||||
// sidecar → API path with: curl https://alexanderwhitestone.com/api/relay/policy
|
||||
//
|
||||
// Not secret-gated — it contains no privileged information.
|
||||
function acceptDecision(id: string): PolicyDecision {
|
||||
return { id, action: "accept", msg: "" };
|
||||
}
|
||||
|
||||
router.get("/relay/policy", (_req: Request, res: Response) => {
|
||||
const probe = evaluatePolicy("0000000000000000000000000000000000000000000000000000000000000000", "probe", 1);
|
||||
// ── GET /relay/policy ─────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/relay/policy", async (_req: Request, res: Response) => {
|
||||
const probeId = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const probe = await evaluatePolicy(probeId, "probe-pubkey-not-real", 1);
|
||||
res.json({
|
||||
ok: true,
|
||||
secretConfigured: !!RELAY_POLICY_SECRET,
|
||||
bootstrapDecision: probe.action,
|
||||
bootstrapMsg: probe.msg,
|
||||
decision: probe.action,
|
||||
msg: probe.msg,
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /relay/policy ────────────────────────────────────────────────────────
|
||||
|
||||
router.post("/relay/policy", (req: Request, res: Response) => {
|
||||
// ── Authentication — Bearer token must match RELAY_POLICY_SECRET ──────────
|
||||
router.post("/relay/policy", async (req: Request, res: Response) => {
|
||||
// ── Authentication ───────────────────────────────────────────────────────
|
||||
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) {
|
||||
@@ -120,10 +116,10 @@ router.post("/relay/policy", (req: Request, res: Response) => {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
logger.warn("relay/policy: RELAY_POLICY_SECRET not set — accepting local-only calls");
|
||||
logger.warn("relay/policy: RELAY_POLICY_SECRET not set — accepting local-only call");
|
||||
}
|
||||
|
||||
// ── Validate request body ─────────────────────────────────────────────────
|
||||
// ── Validate body ────────────────────────────────────────────────────────
|
||||
const body = req.body as Partial<PolicyRequest>;
|
||||
const event = body.event;
|
||||
|
||||
@@ -136,18 +132,8 @@ router.post("/relay/policy", (req: Request, res: Response) => {
|
||||
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);
|
||||
// ── Policy decision ──────────────────────────────────────────────────────
|
||||
const decision = await evaluatePolicy(eventId, pubkey, kind);
|
||||
|
||||
logger.info("relay policy decision", {
|
||||
eventId: eventId.slice(0, 8),
|
||||
@@ -161,22 +147,40 @@ router.post("/relay/policy", (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Evaluate the write policy for an incoming event.
|
||||
* Core write-policy evaluation.
|
||||
*
|
||||
* Bootstrap: all events rejected until whitelist is implemented (Task #37).
|
||||
* The function signature is stable — future tasks replace the body.
|
||||
* Checks relay_accounts for the event's pubkey:
|
||||
* "write" access → accept
|
||||
* "read" / "none" / missing → reject
|
||||
*
|
||||
* Future tasks extend this function (moderation queue, shadowReject for spam).
|
||||
*/
|
||||
function evaluatePolicy(
|
||||
async function evaluatePolicy(
|
||||
eventId: string,
|
||||
pubkey: string,
|
||||
_kind: number,
|
||||
): PolicyDecision {
|
||||
void pubkey; // will be used in Task #37 whitelist check
|
||||
): Promise<PolicyDecision> {
|
||||
if (!pubkey) {
|
||||
return rejectDecision(eventId, "missing pubkey");
|
||||
}
|
||||
|
||||
return reject(
|
||||
eventId,
|
||||
"relay not yet open — whitelist pending (Task #37)",
|
||||
);
|
||||
let accessLevel: string;
|
||||
try {
|
||||
accessLevel = await relayAccountService.getAccess(pubkey);
|
||||
} catch (err) {
|
||||
logger.error("relay-accounts lookup failed — defaulting to reject", { err });
|
||||
return rejectDecision(eventId, "policy service error — try again later");
|
||||
}
|
||||
|
||||
if (accessLevel === "write") {
|
||||
return acceptDecision(eventId);
|
||||
}
|
||||
|
||||
if (accessLevel === "read") {
|
||||
return rejectDecision(eventId, "read-only access — write not permitted");
|
||||
}
|
||||
|
||||
return rejectDecision(eventId, "pubkey not whitelisted for this relay");
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./timmy-config";
|
||||
export * from "./free-tier-grants";
|
||||
export * from "./timmy-nostr-events";
|
||||
export * from "./nostr-trust-vouches";
|
||||
export * from "./relay-accounts";
|
||||
|
||||
36
lib/db/src/schema/relay-accounts.ts
Normal file
36
lib/db/src/schema/relay-accounts.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { nostrIdentities } from "./nostr-identities";
|
||||
|
||||
// ── Access level type ─────────────────────────────────────────────────────────
|
||||
|
||||
export const RELAY_ACCESS_LEVELS = ["none", "read", "write"] as const;
|
||||
export type RelayAccessLevel = (typeof RELAY_ACCESS_LEVELS)[number];
|
||||
|
||||
// ── relay_accounts ────────────────────────────────────────────────────────────
|
||||
// One row per Nostr pubkey that has been explicitly registered with the relay.
|
||||
// Absence = "none" (default deny). FK to nostr_identities ensures we always
|
||||
// have a trust record alongside the relay record.
|
||||
|
||||
export const relayAccounts = pgTable("relay_accounts", {
|
||||
pubkey: text("pubkey")
|
||||
.primaryKey()
|
||||
.references(() => nostrIdentities.pubkey, { onDelete: "cascade" }),
|
||||
|
||||
accessLevel: text("access_level")
|
||||
.$type<RelayAccessLevel>()
|
||||
.notNull()
|
||||
.default("none"),
|
||||
|
||||
// "manual" = operator-granted regardless of trust tier
|
||||
// "auto-tier" = promoted automatically by TrustService
|
||||
grantedBy: text("granted_by").notNull().default("manual"),
|
||||
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
// Set when access is revoked; null means currently active.
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
|
||||
notes: text("notes"),
|
||||
});
|
||||
|
||||
export type RelayAccount = typeof relayAccounts.$inferSelect;
|
||||
Reference in New Issue
Block a user