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) // event.tags — optional: include ["lud16", "user@domain.com"] to store // a lightning address for future zap-out payments 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); // ── Optional: persist lightning address from lud16 tag ──────────────── // Allows ZapService to resolve LNURL-pay invoices for this identity. const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : []; const lud16Tag = tags.find(t => Array.isArray(t) && t[0] === "lud16" && typeof t[1] === "string"); if (lud16Tag) { const lightningAddress = lud16Tag[1] as string; const lnAddrRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; if (lnAddrRe.test(lightningAddress)) { await db .update(nostrIdentities) .set({ lightningAddress, updatedAt: new Date() }) .where(eq(nostrIdentities.pubkey, pubkey)); logger.info("lightning address stored", { pubkey: pubkey.slice(0, 8), lightningAddress }); } } 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, relayUrl: process.env["NOSTR_RELAY_URL"] ?? null, }); } 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 MUST provide a signed Nostr event that explicitly tags the // voucheePubkey in a "p" tag, proving the co-signing is intentional. // // Requires: X-Nostr-Token with elite tier. // Body: { // voucheePubkey: string, — 64-char hex pubkey of the identity to vouch for // event: NostrSignedEvent — REQUIRED: voucher's signed kind-1 (or 9735) event // containing a ["p", voucheePubkey] tag // } 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; } // ── event is REQUIRED — voucher must produce a signed Nostr event ───────── if (!event || typeof event !== "object") { res.status(400).json({ error: "event is required: include a signed Nostr event with a [\"p\", voucheePubkey] tag", }); 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; // ── event.pubkey must match the authenticated voucher ──────────────────── if (ev["pubkey"] !== parsed.pubkey) { res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" }); return; } // ── event must contain a ["p", voucheePubkey] tag — binding co-signature ─ // This proves the voucher intentionally named the vouchee in their signed event. const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : []; const pTag = tags.find( t => Array.isArray(t) && t[0] === "p" && t[1] === voucheePubkey ); if (!pTag) { res.status(400).json({ error: `event must include a ["p", "${voucheePubkey}"] tag to bind the co-signature`, }); return; } // ── Extract event ID for replay guard ──────────────────────────────────── const eventId = typeof ev["id"] === "string" ? ev["id"] : randomUUID(); try { // Upsert vouchee identity so it exists before applying boost await trustService.getOrCreate(voucheePubkey); // Idempotent insert — unique constraints on (voucher, vouchee) pair AND // event_id prevent both duplicate vouches and event replay attacks. // onConflictDoNothing().returning() returns [] on conflict: no boost applied. const inserted = await db .insert(nostrTrustVouches) .values({ id: randomUUID(), voucherPubkey: parsed.pubkey, voucheePubkey, eventId, vouchEventJson: JSON.stringify(event), trustBoost: VOUCH_TRUST_BOOST, }) .onConflictDoNothing() .returning({ id: nostrTrustVouches.id }); if (inserted.length === 0) { // Duplicate — either same pair already vouched or event replayed. // Return existing state without applying another boost. const voucheeIdentity = await trustService.getIdentityWithDecay(voucheePubkey); logger.info("vouch duplicate — no additional boost applied", { voucher: parsed.pubkey.slice(0, 8), vouchee: voucheePubkey.slice(0, 8), }); res.status(409).json({ ok: false, duplicate: true, message: "Already vouched for this identity — co-signature recorded but no additional boost applied", voucheePubkey, currentScore: voucheeIdentity?.trustScore, currentTier: voucheeIdentity?.tier, }); return; } // First-time vouch: apply the one-time trust boost 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;