[claude] Nostr relay account whitelist — access-tier API + NIP-11 (#37) (#65)

This commit was merged in pull request #65.
This commit is contained in:
2026-03-23 20:52:19 +00:00
parent eed37885fb
commit 677c79bd14
7 changed files with 187 additions and 31 deletions

View File

@@ -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(`<!DOCTYPE html>

View File

@@ -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<TrustTier, RelayAccessLevel> = {
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<RelayAccessLevel, number> = { none: 0, read: 1, write: 2 };
const ACCESS_RANK: Record<RelayAccessLevel, number> = { 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<void> {
// 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<void> {
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);
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<TrustTier>(),
// "manual" = operator-granted regardless of trust tier
// "auto-tier" = promoted automatically by TrustService
grantedBy: text("granted_by").notNull().default("manual"),

4
pnpm-lock.yaml generated
View File

@@ -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:'