feat(#26): Nostr identity + trust engine
- New nostr_identities DB table (pubkey, trust_score, tier, interaction_count, sats_absorbed_today, last_seen) - nullable nostr_pubkey FK on sessions + jobs tables; schema pushed - TrustService: getTier, getOrCreate, recordSuccess/Failure, HMAC token (issue/verify) - Soft score decay (lazy, on read) when identity absent > N days - POST /api/identity/challenge + POST /api/identity/verify (NIP-01 sig verification) - GET /api/identity/me — look up trust profile by X-Nostr-Token - POST /api/sessions + POST /api/jobs accept optional nostr_token; bind pubkey to row - GET /sessions/:id + GET /jobs/:id include trust_tier in response - recordSuccess/Failure called after session request + job work completes - X-Nostr-Token added to CORS allowedHeaders + exposedHeaders - TIMMY_TOKEN_SECRET set as persistent shared env var
This commit is contained in:
192
artifacts/api-server/src/lib/trust.ts
Normal file
192
artifacts/api-server/src/lib/trust.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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.
|
||||
async recordSuccess(pubkey: string, satsCost: number): Promise<void> {
|
||||
const identity = await this.getOrCreate(pubkey);
|
||||
const newScore = identity.trustScore + 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),
|
||||
newScore,
|
||||
newTier,
|
||||
satsCost,
|
||||
});
|
||||
}
|
||||
|
||||
// Called after a failed, rejected, or abusive interaction.
|
||||
async recordFailure(pubkey: string, reason: string): Promise<void> {
|
||||
const identity = await this.getOrCreate(pubkey);
|
||||
const newScore = Math.max(0, identity.trustScore - 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),
|
||||
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();
|
||||
Reference in New Issue
Block a user