## Code review round 2 issues resolved
### Vouch replay / duplicate boost vulnerability — FIXED
- `nostr-trust-vouches.ts` schema: added `eventId` column + two unique guards:
1. `UNIQUE(event_id)` — same signed event cannot be replayed for any pair
2. `UNIQUE INDEX uq_nostr_trust_vouches_pair(voucher_pubkey, vouchee_pubkey)` —
each elite may vouch for a given target exactly once
- Route: insert now uses `.onConflictDoNothing().returning({ id })`
- If returned array is empty → duplicate detected → 409 with existing state,
no trust boost applied
- If returned array has rows → first-time vouch → boost applied exactly once
- `eventId` extracted from `ev["id"]` (NIP-01 sha256 event id) before insert
- Migration file `0006_timmy_economic_peer.sql` updated to include both
unique constraints (UNIQUE + CREATE UNIQUE INDEX)
- Schema pushed to production — all three indexes confirmed in DB:
`nostr_trust_vouches_event_id_unique`, `uq_nostr_trust_vouches_pair`, `pkey`
### Previously fixed (round 1)
- LNURL-pay resolution in ZapService (full NIP-57 §4 flow)
- Vouch event made required with p-tag vouchee binding
- DB migration file 0006 created for both new tables + lightning_address column
- GET /identity/timmy now returns relayUrl field
### Verified
- TypeScript: 0 errors (tsc --noEmit clean)
- DB: all constraints confirmed live in production
- API: /identity/timmy 200, /identity/challenge nonce, /identity/vouch 401/400
## 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
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
- resolveNostrPubkey() now returns { pubkey, rejected } instead of string|null
so invalid/expired tokens return 401 instead of silently falling to anonymous
- POST /sessions and POST /jobs: return 401 if nostr_token header/body is
present but invalid or expired
- POST /identity/verify: now accepts optional top-level 'pubkey' field alongside
'event'; asserts pubkey matches event.pubkey if both are provided — aligns
API contract with { pubkey, event } spec shape and hardens against mismatch
- New nostr_identities DB table (pubkey, trust_score, tier, interaction_count, sats_absorbed_today, last_seen)
- nullable nostr_pubkey FK on sessions + jobs tables; schema pushed
- TrustService: getTier, getOrCreate, recordSuccess/Failure, HMAC token (issue/verify)
- Soft score decay (lazy, on read) when identity absent > N days
- POST /api/identity/challenge + POST /api/identity/verify (NIP-01 sig verification)
- GET /api/identity/me — look up trust profile by X-Nostr-Token
- POST /api/sessions + POST /api/jobs accept optional nostr_token; bind pubkey to row
- GET /sessions/:id + GET /jobs/:id include trust_tier in response
- recordSuccess/Failure called after session request + job work completes
- X-Nostr-Token added to CORS allowedHeaders + exposedHeaders
- TIMMY_TOKEN_SECRET set as persistent shared env var