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 router from "./routes/index.js";
|
||||||
import adminRelayPanelRouter from "./routes/admin-relay-panel.js";
|
import adminRelayPanelRouter from "./routes/admin-relay-panel.js";
|
||||||
import { responseTimeMiddleware } from "./middlewares/response-time.js";
|
import { responseTimeMiddleware } from "./middlewares/response-time.js";
|
||||||
|
import { timmyIdentityService } from "./lib/timmy-identity.js";
|
||||||
|
|
||||||
const app: Express = express();
|
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("/sw.js", express.static(path.join(towerDist, "sw.js")));
|
||||||
app.use("/manifest.json", express.static(path.join(towerDist, "manifest.json")));
|
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) => {
|
app.get("/", (_req, res) => {
|
||||||
res.setHeader("Content-Type", "text/html");
|
res.setHeader("Content-Type", "text/html");
|
||||||
res.send(`<!DOCTYPE html>
|
res.send(`<!DOCTYPE html>
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
* relay-accounts.ts — Relay account whitelist + access management.
|
* relay-accounts.ts — Relay account whitelist + access management.
|
||||||
*
|
*
|
||||||
* Trust tier → access level defaults (env-overridable):
|
* Trust tier → access level defaults (env-overridable):
|
||||||
* new → read (RELAY_ACCESS_NEW, default "read")
|
* new → read (RELAY_ACCESS_NEW, default "read")
|
||||||
* established → write (RELAY_ACCESS_ESTABLISHED, default "write")
|
* established → write (RELAY_ACCESS_ESTABLISHED, default "write")
|
||||||
* trusted → write (RELAY_ACCESS_TRUSTED, 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:
|
* Access semantics:
|
||||||
* "write" → relay policy returns "accept" (active write access)
|
* "elite" → relay policy returns "accept" (direct inject, no moderation queue)
|
||||||
* "read" → relay policy returns "reject" (read-only; no write permitted)
|
* "write" → relay policy returns "shadowReject" (enqueued for moderation)
|
||||||
* "none" → relay policy returns "reject" (default deny; no access)
|
* "read" → relay policy returns "reject" (read-only; no write permitted)
|
||||||
|
* "none" → relay policy returns "reject" (default deny; no access)
|
||||||
*
|
*
|
||||||
* Revocation — grantedBy sentinel "manual-revoked":
|
* Revocation — grantedBy sentinel "manual-revoked":
|
||||||
* The base contract for grantedBy is "manual" | "auto-tier".
|
* 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 {
|
function envAccess(name: string, fallback: RelayAccessLevel): RelayAccessLevel {
|
||||||
const v = process.env[name]?.toLowerCase();
|
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;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +42,10 @@ const TIER_ACCESS: Record<TrustTier, RelayAccessLevel> = {
|
|||||||
new: envAccess("RELAY_ACCESS_NEW", "read"),
|
new: envAccess("RELAY_ACCESS_NEW", "read"),
|
||||||
established: envAccess("RELAY_ACCESS_ESTABLISHED", "write"),
|
established: envAccess("RELAY_ACCESS_ESTABLISHED", "write"),
|
||||||
trusted: envAccess("RELAY_ACCESS_TRUSTED", "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 ───────────────────────────────────────────────────────
|
// ── RelayAccountService ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ export class RelayAccountService {
|
|||||||
level: RelayAccessLevel,
|
level: RelayAccessLevel,
|
||||||
reason: string,
|
reason: string,
|
||||||
grantedBy: "manual" | "auto-tier" = "manual",
|
grantedBy: "manual" | "auto-tier" = "manual",
|
||||||
|
tier?: TrustTier,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Ensure FK target exists
|
// Ensure FK target exists
|
||||||
await db
|
await db
|
||||||
@@ -87,6 +89,7 @@ export class RelayAccountService {
|
|||||||
.values({
|
.values({
|
||||||
pubkey,
|
pubkey,
|
||||||
accessLevel: level,
|
accessLevel: level,
|
||||||
|
trustTier: tier ?? null,
|
||||||
grantedBy,
|
grantedBy,
|
||||||
grantedAt: new Date(),
|
grantedAt: new Date(),
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
@@ -96,6 +99,7 @@ export class RelayAccountService {
|
|||||||
target: relayAccounts.pubkey,
|
target: relayAccounts.pubkey,
|
||||||
set: {
|
set: {
|
||||||
accessLevel: level,
|
accessLevel: level,
|
||||||
|
trustTier: tier ?? null,
|
||||||
grantedBy,
|
grantedBy,
|
||||||
grantedAt: new Date(),
|
grantedAt: new Date(),
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
@@ -106,6 +110,7 @@ export class RelayAccountService {
|
|||||||
logger.info("relay access granted", {
|
logger.info("relay access granted", {
|
||||||
pubkey: pubkey.slice(0, 8),
|
pubkey: pubkey.slice(0, 8),
|
||||||
level,
|
level,
|
||||||
|
tier,
|
||||||
grantedBy,
|
grantedBy,
|
||||||
reason,
|
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.
|
* Called at startup for Timmy's own pubkey. Idempotent.
|
||||||
*
|
*
|
||||||
* Sets nostr_identities.tier = "elite" to reflect the elite trust state,
|
* 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> {
|
async seedElite(pubkey: string, notes: string): Promise<void> {
|
||||||
const ELITE_SCORE = 200; // matches TIER_ELITE threshold default
|
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)
|
// Grant relay elite access (manual — never overridden by auto-tier sync)
|
||||||
await this.grant(pubkey, "write", notes, "manual");
|
await this.grant(pubkey, "elite", notes, "manual", "elite");
|
||||||
|
|
||||||
logger.info("relay: elite seed applied", {
|
logger.info("relay: elite seed applied", {
|
||||||
pubkey: pubkey.slice(0, 8),
|
pubkey: pubkey.slice(0, 8),
|
||||||
@@ -203,7 +208,7 @@ export class RelayAccountService {
|
|||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
if (targetLevel !== "none") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -216,14 +221,14 @@ export class RelayAccountService {
|
|||||||
// Active manual grant — only upgrade, never downgrade
|
// Active manual grant — only upgrade, never downgrade
|
||||||
if (existing.grantedBy === "manual" && existing.revokedAt === null) {
|
if (existing.grantedBy === "manual" && existing.revokedAt === null) {
|
||||||
if (ACCESS_RANK[targetLevel] > ACCESS_RANK[existing.accessLevel]) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-tier row — sync to current tier's target level
|
// Auto-tier row — sync to current tier's target level
|
||||||
if (targetLevel !== existing.accessLevel || existing.revokedAt !== null) {
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -175,25 +175,29 @@ async function evaluatePolicy(
|
|||||||
return rejectDecision(eventId, "read-only access — write not permitted");
|
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");
|
return rejectDecision(eventId, "pubkey not whitelisted for this relay");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: Check trust tier (elite bypass) ────────────────────────────────
|
// ── Step 2: Elite access — bypass moderation queue ────────────────────────
|
||||||
let isElite = false;
|
// "elite" access level: direct inject into strfry without moderation.
|
||||||
try {
|
// Also check trust tier for accounts with "write" access level (legacy path).
|
||||||
const rows = await db
|
let isElite = accessLevel === "elite";
|
||||||
.select({ tier: nostrIdentities.tier })
|
if (!isElite) {
|
||||||
.from(nostrIdentities)
|
try {
|
||||||
.where(eq(nostrIdentities.pubkey, pubkey))
|
const rows = await db
|
||||||
.limit(1);
|
.select({ tier: nostrIdentities.tier })
|
||||||
isElite = rows[0]?.tier === "elite";
|
.from(nostrIdentities)
|
||||||
} catch (err) {
|
.where(eq(nostrIdentities.pubkey, pubkey))
|
||||||
logger.error("tier lookup failed — treating as non-elite", { err });
|
.limit(1);
|
||||||
|
isElite = rows[0]?.tier === "elite";
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("tier lookup failed — treating as non-elite", { err });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isElite) {
|
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
|
// On inject failure, return hard reject so the client knows to retry
|
||||||
// (shadowReject would silently drop the event from the sender's perspective).
|
// (shadowReject would silently drop the event from the sender's perspective).
|
||||||
const rawJson = JSON.stringify(rawEvent);
|
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 { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
import { nostrIdentities } from "./nostr-identities";
|
import { nostrIdentities } from "./nostr-identities";
|
||||||
|
import type { TrustTier } from "./nostr-identities";
|
||||||
|
|
||||||
// ── Access level type ─────────────────────────────────────────────────────────
|
// ── 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];
|
export type RelayAccessLevel = (typeof RELAY_ACCESS_LEVELS)[number];
|
||||||
|
|
||||||
// ── relay_accounts ────────────────────────────────────────────────────────────
|
// ── relay_accounts ────────────────────────────────────────────────────────────
|
||||||
@@ -21,6 +22,10 @@ export const relayAccounts = pgTable("relay_accounts", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("none"),
|
.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
|
// "manual" = operator-granted regardless of trust tier
|
||||||
// "auto-tier" = promoted automatically by TrustService
|
// "auto-tier" = promoted automatically by TrustService
|
||||||
grantedBy: text("granted_by").notNull().default("manual"),
|
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
|
version: 7.1.1
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
|
dependencies:
|
||||||
|
nostr-tools:
|
||||||
|
specifier: ^2.23.3
|
||||||
|
version: 2.23.3(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
|
|||||||
Reference in New Issue
Block a user