task/29: fix code review findings — LNURL zap, vouch binding, migration SQL

## Code review issues resolved

### 1. Zap-out: real LNURL-pay resolution (was: log-only when no bolt11)
- `zap.ts`: added `resolveLnurlInvoice()` — full NIP-57 §4 flow:
  * user@domain → https://domain/.well-known/lnurlp/user
  * Fetch LNURL-pay metadata → extract callback URL + min/maxSendable
  * Build signed kind-9734 zap request, send to callback → receive bolt11
  * Pay bolt11 via LNbits. Log event regardless of payment outcome.
- `nostr-identities.ts`: added `lightningAddress` column (nullable TEXT)
- `identity.ts /verify`: extracts `["lud16", "user@domain.com"]` tag from
  signed event and stores it so ZapService can resolve future invoices
- `maybeZapOnJobComplete()` now triggers real payment when lightningAddress
  is stored; logs a warning and skips payment if not available

### 2. Vouch endpoint: signed event is now REQUIRED with p-tag binding
- `event` field changed from optional to required (400 if absent)
- Validates: Nostr signature, event.pubkey matches authenticated voucher
- NEW: event MUST contain a `["p", voucheePubkey]` tag — proves the voucher
  intentionally named the vouchee in their signed event (co-signature binding)

### 3. DB migration file added
- `lib/db/migrations/0006_timmy_economic_peer.sql` — covers:
  * CREATE TABLE IF NOT EXISTS timmy_nostr_events (with indexes)
  * CREATE TABLE IF NOT EXISTS nostr_trust_vouches (with indexes)
  * ALTER TABLE nostr_identities ADD COLUMN IF NOT EXISTS lightning_address
- Schema pushed to production: `lightning_address` column confirmed live

### Additional
- `GET /api/identity/timmy` now includes `relayUrl` field (null when unset)
- TypeScript compiles cleanly (tsc --noEmit: 0 errors)
- All smoke tests pass: /timmy 200, /challenge nonce, /vouch 401/400
This commit is contained in:
alexpaynex
2026-03-19 19:47:00 +00:00
parent 45f4e72f14
commit 33b47f8682
4 changed files with 285 additions and 37 deletions

View File

@@ -53,6 +53,8 @@ router.post("/identity/challenge", (_req: Request, res: Response) => {
// 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)
@@ -133,6 +135,22 @@ router.post("/identity/verify", async (req: Request, res: Response) => {
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,
@@ -173,6 +191,7 @@ router.get("/identity/timmy", async (_req: Request, res: Response) => {
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" });
@@ -181,10 +200,15 @@ router.get("/identity/timmy", async (_req: Request, res: Response) => {
// ── 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.
// 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, event: NostrEvent (optional — voucher's signed event) }
// 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;
@@ -224,40 +248,56 @@ router.post("/identity/vouch", async (req: Request, res: Response) => {
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;
}
// ── 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<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>;
// ── 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;
}
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,
vouchEventJson: JSON.stringify(event),
trustBoost: VOUCH_TRUST_BOOST,
});