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:
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user