task/31: Relay account whitelist + trust-gated access (v2 — code review fixes)

## What was built
Full relay access control: relay_accounts table, RelayAccountService,
trust hook, live policy enforcement, admin CRUD API, elite startup seed.

## DB schema (`lib/db/src/schema/relay-accounts.ts`)
relay_accounts table: pubkey (PK, FK nostr_identities ON DELETE CASCADE),
access_level (none/read/write), granted_by (text), granted_at, revoked_at, notes.
Exported from lib/db/src/schema/index.ts. Pushed via pnpm run push.

## 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, accessLevel→none, grantedBy→"manual-revoked"
  The "manual-revoked" marker prevents syncFromTrustTier from auto-reinstating.
  Only explicit admin grant() can restore access after revocation.
- syncFromTrustTier(pubkey) — fetches tier from DB internally (no tier param to
  avoid caller drift). Respects: manual-revoked (skip), manual active (upgrade only),
  auto-tier (full sync). Never auto-reinstates revoked accounts.
- seedElite(pubkey, notes) — upserts nostr_identities with tier="elite" +
  trustScore=200, then grants relay write access as a permanent manual grant.
  Called at startup for Timmy's own pubkey.
- list(opts) — returns all accounts, filtered by activeOnly if requested.
- Tier→access: new=none, established/trusted/elite=write (env-overridable)

## Trust hook (`artifacts/api-server/src/lib/trust.ts`)
recordSuccess + recordFailure both call syncFromTrustTier(pubkey) after DB write.
Fire-and-forget with catch (trust flow is never blocked by relay errors).

## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`)
evaluatePolicy() async: queries relay_accounts.getAccess(). write→accept,
read/none/missing→reject. DB error→reject (fail-closed).

## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`)
ADMIN_SECRET Bearer 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)
POST /api/admin/relay/accounts/:pk/revoke  — revoke (sets manual-revoked)
pubkey validation: 64-char lowercase hex only.

## Startup seed (`artifacts/api-server/src/index.ts`)
Resolves pubkey from TIMMY_NOSTR_PUBKEY env first, falls back to
timmyIdentityService.pubkeyHex. Calls seedElite() — idempotent upsert.
Sets nostr_identities.tier="elite" alongside relay write access.

## Smoke test results (all pass)
Timmy accept ✓; unknown reject ✓; grant→accept ✓; revoke→manual-revoked ✓;
revoked stays rejected ✓; TypeScript 0 errors.
This commit is contained in:
alexpaynex
2026-03-19 20:26:03 +00:00
parent 94613019fc
commit 31a843a829
3 changed files with 86 additions and 27 deletions

View File

