task/31: Relay account whitelist + trust-gated access

## 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
This commit is contained in:
alexpaynex
2026-03-19 20:21:12 +00:00
parent faef1fe5e0
commit 94613019fc
8 changed files with 425 additions and 45 deletions

View File

@@ -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<TrustTier, RelayAccessLevel> = {
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<RelayAccessLevel> {
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<void> {
// 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<void> {
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<void> {
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<RelayAccessLevel, number> = { 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<typeof relayAccounts.$inferSelect[]> {
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();

View File

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