Files
timmy-tower/artifacts/api-server/src/lib/trust.ts
alexpaynex 31a843a829 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.
2026-03-19 20:26:03 +00:00

211 lines
7.8 KiB
TypeScript

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");
// ── Env-var helpers ────────────────────────────────────────────────────────────
function envInt(name: string, fallback: number): number {
const raw = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
// ── Tier score boundaries (inclusive lower bound) ─────────────────────────────
// Override with TRUST_TIER_ESTABLISHED, TRUST_TIER_TRUSTED, TRUST_TIER_ELITE.
const TIER_ESTABLISHED = envInt("TRUST_TIER_ESTABLISHED", 10);
const TIER_TRUSTED = envInt("TRUST_TIER_TRUSTED", 50);
const TIER_ELITE = envInt("TRUST_TIER_ELITE", 200);
// Points per event
const SCORE_PER_SUCCESS = envInt("TRUST_SCORE_PER_SUCCESS", 2);
const SCORE_PER_FAILURE = envInt("TRUST_SCORE_PER_FAILURE", 5);
// Soft decay: points lost per day absent, applied lazily on read
const DECAY_ABSENT_DAYS = envInt("TRUST_DECAY_ABSENT_DAYS", 30);
const DECAY_PER_DAY = envInt("TRUST_DECAY_PER_DAY", 1);
// ── HMAC token for nostr_token auth ──────────────────────────────────────────
// Token format: `{pubkey}:{expiry}:{hmac}`
const TOKEN_SECRET: string = (() => {
const s = process.env["TIMMY_TOKEN_SECRET"];
if (s && s.length >= 32) return s;
const generated = randomBytes(32).toString("hex");
logger.warn("TIMMY_TOKEN_SECRET not set — generated ephemeral secret (tokens expire on restart)");
return generated;
})();
const TOKEN_TTL_SECS = envInt("NOSTR_TOKEN_TTL_SECS", 86400); // 24 h
function signToken(pubkey: string, expiry: number): string {
const payload = `${pubkey}:${expiry}`;
const hmac = createHmac("sha256", TOKEN_SECRET).update(payload).digest("hex");
return `${payload}:${hmac}`;
}
export function verifyToken(token: string): { pubkey: string; expiry: number } | null {
const parts = token.split(":");
if (parts.length !== 3) return null;
const [pubkey, expiryStr, hmac] = parts as [string, string, string];
const expiry = parseInt(expiryStr, 10);
if (!Number.isFinite(expiry) || Date.now() / 1000 > expiry) return null;
const expected = createHmac("sha256", TOKEN_SECRET)
.update(`${pubkey}:${expiry}`)
.digest("hex");
if (expected !== hmac) return null;
return { pubkey, expiry };
}
export function issueToken(pubkey: string): string {
const expiry = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECS;
return signToken(pubkey, expiry);
}
// ── Trust score helpers ───────────────────────────────────────────────────────
function computeTier(score: number): TrustTier {
if (score >= TIER_ELITE) return "elite";
if (score >= TIER_TRUSTED) return "trusted";
if (score >= TIER_ESTABLISHED) return "established";
return "new";
}
function applyDecay(identity: NostrIdentity): number {
const daysSeen =
(Date.now() - identity.lastSeen.getTime()) / (1000 * 60 * 60 * 24);
if (daysSeen < DECAY_ABSENT_DAYS) return identity.trustScore;
const daysAbsent = Math.floor(daysSeen - DECAY_ABSENT_DAYS);
return Math.max(0, identity.trustScore - daysAbsent * DECAY_PER_DAY);
}
// ── TrustService ──────────────────────────────────────────────────────────────
export class TrustService {
// Upsert a new pubkey with default values.
async getOrCreate(pubkey: string): Promise<NostrIdentity> {
const existing = await this.getIdentity(pubkey);
if (existing) return existing;
const rows = await db
.insert(nostrIdentities)
.values({ pubkey })
.onConflictDoNothing()
.returning();
const row = rows[0];
if (row) return row;
// Race: another request inserted first
return (await this.getIdentity(pubkey))!;
}
async getIdentity(pubkey: string): Promise<NostrIdentity | null> {
const rows = await db
.select()
.from(nostrIdentities)
.where(eq(nostrIdentities.pubkey, pubkey))
.limit(1);
return rows[0] ?? null;
}
// Returns the trust tier for a pubkey, or "new" if unknown.
async getTier(pubkey: string): Promise<TrustTier> {
const identity = await this.getIdentity(pubkey);
if (!identity) return "new";
const decayedScore = applyDecay(identity);
return computeTier(decayedScore);
}
// Returns full identity row with decayed score applied (does NOT persist decay).
async getIdentityWithDecay(pubkey: string): Promise<(NostrIdentity & { tier: TrustTier }) | null> {
const identity = await this.getIdentity(pubkey);
if (!identity) return null;
const score = applyDecay(identity);
const tier = computeTier(score);
return { ...identity, trustScore: score, tier };
}
// Called after a successful (paid) interaction.
// Decay is applied first so long-absent identities start from their decayed
// baseline rather than the raw stored score.
async recordSuccess(pubkey: string, satsCost: number): Promise<void> {
const identity = await this.getOrCreate(pubkey);
const decayedBase = applyDecay(identity);
const newScore = decayedBase + SCORE_PER_SUCCESS;
const newTier = computeTier(newScore);
await db
.update(nostrIdentities)
.set({
trustScore: newScore,
tier: newTier,
interactionCount: identity.interactionCount + 1,
lastSeen: new Date(),
updatedAt: new Date(),
})
.where(eq(nostrIdentities.pubkey, pubkey));
logger.info("trust: success recorded", {
pubkey: pubkey.slice(0, 8),
decayedBase,
newScore,
newTier,
satsCost,
});
// Sync relay access whenever the tier may have changed
relayAccountService.syncFromTrustTier(pubkey).catch((err) =>
logger.warn("relay sync failed after success", { pubkey: pubkey.slice(0, 8), err }),
);
}
// Called after a failed, rejected, or abusive interaction.
// Decay is applied first so mutations always start from the current true baseline.
async recordFailure(pubkey: string, reason: string): Promise<void> {
const identity = await this.getOrCreate(pubkey);
const decayedBase = applyDecay(identity);
const newScore = Math.max(0, decayedBase - SCORE_PER_FAILURE);
const newTier = computeTier(newScore);
await db
.update(nostrIdentities)
.set({
trustScore: newScore,
tier: newTier,
interactionCount: identity.interactionCount + 1,
lastSeen: new Date(),
updatedAt: new Date(),
})
.where(eq(nostrIdentities.pubkey, pubkey));
logger.info("trust: failure recorded", {
pubkey: pubkey.slice(0, 8),
decayedBase,
newScore,
newTier,
reason,
});
// Sync relay access on tier change (may revoke write on repeated failures)
relayAccountService.syncFromTrustTier(pubkey).catch((err) =>
logger.warn("relay sync failed after failure", { pubkey: pubkey.slice(0, 8), err }),
);
}
// Issue a signed identity token for a verified pubkey.
issueToken(pubkey: string): string {
return issueToken(pubkey);
}
// Verify and parse an X-Nostr-Token header value.
verifyToken(token: string): { pubkey: string; expiry: number } | null {
return verifyToken(token);
}
}
export const trustService = new TrustService();