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 { 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 { 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 { 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 { 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 { 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();