Previously recordSuccess/recordFailure read identity.trustScore (raw stored value) and incremented/decremented from there. Long-absent identities could instantly recover their pre-absence tier on the first interaction, defeating decay. Fix: both methods now call applyDecay(identity) first to get the true current baseline, then apply the score delta from there before persisting.
200 lines
7.2 KiB
TypeScript
200 lines
7.2 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";
|
|
|
|
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();
|