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
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomBytes } from "crypto";
|
||||
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");
|
||||
@@ -153,6 +156,145 @@ router.post("/identity/verify", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user