Files
timmy-tower/artifacts/api-server/src/lib/trust.ts

200 lines
7.2 KiB
TypeScript
Raw Normal View History

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";
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,
});
}
// 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,
});
}
// 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();