@@ -32,13 +32,17 @@ server.listen(port, () => {
}
startEngagementEngine();
// Seed Timmy's own pubkey with elite relay access on every startup.
// This is idempotent — upsert is safe to run multiple times.
// Seed Timmy's own pubkey with elite identity + relay write access.
// Resolves pubkey from TIMMY_NOSTR_PUBKEY env var if set (explicit override),
// otherwise falls back to the hex pubkey derived from TIMMY_NOSTR_NSEC.
// Idempotent — safe to run on every startup.
const timmyPubkey = process.env["TIMMY_NOSTR_PUBKEY"] ?? timmyIdentityService.pubkeyHex;
relayAccountService
.grant(timmyIdentityService.pubkeyHex, "write", "Timmy's own pubkey — elite access", "manual")
.seedElite(timmyPubkey, "Timmy's own pubkey — elite access seeded at startup")
.then(() =>
rootLogger.info("relay: Timmy's pubkey seeded with write access", {
pubkey: timmyIdentityService.pubkeyHex.slice(0, 8),
rootLogger.info("relay: Timmy's pubkey seeded with elite access", {
pubkey: timmyPubkey.slice(0, 8),
source: process.env["TIMMY_NOSTR_PUBKEY"] ? "TIMMY_NOSTR_PUBKEY env" : "timmyIdentityService",
}),
)
.catch((err) =>

View File

@@ -10,6 +10,11 @@
* Only "write" access generates an "accept" from the relay policy.
* "read" is reserved for future read-gated relays.
* "none" = default deny.
*
* Revocation:
* revoke() sets grantedBy = "manual-revoked". syncFromTrustTier() respects
* this marker and will never auto-reinstate a manually revoked account.
* Only an explicit admin grant() call can restore access after revocation.
*/
import { db, nostrIdentities, relayAccounts } from "@workspace/db";
@@ -34,6 +39,8 @@ const TIER_ACCESS: Record<TrustTier, RelayAccessLevel> = {
elite: envAccess("RELAY_ACCESS_ELITE", "write"),
};
const ACCESS_RANK: Record<RelayAccessLevel, number> = { none: 0, read: 1, write: 2 };
// ── RelayAccountService ───────────────────────────────────────────────────────
export class RelayAccountService {
@@ -101,14 +108,17 @@ export class RelayAccountService {
/**
* Revoke relay access for a pubkey.
* Sets revokedAt and resets access_level to "none".
* No-op if pubkey has no row.
* Sets revokedAt, access_level → "none", grantedBy → "manual-revoked".
* The "manual-revoked" marker prevents syncFromTrustTier() from auto-reinstating
* this account on the next trust update. Only an explicit grant() by an admin
* can restore access after revocation.
*/
async revoke(pubkey: string, reason?: string): Promise<void> {
await db
.update(relayAccounts)
.set({
accessLevel: "none",
grantedBy: "manual-revoked",
revokedAt: new Date(),
notes: reason ?? null,
})
@@ -121,12 +131,61 @@ export class RelayAccountService {
}
/**
* 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.
* Seed a pubkey with elite identity state and relay write 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.
*/
async syncFromTrustTier(pubkey: string, tier: TrustTier): Promise<void> {
async seedElite(pubkey: string, notes: string): Promise<void> {
const ELITE_SCORE = 200; // matches TIER_ELITE threshold default
// Upsert nostr_identities with elite tier + score
await db
.insert(nostrIdentities)
.values({
pubkey,
tier: "elite",
trustScore: ELITE_SCORE,
lastSeen: new Date(),
})
.onConflictDoUpdate({
target: nostrIdentities.pubkey,
set: {
tier: "elite",
trustScore: ELITE_SCORE,
updatedAt: new Date(),
},
});
// Grant relay write access (manual — never overridden by auto-tier sync)
await this.grant(pubkey, "write", notes, "manual");
logger.info("relay: elite seed applied", {
pubkey: pubkey.slice(0, 8),
notes,
});
}
/**
* Sync relay access from the current trust tier.
* Fetches the pubkey's tier from nostr_identities internally.
*
* Rules:
* - "manual-revoked" rows: never auto-reinstated
* - "manual" rows (active): only upgraded, never downgraded
* - "auto-tier" rows: always synced to current tier's target access level
* - No existing row: create one if the tier earns access (non-"none")
*/
async syncFromTrustTier(pubkey: string): Promise<void> {
// Fetch current tier from DB (written by TrustService before this is called)
const identityRows = await db
.select({ tier: nostrIdentities.tier })
.from(nostrIdentities)
.where(eq(nostrIdentities.pubkey, pubkey))
.limit(1);
const tier: TrustTier = (identityRows[0]?.tier as TrustTier) ?? "new";
const targetLevel = TIER_ACCESS[tier];
const rows = await db
@@ -138,32 +197,28 @@ export class RelayAccountService {
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
// Manually revoked — admin must explicitly re-grant; never auto-reinstate
if (existing.grantedBy === "manual-revoked") {
return;
}
// Active manual grant — only upgrade, never downgrade
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
if (ACCESS_RANK[targetLevel] > ACCESS_RANK[existing.accessLevel]) {
await this.grant(pubkey, targetLevel, `auto-upgrade: tier ${tier}`, "manual");
}
// 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",
);
await this.grant(pubkey, targetLevel, `auto: tier ${tier}${targetLevel}`, "auto-tier");
}
}

View File

@@ -158,7 +158,7 @@ export class TrustService {
});
// Sync relay access whenever the tier may have changed
relayAccountService.syncFromTrustTier(pubkey, newTier).catch((err) =>
relayAccountService.syncFromTrustTier(pubkey).catch((err) =>
logger.warn("relay sync failed after success", { pubkey: pubkey.slice(0, 8), err }),
);
}
@@ -191,7 +191,7 @@ export class TrustService {
});
// Sync relay access on tier change (may revoke write on repeated failures)
relayAccountService.syncFromTrustTier(pubkey, newTier).catch((err) =>
relayAccountService.syncFromTrustTier(pubkey).catch((err) =>
logger.warn("relay sync failed after failure", { pubkey: pubkey.slice(0, 8), err }),
);
}