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:
Replit Agent
2026-03-19 15:59:14 +00:00
parent fa0ebc6b5c
commit 9b778351e4
12 changed files with 581 additions and 6 deletions

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