From 94613019fc15fb45f99a945d10d426df5581b898 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 20:21:12 +0000 Subject: [PATCH] task/31: Relay account whitelist + trust-gated access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- artifacts/api-server/src/index.ts | 14 ++ .../api-server/src/lib/relay-accounts.ts | 183 ++++++++++++++++++ artifacts/api-server/src/lib/trust.ts | 11 ++ .../api-server/src/routes/admin-relay.ts | 129 ++++++++++++ artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/relay.ts | 94 ++++----- lib/db/src/schema/index.ts | 1 + lib/db/src/schema/relay-accounts.ts | 36 ++++ 8 files changed, 425 insertions(+), 45 deletions(-) create mode 100644 artifacts/api-server/src/lib/relay-accounts.ts create mode 100644 artifacts/api-server/src/routes/admin-relay.ts create mode 100644 lib/db/src/schema/relay-accounts.ts diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 4ed1683..8c6667b 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -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 }), + ); }); diff --git a/artifacts/api-server/src/lib/relay-accounts.ts b/artifacts/api-server/src/lib/relay-accounts.ts new file mode 100644 index 0000000..9680722 --- /dev/null +++ b/artifacts/api-server/src/lib/relay-accounts.ts @@ -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 = { + 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 { + 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 { + // 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 { + 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 { + 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 = { 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 { + 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(); diff --git a/artifacts/api-server/src/lib/trust.ts b/artifacts/api-server/src/lib/trust.ts index c5bfaaa..3bd5617 100644 --- a/artifacts/api-server/src/lib/trust.ts +++ b/artifacts/api-server/src/lib/trust.ts @@ -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. diff --git a/artifacts/api-server/src/routes/admin-relay.ts b/artifacts/api-server/src/routes/admin-relay.ts new file mode 100644 index 0000000..ff73892 --- /dev/null +++ b/artifacts/api-server/src/routes/admin-relay.ts @@ -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; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 691e16a..ecc48d6 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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); diff --git a/artifacts/api-server/src/routes/relay.ts b/artifacts/api-server/src/routes/relay.ts index 16e8351..4e53241 100644 --- a/artifacts/api-server/src/routes/relay.ts +++ b/artifacts/api-server/src/routes/relay.ts @@ -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; 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 { + 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; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 144436d..82a6452 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -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"; diff --git a/lib/db/src/schema/relay-accounts.ts b/lib/db/src/schema/relay-accounts.ts new file mode 100644 index 0000000..99cb7ac --- /dev/null +++ b/lib/db/src/schema/relay-accounts.ts @@ -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() + .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;