From 9b778351e445875620bbdc2f9eef0c4271bd53ae Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Thu, 19 Mar 2026 15:59:14 +0000 Subject: [PATCH] feat(#26): Nostr identity + trust engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- artifacts/api-server/package.json | 1 + artifacts/api-server/src/app.ts | 4 +- artifacts/api-server/src/lib/trust.ts | 192 ++++++++++++++++++++ artifacts/api-server/src/routes/identity.ts | 182 +++++++++++++++++++ artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/jobs.ts | 49 ++++- artifacts/api-server/src/routes/sessions.ts | 41 ++++- lib/db/src/schema/index.ts | 1 + lib/db/src/schema/jobs.ts | 3 + lib/db/src/schema/nostr-identities.ts | 39 ++++ lib/db/src/schema/sessions.ts | 3 + pnpm-lock.yaml | 70 +++++++ 12 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 artifacts/api-server/src/lib/trust.ts create mode 100644 artifacts/api-server/src/routes/identity.ts create mode 100644 lib/db/src/schema/nostr-identities.ts diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index dda75ed..9330e75 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -18,6 +18,7 @@ "drizzle-orm": "catalog:", "express": "^5", "express-rate-limit": "^8.3.1", + "nostr-tools": "^2.23.3", "ws": "^8.19.0" }, "devDependencies": { diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index 5ce6888..c2c7f21 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -40,8 +40,8 @@ app.use( }, credentials: true, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization", "X-Session-Token"], - exposedHeaders: ["X-Session-Token"], + allowedHeaders: ["Content-Type", "Authorization", "X-Session-Token", "X-Nostr-Token"], + exposedHeaders: ["X-Session-Token", "X-Nostr-Token"], }), ); diff --git a/artifacts/api-server/src/lib/trust.ts b/artifacts/api-server/src/lib/trust.ts new file mode 100644 index 0000000..4a64d60 --- /dev/null +++ b/artifacts/api-server/src/lib/trust.ts @@ -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 { + 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. + async recordSuccess(pubkey: string, satsCost: number): Promise { + 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 { + 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(); diff --git a/artifacts/api-server/src/routes/identity.ts b/artifacts/api-server/src/routes/identity.ts new file mode 100644 index 0000000..20ed82a --- /dev/null +++ b/artifacts/api-server/src/routes/identity.ts @@ -0,0 +1,182 @@ +import { Router, type Request, type Response } from "express"; +import { randomBytes } from "crypto"; +import { verifyEvent, validateEvent } from "nostr-tools"; +import { trustService } from "../lib/trust.js"; +import { makeLogger } from "../lib/logger.js"; + +const logger = makeLogger("identity"); +const router = Router(); + +// ── In-memory nonce store (TTL = 5 minutes) ─────────────────────────────────── +// Nonces are single-use: consumed on first successful verify. + +const NONCE_TTL_MS = 5 * 60 * 1000; + +interface ChallengeEntry { + nonce: string; + expiresAt: number; +} + +const challenges = new Map(); + +// Cleanup stale nonces periodically +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of challenges) { + if (now > entry.expiresAt) challenges.delete(key); + } +}, 60_000); + +// ── POST /identity/challenge ────────────────────────────────────────────────── +// Returns a time-limited nonce the client must sign with their Nostr key. + +router.post("/identity/challenge", (_req: Request, res: Response) => { + const nonce = randomBytes(32).toString("hex"); + const expiresAt = Date.now() + NONCE_TTL_MS; + challenges.set(nonce, { nonce, expiresAt }); + + res.json({ + nonce, + expiresAt: new Date(expiresAt).toISOString(), + }); +}); + +// ── POST /identity/verify ───────────────────────────────────────────────────── +// Accepts a NIP-01 signed event whose `content` field contains the nonce. +// Verifies the signature, consumes the nonce, upserts the identity row, +// and returns a signed `nostr_token` valid for 24 h. +// +// Body: { event: NostrEvent } +// event.pubkey — 64-char hex pubkey +// event.content — the nonce returned by /identity/challenge +// event.kind — any (27235 recommended per NIP-98, but not enforced) + +router.post("/identity/verify", async (req: Request, res: Response) => { + const { event } = req.body as { event?: unknown }; + + if (!event || typeof event !== "object") { + res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" }); + return; + } + + // ── Validate event structure ────────────────────────────────────────────── + const ev = event as Record; + const pubkey = ev["pubkey"]; + const content = ev["content"]; + + if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/.test(pubkey)) { + res.status(400).json({ error: "event.pubkey must be a 64-char hex string" }); + return; + } + + if (typeof content !== "string" || content.trim().length === 0) { + res.status(400).json({ error: "event.content must be the nonce string" }); + return; + } + + const nonce = content.trim(); + + // ── Check nonce ────────────────────────────────────────────────────────── + const entry = challenges.get(nonce); + if (!entry) { + res.status(401).json({ error: "Nonce not found or already consumed. Request a new challenge." }); + return; + } + if (Date.now() > entry.expiresAt) { + challenges.delete(nonce); + res.status(401).json({ error: "Nonce expired. Request a new challenge." }); + return; + } + + // ── Verify Nostr signature ──────────────────────────────────────────────── + if (!validateEvent(ev as Parameters[0])) { + res.status(401).json({ error: "Invalid Nostr event structure" }); + return; + } + + let valid = false; + try { + valid = verifyEvent(ev as Parameters[0]); + } catch { + valid = false; + } + + if (!valid) { + res.status(401).json({ error: "Nostr signature verification failed" }); + return; + } + + // ── Consume nonce (single-use) ──────────────────────────────────────────── + challenges.delete(nonce); + + // ── Upsert identity & issue token ──────────────────────────────────────── + try { + const identity = await trustService.getOrCreate(pubkey); + const token = trustService.issueToken(pubkey); + const tierInfo = await trustService.getIdentityWithDecay(pubkey); + + logger.info("identity verified", { + pubkey: pubkey.slice(0, 8), + tier: identity.tier, + score: identity.trustScore, + }); + + res.json({ + pubkey, + nostr_token: token, + trust: { + tier: tierInfo?.tier ?? identity.tier, + score: tierInfo?.trustScore ?? identity.trustScore, + interactionCount: identity.interactionCount, + memberSince: identity.createdAt.toISOString(), + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to upsert identity"; + logger.error("identity verify failed", { error: message }); + res.status(500).json({ error: message }); + } +}); + +// ── GET /identity/me ────────────────────────────────────────────────────────── +// Look up the trust profile for a verified nostr_token. + +router.get("/identity/me", async (req: Request, res: Response) => { + const raw = req.headers["x-nostr-token"]; + const token = typeof raw === "string" ? raw.trim() : null; + + if (!token) { + res.status(401).json({ error: "Missing X-Nostr-Token header" }); + return; + } + + const parsed = trustService.verifyToken(token); + if (!parsed) { + res.status(401).json({ error: "Invalid or expired nostr_token" }); + return; + } + + try { + const identity = await trustService.getIdentityWithDecay(parsed.pubkey); + if (!identity) { + res.status(404).json({ error: "Identity not found" }); + return; + } + + res.json({ + pubkey: identity.pubkey, + trust: { + tier: identity.tier, + score: identity.trustScore, + interactionCount: identity.interactionCount, + satsAbsorbedToday: identity.satsAbsorbedToday, + memberSince: identity.createdAt.toISOString(), + lastSeen: identity.lastSeen.toISOString(), + }, + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch identity" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index e0ba72f..4a5f0c3 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -10,6 +10,7 @@ import uiRouter from "./ui.js"; import nodeDiagnosticsRouter from "./node-diagnostics.js"; import metricsRouter from "./metrics.js"; import worldRouter from "./world.js"; +import identityRouter from "./identity.js"; const router: IRouter = Router(); @@ -18,6 +19,7 @@ router.use(metricsRouter); router.use(jobsRouter); router.use(bootstrapRouter); router.use(sessionsRouter); +router.use(identityRouter); router.use(demoRouter); router.use(testkitRouter); router.use(uiRouter); diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index a574d20..69079b0 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -11,6 +11,7 @@ import { eventBus } from "../lib/event-bus.js"; import { streamRegistry } from "../lib/stream-registry.js"; import { makeLogger } from "../lib/logger.js"; import { latencyHistogram } from "../lib/histogram.js"; +import { trustService } from "../lib/trust.js"; const logger = makeLogger("jobs"); @@ -151,6 +152,12 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat refundState, }); eventBus.publish({ type: "job:completed", jobId, result: workResult.result }); + + // Trust scoring — fire and forget + const completedJob = await getJobById(jobId); + if (completedJob?.nostrPubkey) { + void trustService.recordSuccess(completedJob.nostrPubkey, actualAmountSats); + } } catch (err) { const message = err instanceof Error ? err.message : "Execution error"; streamRegistry.end(jobId); @@ -239,6 +246,17 @@ async function advanceJob(job: Job): Promise { // ── POST /jobs ──────────────────────────────────────────────────────────────── +// ── Resolve Nostr pubkey from token header or body ──────────────────────────── + +function resolveNostrPubkey(req: Request): string | null { + const header = req.headers["x-nostr-token"]; + const bodyToken = req.body?.nostr_token; + const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null); + if (!raw) return null; + const parsed = trustService.verifyToken(raw.trim()); + return parsed?.pubkey ?? null; +} + router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { const parseResult = CreateJobBody.safeParse(req.body); if (!parseResult.success) { @@ -251,6 +269,9 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { } const { request } = parseResult.data; + // Optionally bind a Nostr identity + const nostrPubkey = resolveNostrPubkey(req); + try { const evalFee = pricingService.calculateEvalFeeSats(); const jobId = randomUUID(); @@ -260,7 +281,14 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { const lnbitsInvoice = await lnbitsService.createInvoice(evalFee, `Eval fee for job ${jobId}`); await db.transaction(async (tx) => { - await tx.insert(jobs).values({ id: jobId, request, state: "awaiting_eval_payment", evalAmountSats: evalFee, createdAt }); + await tx.insert(jobs).values({ + id: jobId, + request, + state: "awaiting_eval_payment", + evalAmountSats: evalFee, + createdAt, + ...(nostrPubkey ? { nostrPubkey } : {}), + }); await tx.insert(invoices).values({ id: invoiceId, jobId, @@ -273,11 +301,22 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => { await tx.update(jobs).set({ evalInvoiceId: invoiceId, updatedAt: new Date() }).where(eq(jobs.id, jobId)); }); - logger.info("job created", { jobId, evalAmountSats: evalFee, stubMode: lnbitsService.stubMode }); + logger.info("job created", { + jobId, + evalAmountSats: evalFee, + stubMode: lnbitsService.stubMode, + nostrPubkey: nostrPubkey?.slice(0, 8), + }); + + const trust = nostrPubkey + ? await trustService.getIdentityWithDecay(nostrPubkey) + : null; res.status(201).json({ jobId, createdAt: createdAt.toISOString(), + ...(nostrPubkey ? { nostrPubkey } : {}), + ...(trust ? { trust_tier: trust.tier } : {}), evalInvoice: { paymentRequest: lnbitsInvoice.paymentRequest, amountSats: evalFee, @@ -305,11 +344,17 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { const advanced = await advanceJob(job); if (advanced) job = advanced; + const trustTier = job.nostrPubkey + ? await trustService.getTier(job.nostrPubkey) + : undefined; + const base = { jobId: job.id, state: job.state, createdAt: job.createdAt.toISOString(), completedAt: job.state === "complete" ? job.updatedAt.toISOString() : null, + ...(job.nostrPubkey ? { nostrPubkey: job.nostrPubkey } : {}), + ...(trustTier ? { trust_tier: trustTier } : {}), }; switch (job.state) { diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index 0483844..28a168b 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -8,6 +8,7 @@ import { eventBus } from "../lib/event-bus.js"; import { agentService } from "../lib/agent.js"; import { pricingService } from "../lib/pricing.js"; import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js"; +import { trustService } from "../lib/trust.js"; const router = Router(); @@ -41,13 +42,15 @@ function extractMacaroon(req: Request): string | null { return null; } -function sessionView(session: Session, includeInvoice = false) { +function sessionView(session: Session, includeInvoice = false, trustTier?: string) { const base = { sessionId: session.id, state: session.state, balanceSats: session.balanceSats, expiresAt: session.expiresAt?.toISOString() ?? null, minimumBalanceSats: MIN_BALANCE_SATS, + ...(session.nostrPubkey ? { nostrPubkey: session.nostrPubkey } : {}), + ...(trustTier ? { trust_tier: trustTier } : {}), ...(session.macaroon && (session.state === "active" || session.state === "paused") ? { macaroon: session.macaroon } : {}), @@ -133,6 +136,17 @@ async function advanceTopup(session: Session): Promise { return updated[0] ?? session; } +// ── Resolve Nostr pubkey from token header or body ──────────────────────────── + +function resolveNostrPubkey(req: Request): string | null { + const header = req.headers["x-nostr-token"]; + const bodyToken = req.body?.nostr_token; + const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null); + if (!raw) return null; + const parsed = trustService.verifyToken(raw.trim()); + return parsed?.pubkey ?? null; +} + // ── POST /sessions ───────────────────────────────────────────────────────────── router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => { @@ -146,6 +160,9 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => return; } + // Optionally bind a Nostr identity + const nostrPubkey = resolveNostrPubkey(req); + try { const sessionId = randomUUID(); const invoice = await lnbitsService.createInvoice(amountSats, `Session deposit ${sessionId}`); @@ -159,11 +176,18 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => depositPaymentRequest: invoice.paymentRequest, depositPaid: false, expiresAt: new Date(Date.now() + EXPIRY_MS), + ...(nostrPubkey ? { nostrPubkey } : {}), }); + const trust = nostrPubkey + ? await trustService.getIdentityWithDecay(nostrPubkey) + : null; + res.status(201).json({ sessionId, state: "awaiting_payment", + ...(nostrPubkey ? { nostrPubkey } : {}), + ...(trust ? { trust_tier: trust.tier } : {}), invoice: { paymentRequest: invoice.paymentRequest, amountSats, @@ -203,7 +227,11 @@ router.get("/sessions/:id", async (req: Request, res: Response) => { session = await advanceTopup(session); } - res.json(sessionView(session, true)); + const trustTier = session.nostrPubkey + ? await trustService.getTier(session.nostrPubkey) + : undefined; + + res.json(sessionView(session, true, trustTier)); } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch session" }); } @@ -345,6 +373,15 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { .where(eq(sessions.id, id)); }); + // ── Trust scoring ──────────────────────────────────────────────────────── + if (session.nostrPubkey) { + if (finalState === "complete") { + void trustService.recordSuccess(session.nostrPubkey, debitedSats); + } else if (finalState === "rejected") { + void trustService.recordFailure(session.nostrPubkey, reason ?? "rejected"); + } + } + res.json({ requestId, state: finalState, diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index ded2293..c47de51 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -5,3 +5,4 @@ export * from "./messages"; export * from "./bootstrap-jobs"; export * from "./world-events"; export * from "./sessions"; +export * from "./nostr-identities"; diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index 535c651..d293e4a 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -36,6 +36,9 @@ export const jobs = pgTable("jobs", { actualOutputTokens: integer("actual_output_tokens"), actualCostUsd: real("actual_cost_usd"), + // Optional Nostr identity bound at job creation + nostrPubkey: text("nostr_pubkey"), + // ── Post-work honest accounting & refund ───────────────────────────────── actualAmountSats: integer("actual_amount_sats"), refundAmountSats: integer("refund_amount_sats"), diff --git a/lib/db/src/schema/nostr-identities.ts b/lib/db/src/schema/nostr-identities.ts new file mode 100644 index 0000000..eb14769 --- /dev/null +++ b/lib/db/src/schema/nostr-identities.ts @@ -0,0 +1,39 @@ +import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod/v4"; + +// ── Trust tier labels ───────────────────────────────────────────────────────── +// Boundaries are env-var overridable via TrustService. + +export const TRUST_TIERS = ["new", "established", "trusted", "elite"] as const; +export type TrustTier = (typeof TRUST_TIERS)[number]; + +// ── nostr_identities ────────────────────────────────────────────────────────── +// One row per Nostr pubkey (64-char lowercase hex). Trust score drives pricing +// decisions in the cost-routing layer (Task #27). + +export const nostrIdentities = pgTable("nostr_identities", { + pubkey: text("pubkey").primaryKey(), + + trustScore: integer("trust_score").notNull().default(0), + tier: text("tier").$type().notNull().default("new"), + interactionCount: integer("interaction_count").notNull().default(0), + + // Rolling daily absorption budget (reset by TrustService on read) + satsAbsorbedToday: integer("sats_absorbed_today").notNull().default(0), + absorbedResetAt: timestamp("absorbed_reset_at", { withTimezone: true }) + .defaultNow() + .notNull(), + + lastSeen: timestamp("last_seen", { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const insertNostrIdentitySchema = createInsertSchema(nostrIdentities).omit({ + createdAt: true, + updatedAt: true, +}); + +export type NostrIdentity = typeof nostrIdentities.$inferSelect; +export type InsertNostrIdentity = z.infer; diff --git a/lib/db/src/schema/sessions.ts b/lib/db/src/schema/sessions.ts index 54c67d4..231d765 100644 --- a/lib/db/src/schema/sessions.ts +++ b/lib/db/src/schema/sessions.ts @@ -43,6 +43,9 @@ export const sessions = pgTable("sessions", { // Auth token — issued once when session activates; required for requests macaroon: text("macaroon"), + // Optional Nostr identity bound at session creation + nostrPubkey: text("nostr_pubkey"), + // TTL — refreshed on each successful request expiresAt: timestamp("expires_at", { withTimezone: true }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c007c20..3cb0822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: express-rate-limit: specifier: ^8.3.1 version: 8.3.1(express@5.2.1) + nostr-tools: + specifier: ^2.23.3 + version: 2.23.3(typescript@5.9.3) ws: specifier: ^8.19.0 version: 8.19.0 @@ -670,6 +673,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1415,6 +1430,15 @@ packages: resolution: {integrity: sha512-ngJcHGoCHmpWgYtNy08vmzFfLdQEkMpvaCQqNPPMNKq0QEXOv89e/rn+TZJZgPnRlY7fDIoIhn9lNgr+azBW+w==} engines: {node: '>=20'} + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -2532,6 +2556,17 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nostr-tools@2.23.3: + resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -3418,6 +3453,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4285,6 +4328,19 @@ snapshots: dependencies: '@scalar/openapi-types': 0.5.4 + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/engine-oniguruma@3.23.0': @@ -5345,6 +5401,20 @@ snapshots: node-releases@2.0.36: {} + nostr-tools@2.23.3(typescript@5.9.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-wasm@0.1.0: {} + npm-run-path@6.0.0: dependencies: path-key: 4.0.0