import { Router, type Request, type Response } from "express"; import { randomBytes, randomUUID } from "crypto"; import { verifyEvent, validateEvent } from "nostr-tools"; import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db"; import { eq, count } from "drizzle-orm"; import { trustService } from "../lib/trust.js"; import { timmyIdentityService } from "../lib/timmy-identity.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) => { // Accept both { event } and { pubkey, event } shapes (pubkey is optional but asserted if present) const { event, pubkey: explicitPubkey } = req.body as { event?: unknown; pubkey?: unknown }; if (!event || typeof event !== "object") { res.status(400).json({ error: "Body must include 'event' (Nostr signed event)" }); return; } // If caller provided a top-level pubkey, validate it matches event.pubkey if (explicitPubkey !== undefined) { if (typeof explicitPubkey !== "string" || !/^[0-9a-f]{64}$/.test(explicitPubkey)) { res.status(400).json({ error: "top-level 'pubkey' must be a 64-char hex string" }); 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; } // Assert top-level pubkey matches event.pubkey if both are provided if (typeof explicitPubkey === "string" && explicitPubkey !== pubkey) { res.status(400).json({ error: "top-level 'pubkey' does not match event.pubkey" }); 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/timmy ─────────────────────────────────────────────────────── // Returns Timmy's own Nostr identity (npub) and outbound zap statistics. // Public endpoint — no authentication required. router.get("/identity/timmy", async (_req: Request, res: Response) => { try { const zapRows = await db .select({ count: count() }) .from(timmyNostrEvents) .where(eq(timmyNostrEvents.kind, 9734)); const zapCount = zapRows[0]?.count ?? 0; res.json({ npub: timmyIdentityService.npub, pubkeyHex: timmyIdentityService.pubkeyHex, zapCount, }); } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch Timmy identity" }); } }); // ── POST /identity/vouch ────────────────────────────────────────────────────── // Allows an elite-tier identity to co-sign a new pubkey, granting it a trust // boost. The voucher's Nostr event is recorded in nostr_trust_vouches. // // Requires: X-Nostr-Token with elite tier. // Body: { voucheePubkey: string, event: NostrEvent (optional — voucher's signed event) } const VOUCH_TRUST_BOOST = 20; router.post("/identity/vouch", 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: "X-Nostr-Token header required" }); return; } const parsed = trustService.verifyToken(token); if (!parsed) { res.status(401).json({ error: "Invalid or expired nostr_token" }); return; } const tier = await trustService.getTier(parsed.pubkey); if (tier !== "elite") { res.status(403).json({ error: "Vouching requires elite trust tier" }); return; } const { voucheePubkey, event } = req.body as { voucheePubkey?: unknown; event?: unknown; }; if (typeof voucheePubkey !== "string" || !/^[0-9a-f]{64}$/.test(voucheePubkey)) { res.status(400).json({ error: "voucheePubkey must be a 64-char hex pubkey" }); return; } if (voucheePubkey === parsed.pubkey) { res.status(400).json({ error: "Cannot vouch for yourself" }); return; } // Verify the voucher event if provided if (event !== undefined) { if (!event || typeof event !== "object") { res.status(400).json({ error: "event must be a Nostr signed event object" }); return; } if (!validateEvent(event as Parameters[0])) { res.status(400).json({ error: "Invalid Nostr event structure" }); return; } let valid = false; try { valid = verifyEvent(event as Parameters[0]); } catch { valid = false; } if (!valid) { res.status(401).json({ error: "Nostr signature verification failed" }); return; } const ev = event as Record; if (ev["pubkey"] !== parsed.pubkey) { res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" }); return; } } try { // Upsert vouchee identity so it exists before applying boost await trustService.getOrCreate(voucheePubkey); const vouchEventJson = event ? JSON.stringify(event) : JSON.stringify({ voucher: parsed.pubkey, vouchee: voucheePubkey, ts: Date.now() }); await db.insert(nostrTrustVouches).values({ id: randomUUID(), voucherPubkey: parsed.pubkey, voucheePubkey, vouchEventJson, trustBoost: VOUCH_TRUST_BOOST, }); // Apply trust boost: read current score then write boosted value const currentRows = await db .select({ score: nostrIdentities.trustScore }) .from(nostrIdentities) .where(eq(nostrIdentities.pubkey, voucheePubkey)); const currentScore = currentRows[0]?.score ?? 0; await db .update(nostrIdentities) .set({ trustScore: currentScore + VOUCH_TRUST_BOOST, updatedAt: new Date() }) .where(eq(nostrIdentities.pubkey, voucheePubkey)); logger.info("vouch recorded", { voucher: parsed.pubkey.slice(0, 8), vouchee: voucheePubkey.slice(0, 8), boost: VOUCH_TRUST_BOOST, }); const voucheeIdentity = await trustService.getIdentityWithDecay(voucheePubkey); res.json({ ok: true, voucheePubkey, trustBoost: VOUCH_TRUST_BOOST, newScore: voucheeIdentity?.trustScore, newTier: voucheeIdentity?.tier, }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to record vouch"; logger.error("vouch 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;