fix(notifications): P2 extreme-audit P1 fixes before deploy

Independent audit verdict was NOT SAFE; all findings fixed:
- dispatcher Idempotency-Key is now per-attempt (${id}:${attempts}),
  not row id alone, so a transient-failure response cached by Resend
  cannot permanently suppress every retry and silently lose an email.
- enqueueDues is fully try/catch wrapped so a notifications-table or
  render failure can never throw into / wedge the Stripe money path.
- dues_lapsed dedupe falls back to the subscription id when periodEnd
  is null (canceled/deleted subs), so future lapses no longer collapse
  to one key and get suppressed. New test; suite 499.
- documented the dispatcher's once/min concurrency assumption.
This commit is contained in:
Moheeb Zara
2026-05-17 05:06:02 -07:00
parent 7eb83676e7
commit f1bae44afd
5 changed files with 125 additions and 44 deletions
+15 -5
View File
@@ -4,10 +4,14 @@
// (cron has none) so proxy.ts whitelists /api/cron; trust is the CRON_SECRET
// shared-secret header, compared in constant time.
//
// Re-entrancy: a row is updated immediately after its own send, and sendEmail
// passes the row id as Resend's Idempotency-Key, so an overlapping run cannot
// double-send. Transient failures (retryable) stay pending for the next
// minute until the attempt budget is exhausted; permanent ones go to 'failed'.
// Re-entrancy: there is no row-level claim/lock, so two overlapping runs can
// scan the same pending rows. This is safe because (a) the crontab fires once
// a minute and a run drains <=20 rows in ~5s, so overlap is rare, and (b)
// sendEmail passes a per-attempt Idempotency-Key (`${id}:${attempts}`):
// concurrent runs of the SAME attempt dedupe at Resend, while the next
// minute's retry of a transiently-failed row is a deliberately fresh send.
// Transient failures stay pending until the attempt budget is exhausted;
// permanent ones go to 'failed'.
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'node:crypto'
import { createAdminClient } from '@/lib/supabase/admin'
@@ -63,7 +67,13 @@ export async function POST(req: NextRequest) {
subject: row.subject as string,
html: row.body_html as string,
text: row.body_text as string,
idempotencyKey: row.id as string,
// Per-attempt, NOT row.id alone: Resend dedupes 24h on key+payload and
// its docs do not guarantee a failed response is excluded from that
// cache. A stable key would let a cached transient failure permanently
// suppress every later retry (email silently lost). Tying the key to
// the attempt number means concurrent runs of the SAME attempt still
// dedupe, but the next minute's real retry is a fresh send.
idempotencyKey: `${row.id}:${(row.attempts as number) ?? 0}`,
})
if (res.ok) {