This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
token-gated-economy/artifacts/api-server/src/routes/identity.ts
Replit Agent eb5dcfd48a
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
task-29: Timmy as economic peer — Nostr identity, zap-out, vouching, engagement
1. TimmyIdentityService (artifacts/api-server/src/lib/timmy-identity.ts)
   - Loads nsec from TIMMY_NOSTR_NSEC env var at boot (bech32 decode)
   - Generates and warns about ephemeral key if env var absent
   - sign(EventTemplate) → finalizeEvent() with Timmy's key
   - encryptDm(recipientPubkeyHex, plaintext) → NIP-04 nip04.encrypt()
   - Logs npub at server startup

2. ZapService (artifacts/api-server/src/lib/zap.ts)
   - Constructs NIP-57 zap request event (kind 9734), signs with Timmy's key
   - Pays via lnbitsService.payInvoice() if bolt11 provided (stub-mode aware)
   - Logs every outbound event to timmy_nostr_events audit table
   - maybeZapOnJobComplete() wired in jobs.ts after trustService.recordSuccess()
   - Config: ZAP_PCT_DEFAULT (default 0 = disabled), ZAP_MIN_SATS (default 10)
   - Only fires for trusted/elite tier partners when ZAP_PCT_DEFAULT > 0

3. Engagement engine (artifacts/api-server/src/lib/engagement.ts)
   - Configurable cadence: ENGAGEMENT_INTERVAL_DAYS (default 0 = disabled)
   - Queries nostrIdentities for trustScore >= 50 AND lastSeen < threshold
   - Generates personalised DM via agentService.chatReply()
   - Encrypts as NIP-04 DM (kind 4), signs with Timmy's key
   - Logs to timmy_nostr_events; publishes to NOSTR_RELAY_URL if set
   - First run delayed 60s after startup to avoid cold-start noise

4. Vouching endpoint (artifacts/api-server/src/routes/identity.ts)
   - POST /api/identity/vouch: requires X-Nostr-Token with elite tier
   - Verifies optional Nostr event signature from voucher
   - Records relationship in nostr_trust_vouches table
   - Applies VOUCH_TRUST_BOOST (20 pts) to vouchee's trust score
   - GET /api/identity/timmy: public endpoint returning npub + zap count

5. DB schema additions (lib/db/src/schema/)
   - timmy_nostr_events: audit log for all outbound Nostr events
   - nostr_trust_vouches: voucher/vouchee social graph with boost amount
   - Tables created in production DB via drizzle-kit push

6. Frontend identity card (the-matrix/)
   - #timmy-id-card: fixed bottom-right widget with Timmy's npub + zap count
   - timmy-id.js: initTimmyId() fetches /api/identity/timmy on load
   - Npub shortened (npub1xxxx...yyyyyy), click-to-copy with feedback
   - Refreshes every 60s to show live zap count
   - Wired into main.js on firstInit
2026-03-19 19:27:13 +00:00

340 lines
12 KiB
TypeScript

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<string, ChallengeEntry>();
// 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<string, unknown>;
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<typeof validateEvent>[0])) {
res.status(401).json({ error: "Invalid Nostr event structure" });
return;
}
let valid = false;
try {
valid = verifyEvent(ev as Parameters<typeof verifyEvent>[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<typeof validateEvent>[0])) {
res.status(400).json({ error: "Invalid Nostr event structure" });
return;
}
let valid = false;
try { valid = verifyEvent(event as Parameters<typeof verifyEvent>[0]); } catch { valid = false; }
if (!valid) {
res.status(401).json({ error: "Nostr signature verification failed" });
return;
}
const ev = event as Record<string, unknown>;
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;