From 677c79bd14c2b878615291c690177d20ffea000e Mon Sep 17 00:00:00 2001 From: "Claude (Opus 4.6)" Date: Mon, 23 Mar 2026 20:52:19 +0000 Subject: [PATCH] =?UTF-8?q?[claude]=20Nostr=20relay=20account=20whitelist?= =?UTF-8?q?=20=E2=80=94=20access-tier=20API=20+=20NIP-11=20(#37)=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- artifacts/api-server/src/app.ts | 32 ++++++++++ .../api-server/src/lib/relay-accounts.ts | 39 ++++++------ .../api-server/src/routes/admin-relay.ts | 61 +++++++++++++++++++ artifacts/api-server/src/routes/relay.ts | 30 +++++---- .../0007_relay_account_whitelist.sql | 45 ++++++++++++++ lib/db/src/schema/relay-accounts.ts | 7 ++- pnpm-lock.yaml | 4 ++ 7 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 lib/db/migrations/0007_relay_account_whitelist.sql diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index d415685..bf9e662 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "url"; import router from "./routes/index.js"; import adminRelayPanelRouter from "./routes/admin-relay-panel.js"; import { responseTimeMiddleware } from "./middlewares/response-time.js"; +import { timmyIdentityService } from "./lib/timmy-identity.js"; const app: Express = express(); @@ -87,6 +88,37 @@ app.use("/assets", express.static(path.join(towerDist, "assets"))); app.use("/sw.js", express.static(path.join(towerDist, "sw.js"))); app.use("/manifest.json", express.static(path.join(towerDist, "manifest.json"))); +// ── NIP-11 relay information document ──────────────────────────────────────── +// Responds to GET / with Accept: application/nostr+json per NIP-11. +// Returns relay metadata including name, description, pubkey, and supported NIPs. + +app.get("/", (req, res, next) => { + const accept = req.headers["accept"] ?? ""; + if (!accept.includes("application/nostr+json")) { + next(); + return; + } + + const relayName = process.env["RELAY_NAME"] ?? "Alexander Whitestone Relay"; + const relayDescription = + process.env["RELAY_DESCRIPTION"] ?? + "Token-gated Nostr relay. Access requires whitelisted pubkey and sufficient trust tier."; + const relayContact = process.env["RELAY_CONTACT"] ?? "admin@alexanderwhitestone.com"; + const supportedNips = [1, 4, 9, 11, 57]; + + res.setHeader("Content-Type", "application/nostr+json"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.json({ + name: relayName, + description: relayDescription, + pubkey: timmyIdentityService.pubkeyHex, + contact: relayContact, + supported_nips: supportedNips, + software: "https://github.com/hoytech/strfry", + version: process.env["RELAY_VERSION"] ?? "1.0.0", + }); +}); + app.get("/", (_req, res) => { res.setHeader("Content-Type", "text/html"); res.send(` diff --git a/artifacts/api-server/src/lib/relay-accounts.ts b/artifacts/api-server/src/lib/relay-accounts.ts index 88e0a96..e60bdca 100644 --- a/artifacts/api-server/src/lib/relay-accounts.ts +++ b/artifacts/api-server/src/lib/relay-accounts.ts @@ -2,15 +2,16 @@ * relay-accounts.ts — Relay account whitelist + access management. * * Trust tier → access level defaults (env-overridable): - * new → read (RELAY_ACCESS_NEW, default "read") - * established → write (RELAY_ACCESS_ESTABLISHED, default "write") - * trusted → write (RELAY_ACCESS_TRUSTED, default "write") - * elite → write (RELAY_ACCESS_ELITE, default "write") + * new → read (RELAY_ACCESS_NEW, default "read") + * established → write (RELAY_ACCESS_ESTABLISHED, default "write") + * trusted → write (RELAY_ACCESS_TRUSTED, default "write") + * elite → elite (RELAY_ACCESS_ELITE, default "elite") * * Access semantics: - * "write" → relay policy returns "accept" (active write access) - * "read" → relay policy returns "reject" (read-only; no write permitted) - * "none" → relay policy returns "reject" (default deny; no access) + * "elite" → relay policy returns "accept" (direct inject, no moderation queue) + * "write" → relay policy returns "shadowReject" (enqueued for moderation) + * "read" → relay policy returns "reject" (read-only; no write permitted) + * "none" → relay policy returns "reject" (default deny; no access) * * Revocation — grantedBy sentinel "manual-revoked": * The base contract for grantedBy is "manual" | "auto-tier". @@ -33,7 +34,7 @@ const logger = makeLogger("relay-accounts"); function envAccess(name: string, fallback: RelayAccessLevel): RelayAccessLevel { const v = process.env[name]?.toLowerCase(); - if (v === "write" || v === "read" || v === "none") return v; + if (v === "elite" || v === "write" || v === "read" || v === "none") return v; return fallback; } @@ -41,10 +42,10 @@ const TIER_ACCESS: Record = { new: envAccess("RELAY_ACCESS_NEW", "read"), established: envAccess("RELAY_ACCESS_ESTABLISHED", "write"), trusted: envAccess("RELAY_ACCESS_TRUSTED", "write"), - elite: envAccess("RELAY_ACCESS_ELITE", "write"), + elite: envAccess("RELAY_ACCESS_ELITE", "elite"), }; -const ACCESS_RANK: Record = { none: 0, read: 1, write: 2 }; +const ACCESS_RANK: Record = { none: 0, read: 1, write: 2, elite: 3 }; // ── RelayAccountService ─────────────────────────────────────────────────────── @@ -75,6 +76,7 @@ export class RelayAccountService { level: RelayAccessLevel, reason: string, grantedBy: "manual" | "auto-tier" = "manual", + tier?: TrustTier, ): Promise { // Ensure FK target exists await db @@ -87,6 +89,7 @@ export class RelayAccountService { .values({ pubkey, accessLevel: level, + trustTier: tier ?? null, grantedBy, grantedAt: new Date(), revokedAt: null, @@ -96,6 +99,7 @@ export class RelayAccountService { target: relayAccounts.pubkey, set: { accessLevel: level, + trustTier: tier ?? null, grantedBy, grantedAt: new Date(), revokedAt: null, @@ -106,6 +110,7 @@ export class RelayAccountService { logger.info("relay access granted", { pubkey: pubkey.slice(0, 8), level, + tier, grantedBy, reason, }); @@ -136,11 +141,11 @@ export class RelayAccountService { } /** - * Seed a pubkey with elite identity state and relay write access. + * Seed a pubkey with elite identity state and relay elite access. * Called at startup for Timmy's own pubkey. Idempotent. * * Sets nostr_identities.tier = "elite" to reflect the elite trust state, - * then grants relay write access as a manual (permanent) grant. + * then grants relay elite access as a manual (permanent) grant. */ async seedElite(pubkey: string, notes: string): Promise { const ELITE_SCORE = 200; // matches TIER_ELITE threshold default @@ -163,8 +168,8 @@ export class RelayAccountService { }, }); - // Grant relay write access (manual — never overridden by auto-tier sync) - await this.grant(pubkey, "write", notes, "manual"); + // Grant relay elite access (manual — never overridden by auto-tier sync) + await this.grant(pubkey, "elite", notes, "manual", "elite"); logger.info("relay: elite seed applied", { pubkey: pubkey.slice(0, 8), @@ -203,7 +208,7 @@ export class RelayAccountService { if (!existing) { if (targetLevel !== "none") { - await this.grant(pubkey, targetLevel, `auto: tier promotion to ${tier}`, "auto-tier"); + await this.grant(pubkey, targetLevel, `auto: tier promotion to ${tier}`, "auto-tier", tier); } return; } @@ -216,14 +221,14 @@ export class RelayAccountService { // Active manual grant — only upgrade, never downgrade if (existing.grantedBy === "manual" && existing.revokedAt === null) { if (ACCESS_RANK[targetLevel] > ACCESS_RANK[existing.accessLevel]) { - await this.grant(pubkey, targetLevel, `auto-upgrade: tier ${tier}`, "manual"); + await this.grant(pubkey, targetLevel, `auto-upgrade: tier ${tier}`, "manual", tier); } 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"); + await this.grant(pubkey, targetLevel, `auto: tier ${tier} → ${targetLevel}`, "auto-tier", tier); } } diff --git a/artifacts/api-server/src/routes/admin-relay.ts b/artifacts/api-server/src/routes/admin-relay.ts index d268312..34ec32a 100644 --- a/artifacts/api-server/src/routes/admin-relay.ts +++ b/artifacts/api-server/src/routes/admin-relay.ts @@ -200,4 +200,65 @@ router.post( }, ); +// ── POST /admin/relay/accounts ──────────────────────────────────────────────── +// Grant access to a pubkey. Body: { pubkey, level?, notes? } + +router.post( + "/admin/relay/accounts", + requireAdmin, + async (req: Request, res: Response) => { + const body = req.body as { pubkey?: string; level?: string; notes?: string }; + const pubkey = typeof body.pubkey === "string" ? body.pubkey.trim() : ""; + + 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 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 (POST /accounts)", { + pubkey: pubkey.slice(0, 8), + level, + notes, + }); + + res.status(201).json({ ok: true, pubkey, accessLevel: level, notes }); + }, +); + +// ── DELETE /admin/relay/accounts/:pubkey ────────────────────────────────────── +// Revoke access from a pubkey. + +router.delete( + "/admin/relay/accounts/:pubkey", + 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; + } + + await relayAccountService.revoke(pubkey, "admin revoke"); + + logger.info("admin revoked relay access (DELETE /accounts/:pubkey)", { + pubkey: pubkey.slice(0, 8), + }); + + res.json({ ok: true, pubkey, revokedAt: new Date().toISOString() }); + }, +); + export default router; diff --git a/artifacts/api-server/src/routes/relay.ts b/artifacts/api-server/src/routes/relay.ts index 3ab2f0e..814ba11 100644 --- a/artifacts/api-server/src/routes/relay.ts +++ b/artifacts/api-server/src/routes/relay.ts @@ -175,25 +175,29 @@ async function evaluatePolicy( return rejectDecision(eventId, "read-only access — write not permitted"); } - if (accessLevel !== "write") { + if (accessLevel !== "write" && accessLevel !== "elite") { return rejectDecision(eventId, "pubkey not whitelisted for this relay"); } - // ── Step 2: Check trust tier (elite bypass) ──────────────────────────────── - let isElite = false; - try { - const rows = await db - .select({ tier: nostrIdentities.tier }) - .from(nostrIdentities) - .where(eq(nostrIdentities.pubkey, pubkey)) - .limit(1); - isElite = rows[0]?.tier === "elite"; - } catch (err) { - logger.error("tier lookup failed — treating as non-elite", { err }); + // ── Step 2: Elite access — bypass moderation queue ──────────────────────── + // "elite" access level: direct inject into strfry without moderation. + // Also check trust tier for accounts with "write" access level (legacy path). + let isElite = accessLevel === "elite"; + if (!isElite) { + try { + const rows = await db + .select({ tier: nostrIdentities.tier }) + .from(nostrIdentities) + .where(eq(nostrIdentities.pubkey, pubkey)) + .limit(1); + isElite = rows[0]?.tier === "elite"; + } catch (err) { + logger.error("tier lookup failed — treating as non-elite", { err }); + } } if (isElite) { - // Elite accounts bypass moderation — inject directly into strfry. + // Elite access bypass moderation — inject directly into strfry. // On inject failure, return hard reject so the client knows to retry // (shadowReject would silently drop the event from the sender's perspective). const rawJson = JSON.stringify(rawEvent); diff --git a/lib/db/migrations/0007_relay_account_whitelist.sql b/lib/db/migrations/0007_relay_account_whitelist.sql new file mode 100644 index 0000000..056e615 --- /dev/null +++ b/lib/db/migrations/0007_relay_account_whitelist.sql @@ -0,0 +1,45 @@ +-- Migration: Relay account whitelist + NIP-11 self-description (Issue #37) +-- Creates relay_accounts and relay_event_queue tables. +-- Adds trust_tier column for accounts already created by prior drizzle push. + +-- ── relay_accounts ──────────────────────────────────────────────────────────── +-- One row per Nostr pubkey explicitly registered with the relay. +-- Absence = "none" (default deny). + +CREATE TABLE IF NOT EXISTS relay_accounts ( + pubkey TEXT PRIMARY KEY REFERENCES nostr_identities(pubkey) ON DELETE CASCADE, + access_level TEXT NOT NULL DEFAULT 'none', + trust_tier TEXT, + granted_by TEXT NOT NULL DEFAULT 'manual', + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_relay_accounts_access + ON relay_accounts(access_level); + +-- Add trust_tier column to existing deployments that already have the table +ALTER TABLE relay_accounts + ADD COLUMN IF NOT EXISTS trust_tier TEXT; + +-- ── relay_event_queue ───────────────────────────────────────────────────────── +-- Holds every event submitted by whitelisted (non-elite) accounts pending review. + +CREATE TABLE IF NOT EXISTS relay_event_queue ( + event_id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL REFERENCES nostr_identities(pubkey) ON DELETE CASCADE, + kind INTEGER NOT NULL, + raw_event TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reviewed_by TEXT, + review_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + decided_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_relay_event_queue_status + ON relay_event_queue(status); + +CREATE INDEX IF NOT EXISTS idx_relay_event_queue_pubkey + ON relay_event_queue(pubkey); diff --git a/lib/db/src/schema/relay-accounts.ts b/lib/db/src/schema/relay-accounts.ts index 99cb7ac..79268d8 100644 --- a/lib/db/src/schema/relay-accounts.ts +++ b/lib/db/src/schema/relay-accounts.ts @@ -1,9 +1,10 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { nostrIdentities } from "./nostr-identities"; +import type { TrustTier } from "./nostr-identities"; // ── Access level type ───────────────────────────────────────────────────────── -export const RELAY_ACCESS_LEVELS = ["none", "read", "write"] as const; +export const RELAY_ACCESS_LEVELS = ["none", "read", "write", "elite"] as const; export type RelayAccessLevel = (typeof RELAY_ACCESS_LEVELS)[number]; // ── relay_accounts ──────────────────────────────────────────────────────────── @@ -21,6 +22,10 @@ export const relayAccounts = pgTable("relay_accounts", { .notNull() .default("none"), + // Trust tier at the time of last sync from identity system. + // Null for manually-granted accounts that predate auto-sync. + trustTier: text("trust_tier").$type(), + // "manual" = operator-granted regardless of trust tier // "auto-tier" = promoted automatically by TrustService grantedBy: text("granted_by").notNull().default("manual"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1007a83..9c533ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,6 +625,10 @@ importers: version: 7.1.1 scripts: + dependencies: + nostr-tools: + specifier: ^2.23.3 + version: 2.23.3(typescript@5.9.3) devDependencies: '@types/node': specifier: 'catalog:'