This commit was merged in pull request #65.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
* 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")
|
||||
* elite → elite (RELAY_ACCESS_ELITE, default "elite")
|
||||
*
|
||||
* Access semantics:
|
||||
* "write" → relay policy returns "accept" (active write 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)
|
||||
*
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -175,12 +175,15 @@ 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;
|
||||
// ── 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 })
|
||||
@@ -191,9 +194,10 @@ async function evaluatePolicy(
|
||||
} 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);
|
||||
|
||||
45
lib/db/migrations/0007_relay_account_whitelist.sql
Normal file
45
lib/db/migrations/0007_relay_account_whitelist.sql
Normal 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);
|
||||
@@ -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
4
pnpm-lock.yaml
generated
@@ -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:'
|
||||
|
||||
Reference in New Issue
Block a user