From d0dff5eeb86c0cddd602104ef1e4d2c0effe76cc Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 20:53:28 -0500 Subject: [PATCH 01/14] hardening: G2 retryable Twilio webhook failure semantics --- app/api/twilio/sms/route.ts | 9 +++++-- app/api/twilio/status/route.ts | 10 +++++-- docs/PRODUCTION_READINESS_GAPS.md | 27 +++++++++++++++++++ lib/twilio-webhook-retry.ts | 10 +++++++ tests/twilio-webhook-retry.test.ts | 42 ++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 lib/twilio-webhook-retry.ts create mode 100644 tests/twilio-webhook-retry.test.ts diff --git a/app/api/twilio/sms/route.ts b/app/api/twilio/sms/route.ts index 4c33df0..facc083 100644 --- a/app/api/twilio/sms/route.ts +++ b/app/api/twilio/sms/route.ts @@ -9,6 +9,7 @@ import { isSubscriptionActive } from '@/lib/subscription'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; import { handleInboundSmsComplianceCommand } from '@/lib/twilio-sms-compliance'; import { buildOwnerNotificationMessage, persistInboundMessage, sendAndPersistOutboundMessage } from '@/lib/twilio-messaging'; +import { buildTwilioRetryableErrorResponse } from '@/lib/twilio-webhook-retry'; import { hasValidTwilioWebhookRequest } from '@/lib/twilio-webhook'; import { messagingTwiML } from '@/lib/twiml'; import { absoluteUrl } from '@/lib/url'; @@ -30,6 +31,10 @@ function xmlOk(message?: string) { ); } +function retryableErrorResponse() { + return buildTwilioRetryableErrorResponse('sms'); +} + export async function POST(request: Request) { let messageSid: string | null = null; try { @@ -301,7 +306,7 @@ export async function POST(request: Request) { return xmlOk(); } catch (error) { - logTwilioError('sms', 'route_error', { messageSid, eventType: 'inbound_sms', decision: 'return_xml_noop' }, error); - return xmlOk(); + logTwilioError('sms', 'route_error', { messageSid, eventType: 'inbound_sms', decision: 'return_retryable_503' }, error); + return retryableErrorResponse(); } } diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index 3580249..958b8be 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -9,6 +9,7 @@ import { isSubscriptionActive } from '@/lib/subscription'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; import { sendAndPersistOutboundMessage } from '@/lib/twilio-messaging'; import { extractTwilioRecordingMetadata } from '@/lib/twilio-recording'; +import { buildTwilioRetryableErrorResponse } from '@/lib/twilio-webhook-retry'; import { hasValidTwilioWebhookRequest } from '@/lib/twilio-webhook'; import { messagingTwiML } from '@/lib/twiml'; import { describeUsageLimit, getConversationUsageForBusiness, isConversationLimitReached } from '@/lib/usage'; @@ -35,6 +36,10 @@ function xmlOk() { return new NextResponse(messagingTwiML(), { headers: { 'Content-Type': 'text/xml' } }); } +function retryableErrorResponse() { + return buildTwilioRetryableErrorResponse('status'); +} + export async function POST(request: Request) { let callSid: string | null = null; let dialCallSid: string | null = null; @@ -379,6 +384,7 @@ export async function POST(request: Request) { lastInteractionAt: new Date(), }, }); + return retryableErrorResponse(); } return xmlOk(); @@ -386,9 +392,9 @@ export async function POST(request: Request) { logTwilioError( 'status', 'route_error', - { callSid, dialCallSid, eventType: 'dial_status_callback', decision: 'return_xml_noop' }, + { callSid, dialCallSid, eventType: 'dial_status_callback', decision: 'return_retryable_503' }, error ); - return xmlOk(); + return retryableErrorResponse(); } } diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 7eb2ce9..0451402 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -192,3 +192,30 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `433cd34` + +- 2026-03-02 - G2 (DONE) + - Branch: `hardening/g2-webhook-retry-semantics` + - What changed: + - Updated Twilio webhook route fatal-error behavior: + - `app/api/twilio/status/route.ts` now returns retryable `503` on fatal route errors and on initial missed-call SMS send failures. + - `app/api/twilio/sms/route.ts` now returns retryable `503` on fatal route errors. + - Added shared helper `lib/twilio-webhook-retry.ts` to standardize retryable response shape/status. + - Added replay-oriented tests in `tests/twilio-webhook-retry.test.ts` to verify deterministic retry response semantics for both status and sms paths. + - Idempotency notes: + - Existing idempotent guards remain in place (`Call` upsert by `twilioCallSid`, `Lead` reuse by `callId`, inbound message dedupe by `Message.twilioSid`). + - Returning `503` on fatal/transient failures allows provider retries without introducing duplicate durable records. + - Commands run + results: + - `npm test` -> PASS (22/22) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `app/api/twilio/status/route.ts` + - `app/api/twilio/sms/route.ts` + - `lib/twilio-webhook-retry.ts` + - `tests/twilio-webhook-retry.test.ts` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - pending (added in immediate follow-up changelog commit) diff --git a/lib/twilio-webhook-retry.ts b/lib/twilio-webhook-retry.ts new file mode 100644 index 0000000..8215c1b --- /dev/null +++ b/lib/twilio-webhook-retry.ts @@ -0,0 +1,10 @@ +export type TwilioWebhookRoute = 'sms' | 'status'; + +const RETRYABLE_ERROR_BODY = { + error: 'Temporary webhook processing failure', + retryable: true, +} as const; + +export function buildTwilioRetryableErrorResponse(route: TwilioWebhookRoute) { + return Response.json({ ...RETRYABLE_ERROR_BODY, route }, { status: 503 }); +} diff --git a/tests/twilio-webhook-retry.test.ts b/tests/twilio-webhook-retry.test.ts new file mode 100644 index 0000000..a61d6c1 --- /dev/null +++ b/tests/twilio-webhook-retry.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildTwilioRetryableErrorResponse } from '../lib/twilio-webhook-retry.ts'; + +test('status webhook retry responses are stable across replay attempts', async () => { + const first = buildTwilioRetryableErrorResponse('status'); + const replay = buildTwilioRetryableErrorResponse('status'); + + assert.equal(first.status, 503); + assert.equal(replay.status, 503); + + assert.deepEqual(await first.json(), { + error: 'Temporary webhook processing failure', + retryable: true, + route: 'status', + }); + assert.deepEqual(await replay.json(), { + error: 'Temporary webhook processing failure', + retryable: true, + route: 'status', + }); +}); + +test('sms webhook retry responses are stable across replay attempts', async () => { + const first = buildTwilioRetryableErrorResponse('sms'); + const replay = buildTwilioRetryableErrorResponse('sms'); + + assert.equal(first.status, 503); + assert.equal(replay.status, 503); + + assert.deepEqual(await first.json(), { + error: 'Temporary webhook processing failure', + retryable: true, + route: 'sms', + }); + assert.deepEqual(await replay.json(), { + error: 'Temporary webhook processing failure', + retryable: true, + route: 'sms', + }); +}); From 95bb6812e66299e0ac0f2ed75ef389495ce110a5 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 20:53:40 -0500 Subject: [PATCH 02/14] docs: record G2 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 0451402..c02f528 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -218,4 +218,4 @@ Dependencies: G4 (recommended) - `tests/twilio-webhook-retry.test.ts` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - pending (added in immediate follow-up changelog commit) + - `d0dff5e` From 65321346ceafece8d6e8799037abdd74f0fe2b9e Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 20:58:27 -0500 Subject: [PATCH 03/14] hardening: G3 dedupe usage-limit notifications --- app/api/twilio/status/route.ts | 38 +++++++++++++++++++ docs/PRODUCTION_READINESS_GAPS.md | 34 +++++++++++++++++ lib/portfolio-demo.ts | 1 + lib/usage-limit-notification.ts | 27 +++++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + tests/usage-limit-notification.test.ts | 23 +++++++++++ 7 files changed, 126 insertions(+) create mode 100644 lib/usage-limit-notification.ts create mode 100644 prisma/migrations/20260302010000_add_usage_limit_notified_at/migration.sql create mode 100644 tests/usage-limit-notification.test.ts diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index 958b8be..8d6d113 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -12,6 +12,7 @@ import { extractTwilioRecordingMetadata } from '@/lib/twilio-recording'; import { buildTwilioRetryableErrorResponse } from '@/lib/twilio-webhook-retry'; import { hasValidTwilioWebhookRequest } from '@/lib/twilio-webhook'; import { messagingTwiML } from '@/lib/twiml'; +import { claimUsageLimitNotification } from '@/lib/usage-limit-notification'; import { describeUsageLimit, getConversationUsageForBusiness, isConversationLimitReached } from '@/lib/usage'; export const runtime = 'nodejs'; @@ -272,7 +273,32 @@ export async function POST(request: Request) { }, }); + if (lead.usageLimitNotifiedAt) { + logTwilioInfo('status', 'usage_limit_owner_notify_already_recorded', { + callSid, + dialCallSid, + eventType: 'dial_status_callback', + businessId: business.id, + leadId: lead.id, + decision: 'noop_usage_limit_notification_already_recorded', + }); + return xmlOk(); + } + if (business.notifyPhone) { + const claimed = await claimUsageLimitNotification(db, lead.id); + if (!claimed) { + logTwilioInfo('status', 'usage_limit_owner_notify_already_claimed', { + callSid, + dialCallSid, + eventType: 'dial_status_callback', + businessId: business.id, + leadId: lead.id, + decision: 'noop_usage_limit_notification_already_claimed', + }); + return xmlOk(); + } + try { const notifyResult = await sendAndPersistOutboundMessage({ businessId: business.id, @@ -304,6 +330,18 @@ export async function POST(request: Request) { decision: 'owner_notification_sent', }); } catch (notifyError) { + try { + await db.lead.update({ + where: { id: lead.id }, + data: { + usageLimitNotifiedAt: null, + lastInteractionAt: new Date(), + }, + }); + } catch { + // best-effort reset; retry may still be deduped if reset fails + } + logTwilioError( 'status', 'usage_limit_owner_notify_failed', diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index c02f528..93760d1 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -219,3 +219,37 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `d0dff5e` + +- 2026-03-02 - G3 (DONE) + - Branch: `hardening/g3-dedupe-usage-limit-notifications` + - What changed: + - Added durable usage-limit owner-notification claim marker to `Lead`: + - `usageLimitNotifiedAt` in `prisma/schema.prisma` + - migration: `prisma/migrations/20260302010000_add_usage_limit_notified_at/migration.sql` + - Added atomic claim helper `lib/usage-limit-notification.ts` using `updateMany where usageLimitNotifiedAt=null` so only one replay attempt can claim notification send ownership. + - Updated `app/api/twilio/status/route.ts` usage-limit branch to: + - short-circuit when notification was already recorded/claimed + - claim once before owner-notification send + - perform best-effort claim reset on send failure to allow retried delivery + - Updated demo lead factory defaults in `lib/portfolio-demo.ts` for new field compatibility. + - Added `tests/usage-limit-notification.test.ts` to verify first-claim/duplicate-replay behavior. + - Idempotency notes: + - Duplicate Twilio replay callbacks can no longer create repeated usage-limit owner notification sends for the same lead once claimed. + - Claim path is atomic at DB level (`updateMany` count gate) and replay-safe. + - Commands run + results: + - `npm test` -> PASS (23/23) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `app/api/twilio/status/route.ts` + - `lib/usage-limit-notification.ts` + - `lib/portfolio-demo.ts` + - `prisma/schema.prisma` + - `prisma/migrations/20260302010000_add_usage_limit_notified_at/migration.sql` + - `tests/usage-limit-notification.test.ts` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` diff --git a/lib/portfolio-demo.ts b/lib/portfolio-demo.ts index 57b3d8e..39c14b4 100644 --- a/lib/portfolio-demo.ts +++ b/lib/portfolio-demo.ts @@ -78,6 +78,7 @@ function makeLead(input: Partial & Pick; + }; +}; + +export async function claimUsageLimitNotification( + client: LeadUpdateManyClient, + leadId: string, + now: Date = new Date() +) { + const result = await client.lead.updateMany({ + where: { + id: leadId, + usageLimitNotifiedAt: null, + }, + data: { + usageLimitNotifiedAt: now, + lastInteractionAt: now, + }, + }); + + return result.count > 0; +} diff --git a/prisma/migrations/20260302010000_add_usage_limit_notified_at/migration.sql b/prisma/migrations/20260302010000_add_usage_limit_notified_at/migration.sql new file mode 100644 index 0000000..7cc0f63 --- /dev/null +++ b/prisma/migrations/20260302010000_add_usage_limit_notified_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Lead" ADD COLUMN "usageLimitNotifiedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b8dd82..a0805cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,6 +118,7 @@ model Lead { bestTime String? contactName String? ownerNotifiedAt DateTime? + usageLimitNotifiedAt DateTime? smsStartedAt DateTime? smsCompletedAt DateTime? lastInboundAt DateTime? diff --git a/tests/usage-limit-notification.test.ts b/tests/usage-limit-notification.test.ts new file mode 100644 index 0000000..57eb374 --- /dev/null +++ b/tests/usage-limit-notification.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { claimUsageLimitNotification } from '../lib/usage-limit-notification.ts'; + +test('claimUsageLimitNotification returns true only for first claim', async () => { + let claimed = false; + const fakeClient = { + lead: { + async updateMany() { + if (claimed) return { count: 0 }; + claimed = true; + return { count: 1 }; + }, + }, + }; + + const first = await claimUsageLimitNotification(fakeClient, 'lead_123', new Date('2026-03-02T00:00:00.000Z')); + const replay = await claimUsageLimitNotification(fakeClient, 'lead_123', new Date('2026-03-02T00:00:01.000Z')); + + assert.equal(first, true); + assert.equal(replay, false); +}); From 4f4ef0f08b40d4c12982c3b7b642cdd2992fe6f6 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 20:58:39 -0500 Subject: [PATCH 04/14] docs: record G3 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 93760d1..bc6f5db 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -252,4 +252,4 @@ Dependencies: G4 (recommended) - `tests/usage-limit-notification.test.ts` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `6532134` From 80a23c3d968a43b301aa3e4acd3416da995c99da Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:04:50 -0500 Subject: [PATCH 05/14] hardening: G5 add provider-safe rate limiting --- .env.example | 8 ++ README.md | 2 + app/api/stripe/webhook/route.ts | 33 +++++++ app/api/twilio/sms/route.ts | 53 +++++++++++- app/api/twilio/status/route.ts | 53 +++++++++++- app/api/twilio/voice/route.ts | 59 ++++++++++++- docs/PRODUCTION_ENV.md | 7 ++ docs/PRODUCTION_READINESS_GAPS.md | 44 ++++++++++ lib/rate-limit-config.ts | 31 +++++++ lib/rate-limit.ts | 138 ++++++++++++++++++++++++++++++ middleware.ts | 21 +++++ tests/rate-limit.test.ts | 66 ++++++++++++++ 12 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 lib/rate-limit-config.ts create mode 100644 lib/rate-limit.ts create mode 100644 tests/rate-limit.test.ts diff --git a/.env.example b/.env.example index fb5f130..be5086f 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,14 @@ TWILIO_VALIDATE_SIGNATURE= DEBUG_ENV_ENDPOINT_TOKEN= PORTFOLIO_DEMO_MODE= +# Optional rate limiting (defaults are safe for provider webhooks) +# RATE_LIMIT_WINDOW_MS=60000 +# RATE_LIMIT_TWILIO_AUTH_MAX=240 +# RATE_LIMIT_TWILIO_UNAUTH_MAX=40 +# RATE_LIMIT_STRIPE_AUTH_MAX=240 +# RATE_LIMIT_STRIPE_UNAUTH_MAX=40 +# RATE_LIMIT_PROTECTED_API_MAX=80 + # Vercel system envs (auto-set on Vercel; optional locally for fallback testing only) # VERCEL_ENV= # VERCEL_URL= diff --git a/README.md b/README.md index 445d64b..80ec288 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Required categories: - Stripe keys + price IDs + webhook secret - Twilio credentials + webhook auth token - Database URL +- Optional rate-limit tuning vars (defaults are built in) ### 4. Run Prisma migrations / generate client @@ -249,6 +250,7 @@ Compliance handling: Security / idempotency notes: - Invalid webhook token -> `401` +- Unauthorized webhook bursts are rate-limited with `429` (`Retry-After` + `X-RateLimit-*` headers) - Duplicate inbound SMS retries with the same `MessageSid` are deduped via `Message.twilioSid` and ignored after persistence check - Webhook handlers log structured events (`callSid` / `messageSid`, event type, decision) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 8bc19f2..e0922d6 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'; import Stripe from 'stripe'; import { db } from '@/lib/db'; +import { RATE_LIMIT_STRIPE_AUTH_MAX, RATE_LIMIT_STRIPE_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { getStripe } from '@/lib/stripe'; export const runtime = 'nodejs'; @@ -82,6 +84,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { } export async function POST(request: Request) { + const clientIp = getClientIpAddress(request); const signature = request.headers.get('stripe-signature'); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!signature || !webhookSecret) { @@ -95,10 +98,40 @@ export async function POST(request: Request) { try { event = stripe.webhooks.constructEvent(payload, signature, webhookSecret); } catch (error) { + const unauthRateLimit = consumeRateLimit({ + key: `stripe:webhook:unauth:${clientIp}`, + limit: RATE_LIMIT_STRIPE_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!unauthRateLimit.allowed) { + console.warn('Stripe webhook rate-limited (invalid signature burst)', { + clientIp, + decision: 'reject_429', + }); + return NextResponse.json( + { error: 'Too many invalid webhook attempts' }, + { status: 429, headers: buildRateLimitHeaders(unauthRateLimit) } + ); + } + const message = error instanceof Error ? error.message : 'Invalid webhook signature'; return NextResponse.json({ error: message }, { status: 400 }); } + const authRateLimit = consumeRateLimit({ + key: `stripe:webhook:auth:${clientIp}`, + limit: RATE_LIMIT_STRIPE_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + console.warn('Stripe webhook rate-limited', { + clientIp, + eventType: event.type, + decision: 'reject_429', + }); + return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: buildRateLimitHeaders(authRateLimit) }); + } + try { switch (event.type) { case 'checkout.session.completed': diff --git a/app/api/twilio/sms/route.ts b/app/api/twilio/sms/route.ts index facc083..6393158 100644 --- a/app/api/twilio/sms/route.ts +++ b/app/api/twilio/sms/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { advanceLeadConversation } from '@/lib/sms-state-machine'; import { isSubscriptionActive } from '@/lib/subscription'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; @@ -35,17 +37,66 @@ function retryableErrorResponse() { return buildTwilioRetryableErrorResponse('sms'); } +function rateLimitSmsResponse(retryAfterSeconds: number) { + return new NextResponse(messagingTwiML(), { + status: 429, + headers: { + 'Content-Type': 'text/xml', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} + export async function POST(request: Request) { let messageSid: string | null = null; try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + const accountSid = formField(formData, 'AccountSid'); + + const authorized = hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:sms:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('sms', 'webhook_unauthorized_rate_limited', { + eventType: 'inbound_sms', + decision: 'reject_429', + clientIp, + }); + return new NextResponse( + JSON.stringify({ error: 'Too many unauthorized requests' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } + ); + } - if (!hasValidTwilioWebhookRequest(request, payload)) { logTwilioWarn('sms', 'webhook_unauthorized', { decision: 'reject_401' }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const authRateLimit = consumeRateLimit({ + key: `twilio:sms:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + logTwilioWarn('sms', 'webhook_rate_limited', { + eventType: 'inbound_sms', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + const response = rateLimitSmsResponse(authRateLimit.retryAfterSeconds); + Object.entries(buildRateLimitHeaders(authRateLimit)).forEach(([name, value]) => { + response.headers.set(name, value); + }); + return response; + } + const to = normalizePhoneNumber(formField(formData, 'To')); const from = normalizePhoneNumber(formField(formData, 'From')); const body = formField(formData, 'Body'); diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index 8d6d113..13c017f 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -4,6 +4,8 @@ import { SubscriptionStatus } from '@prisma/client'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { getServicePrompt } from '@/lib/sms-state-machine'; import { isSubscriptionActive } from '@/lib/subscription'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; @@ -41,18 +43,67 @@ function retryableErrorResponse() { return buildTwilioRetryableErrorResponse('status'); } +function rateLimitStatusResponse(retryAfterSeconds: number) { + return new NextResponse(messagingTwiML(), { + status: 429, + headers: { + 'Content-Type': 'text/xml', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} + export async function POST(request: Request) { let callSid: string | null = null; let dialCallSid: string | null = null; try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + const accountSid = formField(formData, 'AccountSid'); + + const authorized = hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:status:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('status', 'webhook_unauthorized_rate_limited', { + eventType: 'dial_status_callback', + decision: 'reject_429', + clientIp, + }); + return new NextResponse( + JSON.stringify({ error: 'Too many unauthorized requests' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } + ); + } - if (!hasValidTwilioWebhookRequest(request, payload)) { logTwilioWarn('status', 'webhook_unauthorized', { decision: 'reject_401' }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const authRateLimit = consumeRateLimit({ + key: `twilio:status:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!authRateLimit.allowed) { + logTwilioWarn('status', 'webhook_rate_limited', { + eventType: 'dial_status_callback', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + const response = rateLimitStatusResponse(authRateLimit.retryAfterSeconds); + Object.entries(buildRateLimitHeaders(authRateLimit)).forEach(([name, value]) => { + response.headers.set(name, value); + }); + return response; + } + const to = normalizePhoneNumber(formField(formData, 'To')); const from = normalizePhoneNumber(formField(formData, 'From')); callSid = formField(formData, 'CallSid') || null; diff --git a/app/api/twilio/voice/route.ts b/app/api/twilio/voice/route.ts index 93989bc..005dd0b 100644 --- a/app/api/twilio/voice/route.ts +++ b/app/api/twilio/voice/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; import { normalizePhoneNumber } from '@/lib/phone'; +import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { logTwilioError, logTwilioInfo, logTwilioWarn } from '@/lib/twilio-logging'; import { buildDialRecordingOptions } from '@/lib/twilio-recording'; import { hasValidTwilioWebhookRequest } from '@/lib/twilio-webhook'; @@ -25,13 +27,47 @@ function withWebhookToken(url: string) { return next.toString(); } +function rateLimitVoiceResponse(retryAfterSeconds: number) { + const xml = voiceTwiML((response) => { + response.say('Too many requests. Please try again shortly.'); + response.hangup(); + }); + return new NextResponse(xml, { + status: 429, + headers: { + 'Content-Type': 'text/xml', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} + export async function POST(request: Request) { let callSid: string | null = null; try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; + const clientIp = getClientIpAddress(request); + + const authorized = hasValidTwilioWebhookRequest(request, payload); + if (!authorized) { + const rateLimit = consumeRateLimit({ + key: `twilio:voice:unauth:${clientIp}`, + limit: RATE_LIMIT_TWILIO_UNAUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('voice', 'webhook_unauthorized_rate_limited', { + callSid, + eventType: 'incoming_call', + decision: 'reject_429', + clientIp, + }); + return new NextResponse( + JSON.stringify({ error: 'Too many unauthorized requests' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } + ); + } - if (!hasValidTwilioWebhookRequest(request, payload)) { logTwilioWarn('voice', 'webhook_unauthorized', { decision: 'reject_401' }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -39,6 +75,27 @@ export async function POST(request: Request) { const to = normalizePhoneNumber(formField(formData, 'To')); const from = normalizePhoneNumber(formField(formData, 'From')); callSid = formField(formData, 'CallSid') || null; + const accountSid = formField(formData, 'AccountSid'); + + const rateLimit = consumeRateLimit({ + key: `twilio:voice:auth:${accountSid || clientIp}`, + limit: RATE_LIMIT_TWILIO_AUTH_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + if (!rateLimit.allowed) { + logTwilioWarn('voice', 'webhook_rate_limited', { + callSid, + eventType: 'incoming_call', + decision: 'reject_429', + accountSid: accountSid || null, + clientIp, + }); + const response = rateLimitVoiceResponse(rateLimit.retryAfterSeconds); + Object.entries(buildRateLimitHeaders(rateLimit)).forEach(([name, value]) => { + response.headers.set(name, value); + }); + return response; + } logTwilioInfo('voice', 'webhook_received', { callSid, diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index 1459840..3cf8d81 100644 --- a/docs/PRODUCTION_ENV.md +++ b/docs/PRODUCTION_ENV.md @@ -30,6 +30,12 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s | `TWILIO_VALIDATE_SIGNATURE` | Server-only | Yes (production) | Vercel | Must be `true` in production. Twilio webhooks require valid `X-Twilio-Signature` verification using `TWILIO_AUTH_TOKEN`; production fails closed otherwise. | | `DEBUG_ENV_ENDPOINT_TOKEN` | Server-only | Optional | Vercel | Protects `/api/debug/env` in production. If unset, the endpoint returns `404` in production. | | `PORTFOLIO_DEMO_MODE` | Server-only | Optional | Local / Vercel | Enables demo data/auth bypass mode for portfolio/demo screenshots. Keep disabled in production unless intentionally using demo mode. | +| `RATE_LIMIT_WINDOW_MS` | Server-only | Optional | Vercel | Shared rate-limit window in milliseconds. Default `60000`. | +| `RATE_LIMIT_TWILIO_AUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for valid/authorized traffic. Default `240`. | +| `RATE_LIMIT_TWILIO_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for unauthorized traffic. Default `40`. | +| `RATE_LIMIT_STRIPE_AUTH_MAX` | Server-only | Optional | Vercel | Max Stripe webhook requests per window for valid-signed traffic. Default `240`. | +| `RATE_LIMIT_STRIPE_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Stripe webhook requests per window for invalid-signature traffic. Default `40`. | +| `RATE_LIMIT_PROTECTED_API_MAX` | Server-only | Optional | Vercel | Max requests per window for protected Stripe mutation APIs (`/api/stripe/checkout`, `/api/stripe/portal`). Default `80`. | ## Runtime Validation (Production) @@ -44,6 +50,7 @@ The app now validates required server env vars at runtime in production via `lib - Twilio webhook auth behavior: - Production: `TWILIO_VALIDATE_SIGNATURE=true` is required and token-only auth is rejected - Non-production: signature mode can fall back to shared-token auth for local/dev workflows +- Rate limiting defaults are tuned to avoid blocking normal Twilio/Stripe provider traffic while still throttling abusive bursts. Tune limits only if you observe false positives in logs. - `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`. ## Vercel: Preview vs Production diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index bc6f5db..3b56757 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -253,3 +253,47 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `6532134` + +- 2026-03-02 - G5 (DONE) + - Branch: `hardening/g5-rate-limiting` + - What changed: + - Added shared in-memory limiter utilities: + - `lib/rate-limit.ts` (bucket store, client IP extraction, rate-limit headers) + - `lib/rate-limit-config.ts` (env-tunable defaults) + - Added auth-aware webhook throttling: + - Twilio `voice/status/sms` routes now enforce: + - stricter unauthenticated burst limits + - higher limits for authorized provider traffic + - Stripe webhook now enforces: + - stricter invalid-signature burst limits + - higher limits for valid-signed webhook traffic + - Added middleware throttling for protected Stripe mutation APIs: + - `/api/stripe/checkout` + - `/api/stripe/portal` + - Added rate-limit unit tests in `tests/rate-limit.test.ts`. + - Documented optional rate-limit env knobs in `.env.example`, `README.md`, and `docs/PRODUCTION_ENV.md`. + - Safety notes: + - Twilio/Stripe normal traffic is protected by separate authorized-vs-unauthorized thresholds to reduce false positives. + - All 429 responses include `Retry-After` and `X-RateLimit-*` headers. + - Commands run + results: + - `npm test` -> PASS (26/26) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `lib/rate-limit.ts` + - `lib/rate-limit-config.ts` + - `middleware.ts` + - `app/api/twilio/voice/route.ts` + - `app/api/twilio/status/route.ts` + - `app/api/twilio/sms/route.ts` + - `app/api/stripe/webhook/route.ts` + - `tests/rate-limit.test.ts` + - `.env.example` + - `README.md` + - `docs/PRODUCTION_ENV.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` diff --git a/lib/rate-limit-config.ts b/lib/rate-limit-config.ts new file mode 100644 index 0000000..0692146 --- /dev/null +++ b/lib/rate-limit-config.ts @@ -0,0 +1,31 @@ +import { getRateLimitNumber } from './rate-limit'; + +export const RATE_LIMIT_WINDOW_MS = getRateLimitNumber('RATE_LIMIT_WINDOW_MS', 60_000, { + min: 1_000, + max: 3_600_000, +}); + +export const RATE_LIMIT_TWILIO_AUTH_MAX = getRateLimitNumber('RATE_LIMIT_TWILIO_AUTH_MAX', 240, { + min: 10, + max: 10_000, +}); + +export const RATE_LIMIT_TWILIO_UNAUTH_MAX = getRateLimitNumber('RATE_LIMIT_TWILIO_UNAUTH_MAX', 40, { + min: 5, + max: 5_000, +}); + +export const RATE_LIMIT_STRIPE_AUTH_MAX = getRateLimitNumber('RATE_LIMIT_STRIPE_AUTH_MAX', 240, { + min: 10, + max: 10_000, +}); + +export const RATE_LIMIT_STRIPE_UNAUTH_MAX = getRateLimitNumber('RATE_LIMIT_STRIPE_UNAUTH_MAX', 40, { + min: 5, + max: 5_000, +}); + +export const RATE_LIMIT_PROTECTED_API_MAX = getRateLimitNumber('RATE_LIMIT_PROTECTED_API_MAX', 80, { + min: 10, + max: 10_000, +}); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..4b19545 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,138 @@ +type RateLimitBucket = { + count: number; + resetAtMs: number; +}; + +export type RateLimitDecision = { + allowed: boolean; + limit: number; + remaining: number; + resetAtMs: number; + retryAfterSeconds: number; +}; + +const bucketStore = new Map(); +let consumeCount = 0; + +function clampInteger(value: number, min: number, max: number) { + const next = Math.trunc(value); + if (!Number.isFinite(next)) return min; + if (next < min) return min; + if (next > max) return max; + return next; +} + +function pruneExpiredBuckets(nowMs: number) { + consumeCount += 1; + if (consumeCount % 200 !== 0) return; + + for (const [key, bucket] of bucketStore.entries()) { + if (bucket.resetAtMs <= nowMs) { + bucketStore.delete(key); + } + } +} + +export function consumeRateLimit(input: { key: string; limit: number; windowMs: number; nowMs?: number }): RateLimitDecision { + const nowMs = input.nowMs ?? Date.now(); + const limit = clampInteger(input.limit, 1, 1_000_000); + const windowMs = clampInteger(input.windowMs, 1_000, 3_600_000); + const key = input.key.trim(); + + if (!key) { + return { + allowed: true, + limit, + remaining: limit, + resetAtMs: nowMs + windowMs, + retryAfterSeconds: 0, + }; + } + + const existing = bucketStore.get(key); + if (!existing || existing.resetAtMs <= nowMs) { + const next: RateLimitBucket = { count: 1, resetAtMs: nowMs + windowMs }; + bucketStore.set(key, next); + pruneExpiredBuckets(nowMs); + return { + allowed: true, + limit, + remaining: Math.max(limit - 1, 0), + resetAtMs: next.resetAtMs, + retryAfterSeconds: 0, + }; + } + + if (existing.count >= limit) { + const retryAfterMs = Math.max(existing.resetAtMs - nowMs, 0); + return { + allowed: false, + limit, + remaining: 0, + resetAtMs: existing.resetAtMs, + retryAfterSeconds: Math.ceil(retryAfterMs / 1000), + }; + } + + existing.count += 1; + bucketStore.set(key, existing); + + return { + allowed: true, + limit, + remaining: Math.max(limit - existing.count, 0), + resetAtMs: existing.resetAtMs, + retryAfterSeconds: 0, + }; +} + +export function getClientIpAddress(request: Pick) { + const headers = request.headers; + const forwardedFor = headers.get('x-forwarded-for')?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(',')[0]?.trim(); + if (first) return first; + } + + const realIp = headers.get('x-real-ip')?.trim(); + if (realIp) return realIp; + + const cloudflareIp = headers.get('cf-connecting-ip')?.trim(); + if (cloudflareIp) return cloudflareIp; + + return 'unknown'; +} + +export function getRateLimitNumber( + envName: string, + fallback: number, + options: { + min?: number; + max?: number; + env?: Record; + } = {} +) { + const env = options.env ?? process.env; + const min = options.min ?? 1; + const max = options.max ?? 1_000_000; + const raw = env[envName]?.trim(); + if (!raw) return clampInteger(fallback, min, max); + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return clampInteger(fallback, min, max); + return clampInteger(parsed, min, max); +} + +export function buildRateLimitHeaders(result: RateLimitDecision) { + return { + 'Retry-After': String(result.retryAfterSeconds), + 'X-RateLimit-Limit': String(result.limit), + 'X-RateLimit-Remaining': String(result.remaining), + 'X-RateLimit-Reset': String(Math.ceil(result.resetAtMs / 1000)), + }; +} + +export function resetRateLimitStore() { + bucketStore.clear(); + consumeCount = 0; +} diff --git a/middleware.ts b/middleware.ts index 05054ef..4ffa092 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,11 @@ +import { NextResponse } from 'next/server'; import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +import { RATE_LIMIT_PROTECTED_API_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; +import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; + const isProtectedRoute = createRouteMatcher(['/app(.*)', '/api/stripe/checkout(.*)', '/api/stripe/portal(.*)']); +const isProtectedApiMutationRoute = createRouteMatcher(['/api/stripe/checkout', '/api/stripe/portal']); export default clerkMiddleware(async (auth, req) => { if (process.env.PORTFOLIO_DEMO_MODE === '1') { @@ -10,6 +15,22 @@ export default clerkMiddleware(async (auth, req) => { if (isProtectedRoute(req)) { await auth.protect(); } + + if (req.method === 'POST' && isProtectedApiMutationRoute(req)) { + const clientIp = getClientIpAddress(req); + const rateLimit = consumeRateLimit({ + key: `middleware:protected-api:${clientIp}`, + limit: RATE_LIMIT_PROTECTED_API_MAX, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many requests' }, + { status: 429, headers: buildRateLimitHeaders(rateLimit) } + ); + } + } }); export const config = { diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts new file mode 100644 index 0000000..5f1e018 --- /dev/null +++ b/tests/rate-limit.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + consumeRateLimit, + getClientIpAddress, + getRateLimitNumber, + resetRateLimitStore, +} from '../lib/rate-limit.ts'; + +test('consumeRateLimit blocks once limit is exceeded and resets after window', () => { + resetRateLimitStore(); + + const first = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 1_000, + }); + const second = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 1_100, + }); + const blocked = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 1_200, + }); + const afterWindow = consumeRateLimit({ + key: 'twilio:test:127.0.0.1', + limit: 2, + windowMs: 10_000, + nowMs: 11_500, + }); + + assert.equal(first.allowed, true); + assert.equal(second.allowed, true); + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterSeconds > 0, true); + assert.equal(afterWindow.allowed, true); +}); + +test('getClientIpAddress prefers x-forwarded-for first value', () => { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-for': '203.0.113.1, 198.51.100.2', + 'x-real-ip': '198.51.100.20', + }, + }); + + assert.equal(getClientIpAddress(request), '203.0.113.1'); +}); + +test('getRateLimitNumber uses fallback for invalid env input', () => { + const env = { + RATE_LIMIT_TWILIO_AUTH_MAX: 'bad', + }; + + assert.equal( + getRateLimitNumber('RATE_LIMIT_TWILIO_AUTH_MAX', 240, { env }), + 240 + ); +}); From b878c090f29fe2903e63929b1b5c342ecb75e8aa Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:04:59 -0500 Subject: [PATCH 06/14] docs: record G5 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 3b56757..b2b63af 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -296,4 +296,4 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_ENV.md` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `80a23c3` From 119c217ef762cbfb1ed2f0d94c3d8eb76b59eea1 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:11:58 -0500 Subject: [PATCH 07/14] hardening: G8 add correlation IDs and alert hooks --- .env.example | 5 ++ README.md | 1 + RUNBOOK.md | 12 +++- app/api/stripe/webhook/route.ts | 42 ++++++++++--- app/api/twilio/sms/route.ts | 47 ++++++++++----- app/api/twilio/status/route.ts | 65 +++++++++++++------- app/api/twilio/voice/route.ts | 27 ++++++--- docs/PRODUCTION_ENV.md | 4 ++ docs/PRODUCTION_READINESS_GAPS.md | 47 +++++++++++++++ lib/observability.ts | 98 +++++++++++++++++++++++++++++++ lib/twilio-logging.ts | 13 +++- tests/observability.test.ts | 42 +++++++++++++ 12 files changed, 351 insertions(+), 52 deletions(-) create mode 100644 lib/observability.ts create mode 100644 tests/observability.test.ts diff --git a/.env.example b/.env.example index be5086f..84dd23c 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ PORTFOLIO_DEMO_MODE= # RATE_LIMIT_STRIPE_UNAUTH_MAX=40 # RATE_LIMIT_PROTECTED_API_MAX=80 +# Optional observability + alerting +# ALERT_WEBHOOK_URL= +# ALERT_WEBHOOK_TOKEN= +# ALERT_WEBHOOK_TIMEOUT_MS=4000 + # Vercel system envs (auto-set on Vercel; optional locally for fallback testing only) # VERCEL_ENV= # VERCEL_URL= diff --git a/README.md b/README.md index 80ec288..e1dfedd 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ When a customer calls a business's Twilio number and the forwarded call is misse - SMS compliance commands (`STOP` / `START` / `HELP`) with DB-backed opt-out state - Call recording enabled on forwarded calls + recording metadata captured on callbacks - Twilio webhook protection: production-enforced `X-Twilio-Signature` validation, with shared-token fallback only in non-production +- Webhook observability baseline: correlation IDs (`X-Correlation-Id`), centralized `app.error` reporting, optional alert webhook dispatch ## Local Setup diff --git a/RUNBOOK.md b/RUNBOOK.md index 23c2f11..725d0f8 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -42,6 +42,7 @@ - Vercel runtime logs: - API route logs for `/api/twilio/voice`, `/api/twilio/status`, `/api/twilio/sms` - Look for structured prefixes: `twilio.voice`, `twilio.status`, `twilio.sms`, `twilio.messaging`, `twilio.webhook-auth` + - Look for centralized error events: `app.error` (includes `correlationId`, `source`, `event`, and metadata) - Twilio Console: - Phone Number webhook logs / Debugger - Call Logs and Recordings @@ -49,6 +50,16 @@ - Neon: - Query activity / connection issues (if DB errors occur) +## Observability + Alerts + +- Every Twilio/Stripe webhook response now includes `X-Correlation-Id`. +- For incident triage, capture the correlation ID from provider delivery logs and search Vercel logs for that ID. +- Optional alert wiring: + 1. Set `ALERT_WEBHOOK_URL` in Vercel (Slack/PagerDuty/incident gateway endpoint). + 2. Optionally set `ALERT_WEBHOOK_TOKEN` if your endpoint requires bearer auth. + 3. Optionally set `ALERT_WEBHOOK_TIMEOUT_MS` (default `4000`). + 4. Redeploy and induce a safe synthetic webhook failure in non-production to confirm alert delivery. + ## Common Failure Modes - Twilio webhooks return `401` @@ -67,4 +78,3 @@ - `DATABASE_URL` / `DIRECT_DATABASE_URL` swapped - Missing `sslmode=require` - `DIRECT_DATABASE_URL` accidentally using Neon pooler host - diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index e0922d6..a8869e0 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import Stripe from 'stripe'; import { db } from '@/lib/db'; +import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; import { RATE_LIMIT_STRIPE_AUTH_MAX, RATE_LIMIT_STRIPE_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; import { getStripe } from '@/lib/stripe'; @@ -85,10 +86,12 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { export async function POST(request: Request) { const clientIp = getClientIpAddress(request); + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); const signature = request.headers.get('stripe-signature'); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!signature || !webhookSecret) { - return NextResponse.json({ error: 'Missing Stripe webhook configuration' }, { status: 400 }); + return withCorrelation(NextResponse.json({ error: 'Missing Stripe webhook configuration' }, { status: 400 })); } const payload = await request.text(); @@ -106,16 +109,29 @@ export async function POST(request: Request) { if (!unauthRateLimit.allowed) { console.warn('Stripe webhook rate-limited (invalid signature burst)', { clientIp, + correlationId, decision: 'reject_429', }); - return NextResponse.json( + return withCorrelation( + NextResponse.json( { error: 'Too many invalid webhook attempts' }, { status: 429, headers: buildRateLimitHeaders(unauthRateLimit) } + ) ); } const message = error instanceof Error ? error.message : 'Invalid webhook signature'; - return NextResponse.json({ error: message }, { status: 400 }); + reportApplicationError({ + source: 'stripe.webhook', + event: 'invalid_signature', + correlationId, + error, + alert: false, + metadata: { + clientIp, + }, + }); + return withCorrelation(NextResponse.json({ error: message }, { status: 400 })); } const authRateLimit = consumeRateLimit({ @@ -126,10 +142,13 @@ export async function POST(request: Request) { if (!authRateLimit.allowed) { console.warn('Stripe webhook rate-limited', { clientIp, + correlationId, eventType: event.type, decision: 'reject_429', }); - return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: buildRateLimitHeaders(authRateLimit) }); + return withCorrelation( + NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: buildRateLimitHeaders(authRateLimit) }) + ); } try { @@ -168,9 +187,18 @@ export async function POST(request: Request) { break; } } catch (error) { - console.error('Stripe webhook handler error', error); - return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 }); + reportApplicationError({ + source: 'stripe.webhook', + event: 'handler_error', + correlationId, + error, + metadata: { + clientIp, + eventType: event.type, + }, + }); + return withCorrelation(NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })); } - return NextResponse.json({ received: true }); + return withCorrelation(NextResponse.json({ received: true })); } diff --git a/app/api/twilio/sms/route.ts b/app/api/twilio/sms/route.ts index 6393158..941243a 100644 --- a/app/api/twilio/sms/route.ts +++ b/app/api/twilio/sms/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; +import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; import { normalizePhoneNumber } from '@/lib/phone'; import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; @@ -49,6 +50,8 @@ function rateLimitSmsResponse(retryAfterSeconds: number) { export async function POST(request: Request) { let messageSid: string | null = null; + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: Response) => withCorrelationIdHeader(response, correlationId); try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; @@ -64,18 +67,19 @@ export async function POST(request: Request) { }); if (!rateLimit.allowed) { logTwilioWarn('sms', 'webhook_unauthorized_rate_limited', { + correlationId, eventType: 'inbound_sms', decision: 'reject_429', clientIp, }); - return new NextResponse( + return withCorrelation(new NextResponse( JSON.stringify({ error: 'Too many unauthorized requests' }), { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } - ); + )); } - logTwilioWarn('sms', 'webhook_unauthorized', { decision: 'reject_401' }); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + logTwilioWarn('sms', 'webhook_unauthorized', { correlationId, decision: 'reject_401' }); + return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); } const authRateLimit = consumeRateLimit({ @@ -85,6 +89,7 @@ export async function POST(request: Request) { }); if (!authRateLimit.allowed) { logTwilioWarn('sms', 'webhook_rate_limited', { + correlationId, eventType: 'inbound_sms', decision: 'reject_429', accountSid: accountSid || null, @@ -94,7 +99,7 @@ export async function POST(request: Request) { Object.entries(buildRateLimitHeaders(authRateLimit)).forEach(([name, value]) => { response.headers.set(name, value); }); - return response; + return withCorrelation(response); } const to = normalizePhoneNumber(formField(formData, 'To')); @@ -104,6 +109,7 @@ export async function POST(request: Request) { logTwilioInfo('sms', 'webhook_received', { messageSid, + correlationId, eventType: 'inbound_sms', decision: 'processing', }); @@ -111,20 +117,22 @@ export async function POST(request: Request) { if (!to || !from) { logTwilioWarn('sms', 'missing_required_fields', { messageSid, + correlationId, eventType: 'inbound_sms', decision: 'noop_missing_to_or_from', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const business = await findBusinessByTwilioNumber(to); if (!business) { logTwilioWarn('sms', 'business_not_found', { messageSid, + correlationId, eventType: 'inbound_sms', decision: 'noop_business_not_found', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const inbound = await persistInboundMessage({ @@ -148,6 +156,7 @@ export async function POST(request: Request) { if (compliance.handled) { logTwilioInfo('sms', 'compliance_keyword_handled', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, command: compliance.command, @@ -155,18 +164,19 @@ export async function POST(request: Request) { duplicateInbound: inbound.duplicate, decision: 'reply_compliance_message', }); - return xmlOk(compliance.replyText); + return withCorrelation(xmlOk(compliance.replyText)); } if (inbound.duplicate) { logTwilioInfo('sms', 'duplicate_message_retry', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: null, decision: 'noop_duplicate', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const lead = @@ -186,11 +196,12 @@ export async function POST(request: Request) { if (!lead) { logTwilioInfo('sms', 'no_matching_lead', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, decision: 'noop_no_lead_thread', }); - return xmlOk(); + return withCorrelation(xmlOk()); } await db.message.update({ @@ -209,6 +220,7 @@ export async function POST(request: Request) { if (!isSubscriptionActive(business.subscriptionStatus) || lead.billingRequired || !business.twilioPhoneNumber) { logTwilioInfo('sms', 'automation_blocked', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: lead.id, @@ -218,7 +230,7 @@ export async function POST(request: Request) { ? 'noop_billing_required' : 'noop_missing_twilio_number', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const transition = advanceLeadConversation(lead, body, business); @@ -237,6 +249,7 @@ export async function POST(request: Request) { logTwilioInfo('sms', 'state_machine_transition', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -272,6 +285,7 @@ export async function POST(request: Request) { if (ownerSend.suppressed) { logTwilioWarn('sms', 'owner_notification_suppressed', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -284,6 +298,7 @@ export async function POST(request: Request) { }); logTwilioInfo('sms', 'owner_notification_sent', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -296,6 +311,7 @@ export async function POST(request: Request) { 'owner_notification_failed', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -318,6 +334,7 @@ export async function POST(request: Request) { if (leadSend.suppressed) { logTwilioWarn('sms', 'lead_reply_suppressed', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -334,6 +351,7 @@ export async function POST(request: Request) { logTwilioInfo('sms', 'lead_reply_sent', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -346,6 +364,7 @@ export async function POST(request: Request) { 'lead_reply_send_failed', { messageSid, + correlationId, eventType: 'inbound_sms', businessId: business.id, leadId: updatedLead.id, @@ -355,9 +374,9 @@ export async function POST(request: Request) { ); } - return xmlOk(); + return withCorrelation(xmlOk()); } catch (error) { - logTwilioError('sms', 'route_error', { messageSid, eventType: 'inbound_sms', decision: 'return_retryable_503' }, error); - return retryableErrorResponse(); + logTwilioError('sms', 'route_error', { messageSid, correlationId, eventType: 'inbound_sms', decision: 'return_retryable_503' }, error); + return withCorrelation(retryableErrorResponse()); } } diff --git a/app/api/twilio/status/route.ts b/app/api/twilio/status/route.ts index 13c017f..c6114b7 100644 --- a/app/api/twilio/status/route.ts +++ b/app/api/twilio/status/route.ts @@ -3,6 +3,7 @@ import { SubscriptionStatus } from '@prisma/client'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; +import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; import { normalizePhoneNumber } from '@/lib/phone'; import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; @@ -56,6 +57,8 @@ function rateLimitStatusResponse(retryAfterSeconds: number) { export async function POST(request: Request) { let callSid: string | null = null; let dialCallSid: string | null = null; + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: Response) => withCorrelationIdHeader(response, correlationId); try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; @@ -71,18 +74,19 @@ export async function POST(request: Request) { }); if (!rateLimit.allowed) { logTwilioWarn('status', 'webhook_unauthorized_rate_limited', { + correlationId, eventType: 'dial_status_callback', decision: 'reject_429', clientIp, }); - return new NextResponse( + return withCorrelation(new NextResponse( JSON.stringify({ error: 'Too many unauthorized requests' }), { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } - ); + )); } - logTwilioWarn('status', 'webhook_unauthorized', { decision: 'reject_401' }); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + logTwilioWarn('status', 'webhook_unauthorized', { correlationId, decision: 'reject_401' }); + return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); } const authRateLimit = consumeRateLimit({ @@ -92,6 +96,7 @@ export async function POST(request: Request) { }); if (!authRateLimit.allowed) { logTwilioWarn('status', 'webhook_rate_limited', { + correlationId, eventType: 'dial_status_callback', decision: 'reject_429', accountSid: accountSid || null, @@ -101,7 +106,7 @@ export async function POST(request: Request) { Object.entries(buildRateLimitHeaders(authRateLimit)).forEach(([name, value]) => { response.headers.set(name, value); }); - return response; + return withCorrelation(response); } const to = normalizePhoneNumber(formField(formData, 'To')); @@ -114,6 +119,7 @@ export async function POST(request: Request) { logTwilioInfo('status', 'webhook_received', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', dialCallStatus: dialCallStatus || null, recordingSid: recording?.recordingSid ?? null, @@ -141,23 +147,25 @@ export async function POST(request: Request) { logTwilioInfo('status', 'recording_metadata_persisted_only', { callSid, + correlationId, eventType: 'recording_status_callback', recordingSid: recording?.recordingSid ?? null, recordingStatus: recording?.recordingStatus ?? null, decision: updated.count > 0 ? 'update_call_recording_metadata' : 'noop_call_not_found', }); - return xmlOk(); + return withCorrelation(xmlOk()); } if (!to || !callSid) { logTwilioWarn('status', 'missing_required_fields', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', decision: 'noop_missing_to_or_callSid', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const business = await findBusinessByTwilioNumber(to); @@ -165,10 +173,11 @@ export async function POST(request: Request) { logTwilioWarn('status', 'business_not_found', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', decision: 'noop_business_not_found', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const answered = dialCallStatus.trim().toLowerCase() === 'completed'; @@ -211,6 +220,7 @@ export async function POST(request: Request) { logTwilioInfo('status', 'call_upserted', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, answered, @@ -222,6 +232,7 @@ export async function POST(request: Request) { logTwilioInfo('status', 'recording_metadata_persisted', { callSid, dialCallSid, + correlationId, eventType: 'recording_status_callback', businessId: business.id, recordingSid: recording?.recordingSid ?? null, @@ -234,11 +245,12 @@ export async function POST(request: Request) { logTwilioInfo('status', 'not_missed_noop', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, decision: 'noop_not_missed', }); - return xmlOk(); + return withCorrelation(xmlOk()); } const callerPhone = from || formField(formData, 'From'); @@ -260,6 +272,7 @@ export async function POST(request: Request) { logTwilioInfo('status', 'lead_created_for_missed_call', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -269,6 +282,7 @@ export async function POST(request: Request) { logTwilioInfo('status', 'lead_reused_for_retry', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -283,24 +297,26 @@ export async function POST(request: Request) { logTwilioInfo('status', 'billing_inactive_no_sms', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, decision: 'noop_billing_inactive', }); - return xmlOk(); + return withCorrelation(xmlOk()); } if (!business.twilioPhoneNumber || lead.smsStartedAt) { logTwilioInfo('status', 'already_started_or_missing_twilio_number', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, decision: lead.smsStartedAt ? 'noop_retry_sms_already_started' : 'noop_missing_twilio_number', }); - return xmlOk(); + return withCorrelation(xmlOk()); } try { @@ -309,6 +325,7 @@ export async function POST(request: Request) { logTwilioWarn('status', 'usage_limit_reached', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -328,12 +345,13 @@ export async function POST(request: Request) { logTwilioInfo('status', 'usage_limit_owner_notify_already_recorded', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, decision: 'noop_usage_limit_notification_already_recorded', }); - return xmlOk(); + return withCorrelation(xmlOk()); } if (business.notifyPhone) { @@ -342,12 +360,13 @@ export async function POST(request: Request) { logTwilioInfo('status', 'usage_limit_owner_notify_already_claimed', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, decision: 'noop_usage_limit_notification_already_claimed', }); - return xmlOk(); + return withCorrelation(xmlOk()); } try { @@ -365,16 +384,18 @@ export async function POST(request: Request) { logTwilioWarn('status', 'usage_limit_owner_notify_suppressed', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, decision: 'owner_notification_suppressed_opted_out', }); - return xmlOk(); + return withCorrelation(xmlOk()); } logTwilioInfo('status', 'usage_limit_owner_notified', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -399,6 +420,7 @@ export async function POST(request: Request) { { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -409,7 +431,7 @@ export async function POST(request: Request) { } } - return xmlOk(); + return withCorrelation(xmlOk()); } const prompt = getServicePrompt(business); @@ -425,12 +447,13 @@ export async function POST(request: Request) { logTwilioWarn('status', 'initial_missed_call_sms_suppressed', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, decision: 'skip_opted_out_recipient', }); - return xmlOk(); + return withCorrelation(xmlOk()); } await db.lead.update({ @@ -447,6 +470,7 @@ export async function POST(request: Request) { logTwilioInfo('status', 'initial_missed_call_sms_started', { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -459,6 +483,7 @@ export async function POST(request: Request) { { callSid, dialCallSid, + correlationId, eventType: 'dial_status_callback', businessId: business.id, leadId: lead.id, @@ -473,17 +498,17 @@ export async function POST(request: Request) { lastInteractionAt: new Date(), }, }); - return retryableErrorResponse(); + return withCorrelation(retryableErrorResponse()); } - return xmlOk(); + return withCorrelation(xmlOk()); } catch (error) { logTwilioError( 'status', 'route_error', - { callSid, dialCallSid, eventType: 'dial_status_callback', decision: 'return_retryable_503' }, + { callSid, dialCallSid, correlationId, eventType: 'dial_status_callback', decision: 'return_retryable_503' }, error ); - return retryableErrorResponse(); + return withCorrelation(retryableErrorResponse()); } } diff --git a/app/api/twilio/voice/route.ts b/app/api/twilio/voice/route.ts index 005dd0b..e246790 100644 --- a/app/api/twilio/voice/route.ts +++ b/app/api/twilio/voice/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { findBusinessByTwilioNumber } from '@/lib/business'; import { db } from '@/lib/db'; +import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; import { normalizePhoneNumber } from '@/lib/phone'; import { RATE_LIMIT_TWILIO_AUTH_MAX, RATE_LIMIT_TWILIO_UNAUTH_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; @@ -43,6 +44,8 @@ function rateLimitVoiceResponse(retryAfterSeconds: number) { export async function POST(request: Request) { let callSid: string | null = null; + const correlationId = getCorrelationIdFromRequest(request); + const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); try { const formData = await request.formData(); const payload = Object.fromEntries(formData.entries()) as Record; @@ -58,18 +61,19 @@ export async function POST(request: Request) { if (!rateLimit.allowed) { logTwilioWarn('voice', 'webhook_unauthorized_rate_limited', { callSid, + correlationId, eventType: 'incoming_call', decision: 'reject_429', clientIp, }); - return new NextResponse( + return withCorrelation(new NextResponse( JSON.stringify({ error: 'Too many unauthorized requests' }), { status: 429, headers: { 'Content-Type': 'application/json', ...buildRateLimitHeaders(rateLimit) } } - ); + )); } - logTwilioWarn('voice', 'webhook_unauthorized', { decision: 'reject_401' }); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + logTwilioWarn('voice', 'webhook_unauthorized', { correlationId, decision: 'reject_401' }); + return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); } const to = normalizePhoneNumber(formField(formData, 'To')); @@ -85,6 +89,7 @@ export async function POST(request: Request) { if (!rateLimit.allowed) { logTwilioWarn('voice', 'webhook_rate_limited', { callSid, + correlationId, eventType: 'incoming_call', decision: 'reject_429', accountSid: accountSid || null, @@ -94,11 +99,12 @@ export async function POST(request: Request) { Object.entries(buildRateLimitHeaders(rateLimit)).forEach(([name, value]) => { response.headers.set(name, value); }); - return response; + return withCorrelation(response); } logTwilioInfo('voice', 'webhook_received', { callSid, + correlationId, eventType: 'incoming_call', decision: 'processing', }); @@ -107,6 +113,7 @@ export async function POST(request: Request) { if (!business) { logTwilioWarn('voice', 'business_not_found', { callSid, + correlationId, eventType: 'incoming_call', decision: 'respond_not_configured', }); @@ -115,7 +122,7 @@ export async function POST(request: Request) { response.say('Sorry, this number is not configured.'); response.hangup(); }); - return new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } }); + return withCorrelation(new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } })); } if (callSid) { @@ -140,6 +147,7 @@ export async function POST(request: Request) { logTwilioInfo('voice', 'call_persisted', { callSid, + correlationId, eventType: 'incoming_call', businessId: business.id, decision: 'upsert_call', @@ -160,18 +168,19 @@ export async function POST(request: Request) { logTwilioInfo('voice', 'twiml_returned', { callSid, + correlationId, eventType: 'incoming_call', businessId: business.id, decision: 'dial_forwarding_number_with_recording', }); - return new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } }); + return withCorrelation(new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } })); } catch (error) { - logTwilioError('voice', 'route_error', { callSid, eventType: 'incoming_call', decision: 'fallback_hangup' }, error); + logTwilioError('voice', 'route_error', { callSid, correlationId, eventType: 'incoming_call', decision: 'fallback_hangup' }, error); const xml = voiceTwiML((response) => { response.say('Sorry, we are having trouble connecting your call right now.'); response.hangup(); }); - return new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } }); + return withCorrelation(new NextResponse(xml, { headers: { 'Content-Type': 'text/xml' } })); } } diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index 3cf8d81..a5fc7bd 100644 --- a/docs/PRODUCTION_ENV.md +++ b/docs/PRODUCTION_ENV.md @@ -36,6 +36,9 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s | `RATE_LIMIT_STRIPE_AUTH_MAX` | Server-only | Optional | Vercel | Max Stripe webhook requests per window for valid-signed traffic. Default `240`. | | `RATE_LIMIT_STRIPE_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Stripe webhook requests per window for invalid-signature traffic. Default `40`. | | `RATE_LIMIT_PROTECTED_API_MAX` | Server-only | Optional | Vercel | Max requests per window for protected Stripe mutation APIs (`/api/stripe/checkout`, `/api/stripe/portal`). Default `80`. | +| `ALERT_WEBHOOK_URL` | Server-only | Optional | Vercel / Ops | If set, critical application errors are POSTed to this webhook for alert fan-out (Slack/Pager/incident gateway). | +| `ALERT_WEBHOOK_TOKEN` | Server-only | Optional | Vercel / Ops | Optional bearer token added to alert webhook requests as `Authorization: Bearer `. | +| `ALERT_WEBHOOK_TIMEOUT_MS` | Server-only | Optional | Vercel / Ops | Timeout for alert webhook dispatch. Default `4000` ms. | ## Runtime Validation (Production) @@ -51,6 +54,7 @@ The app now validates required server env vars at runtime in production via `lib - Production: `TWILIO_VALIDATE_SIGNATURE=true` is required and token-only auth is rejected - Non-production: signature mode can fall back to shared-token auth for local/dev workflows - Rate limiting defaults are tuned to avoid blocking normal Twilio/Stripe provider traffic while still throttling abusive bursts. Tune limits only if you observe false positives in logs. +- Error reporting emits structured `app.error` logs and, when configured, dispatches alert payloads to `ALERT_WEBHOOK_URL`. - `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`. ## Vercel: Preview vs Production diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index b2b63af..d25cfee 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -297,3 +297,50 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `80a23c3` + +- 2026-03-02 - G8 (DONE) + - Branch: `hardening/g8-observability` + - What changed: + - Added shared observability primitives in `lib/observability.ts`: + - request correlation ID extraction (`x-correlation-id` / `x-request-id`) + - response correlation header injection (`X-Correlation-Id`) + - centralized structured error reporting (`app.error`) + - optional alert webhook dispatch (`ALERT_WEBHOOK_URL`, optional bearer token + timeout) + - Wired Twilio webhook routes to include correlation IDs in key logs and responses: + - `app/api/twilio/voice/route.ts` + - `app/api/twilio/status/route.ts` + - `app/api/twilio/sms/route.ts` + - Wired Stripe webhook route for centralized error reporting + correlation header: + - `app/api/stripe/webhook/route.ts` + - Upgraded `lib/twilio-logging.ts` error path to feed centralized reporting (`app.error`) while preserving existing `twilio.*` logs. + - Added observability unit tests in `tests/observability.test.ts`. + - Documented alert/correlation operations in: + - `.env.example` + - `docs/PRODUCTION_ENV.md` + - `RUNBOOK.md` + - `README.md` + - Ops notes: + - Correlation IDs can now be traced from provider delivery attempts to Vercel logs. + - Alerts are wired as an optional webhook sink and do not block request handling. + - Commands run + results: + - `npm test` -> PASS (30/30) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `lib/observability.ts` + - `lib/twilio-logging.ts` + - `app/api/twilio/voice/route.ts` + - `app/api/twilio/status/route.ts` + - `app/api/twilio/sms/route.ts` + - `app/api/stripe/webhook/route.ts` + - `tests/observability.test.ts` + - `.env.example` + - `docs/PRODUCTION_ENV.md` + - `RUNBOOK.md` + - `README.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` diff --git a/lib/observability.ts b/lib/observability.ts new file mode 100644 index 0000000..e60b6f1 --- /dev/null +++ b/lib/observability.ts @@ -0,0 +1,98 @@ +type ErrorReportPayload = { + source: string; + event: string; + correlationId: string; + error: string; + metadata: Record; + timestamp: string; +}; + +type ReportErrorInput = { + source: string; + event: string; + correlationId: string; + error?: unknown; + metadata?: Record; + alert?: boolean; +}; + +function toErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +function sanitizeCorrelationId(value: string | null | undefined) { + const trimmed = value?.trim(); + if (!trimmed) return null; + if (trimmed.length > 128) return trimmed.slice(0, 128); + return trimmed; +} + +function generateCorrelationId() { + return `req_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; +} + +function buildAlertPayload(payload: ErrorReportPayload) { + return { + text: `[CallbackCloser] ${payload.source}.${payload.event} (${payload.correlationId})`, + ...payload, + }; +} + +async function dispatchAlert(payload: ErrorReportPayload) { + const webhookUrl = process.env.ALERT_WEBHOOK_URL?.trim(); + if (!webhookUrl) return; + + const timeoutMsRaw = process.env.ALERT_WEBHOOK_TIMEOUT_MS?.trim(); + const timeoutMs = timeoutMsRaw ? Number.parseInt(timeoutMsRaw, 10) : 4_000; + const token = process.env.ALERT_WEBHOOK_TOKEN?.trim(); + + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(buildAlertPayload(payload)), + signal: AbortSignal.timeout(Number.isFinite(timeoutMs) ? Math.max(timeoutMs, 1_000) : 4_000), + }); + } catch (error) { + console.error('app.alert_dispatch_failed', { + source: payload.source, + event: payload.event, + correlationId: payload.correlationId, + error: toErrorMessage(error), + }); + } +} + +export function getCorrelationIdFromRequest(request: Pick) { + return ( + sanitizeCorrelationId(request.headers.get('x-correlation-id')) || + sanitizeCorrelationId(request.headers.get('x-request-id')) || + generateCorrelationId() + ); +} + +export function withCorrelationIdHeader(response: T, correlationId: string) { + response.headers.set('X-Correlation-Id', correlationId); + return response; +} + +export function reportApplicationError(input: ReportErrorInput) { + const errorMessage = input.error === undefined ? 'unknown_error' : toErrorMessage(input.error); + const payload: ErrorReportPayload = { + source: input.source, + event: input.event, + correlationId: input.correlationId, + error: errorMessage, + metadata: input.metadata ?? {}, + timestamp: new Date().toISOString(), + }; + + console.error('app.error', payload); + + if (input.alert === false) return; + void dispatchAlert(payload); +} diff --git a/lib/twilio-logging.ts b/lib/twilio-logging.ts index 4ce5f79..336ab25 100644 --- a/lib/twilio-logging.ts +++ b/lib/twilio-logging.ts @@ -1,3 +1,5 @@ +import { reportApplicationError } from './observability.ts'; + type TwilioLogRoute = 'voice' | 'sms' | 'status' | 'messaging' | 'webhook-auth'; type TwilioLogLevel = 'info' | 'warn' | 'error'; @@ -24,5 +26,14 @@ export function logTwilioWarn(route: TwilioLogRoute, event: string, fields: Twil } export function logTwilioError(route: TwilioLogRoute, event: string, fields: TwilioLogFields = {}, error?: unknown) { - write('error', route, event, error ? { ...fields, error: errorMessage(error) } : fields); + const payload = error ? { ...fields, error: errorMessage(error) } : fields; + const observedError = error ?? (typeof payload.error === 'string' ? payload.error : undefined); + write('error', route, event, payload); + reportApplicationError({ + source: `twilio.${route}`, + event, + correlationId: typeof fields.correlationId === 'string' ? fields.correlationId : 'n/a', + error: observedError, + metadata: payload, + }); } diff --git a/tests/observability.test.ts b/tests/observability.test.ts new file mode 100644 index 0000000..41d7c9e --- /dev/null +++ b/tests/observability.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '../lib/observability.ts'; + +test('getCorrelationIdFromRequest prefers explicit x-correlation-id header', () => { + const request = new Request('https://example.com', { + headers: { + 'x-correlation-id': 'corr-explicit-123', + 'x-request-id': 'req-fallback-456', + }, + }); + + assert.equal(getCorrelationIdFromRequest(request), 'corr-explicit-123'); +}); + +test('getCorrelationIdFromRequest falls back to x-request-id when needed', () => { + const request = new Request('https://example.com', { + headers: { + 'x-request-id': 'req-fallback-456', + }, + }); + + assert.equal(getCorrelationIdFromRequest(request), 'req-fallback-456'); +}); + +test('withCorrelationIdHeader sets response header', () => { + const response = withCorrelationIdHeader(new Response('ok'), 'corr-abc'); + assert.equal(response.headers.get('x-correlation-id'), 'corr-abc'); +}); + +test('reportApplicationError is safe when alerts are disabled', () => { + reportApplicationError({ + source: 'test.route', + event: 'synthetic_error', + correlationId: 'corr-test', + error: new Error('boom'), + alert: false, + }); + + assert.equal(true, true); +}); From 31fc23b6963e037b6c8606970f20e1e171cc5675 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:12:08 -0500 Subject: [PATCH 08/14] docs: record G8 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index d25cfee..f762ae9 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -343,4 +343,4 @@ Dependencies: G4 (recommended) - `README.md` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `119c217` From 2dc1d7c875be09bcd132f6d877ff04ac49858971 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:13:45 -0500 Subject: [PATCH 09/14] hardening: G9 add backup restore runbook --- RUNBOOK.md | 8 +++ docs/BACKUP_RESTORE_RUNBOOK.md | 107 ++++++++++++++++++++++++++++++ docs/DB_NEON_PRISMA.md | 5 ++ docs/PRODUCTION_READINESS_GAPS.md | 33 +++++++++ 4 files changed, 153 insertions(+) create mode 100644 docs/BACKUP_RESTORE_RUNBOOK.md diff --git a/RUNBOOK.md b/RUNBOOK.md index 725d0f8..4e36d63 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -19,6 +19,14 @@ 7. Verify Stripe webhook endpoint still points to the correct production URL. 8. Run a live Twilio smoke test (call + missed call + SMS reply + STOP/START). +## Backup + Restore + +- Canonical procedure: `docs/BACKUP_RESTORE_RUNBOOK.md` +- Minimum policy: + - Neon PITR enabled for production. + - Logical backup artifacts retained for 30+ days. + - Restore drill executed monthly with recorded evidence. + ## Rotate `TWILIO_WEBHOOK_AUTH_TOKEN` (shared webhook token) 1. Generate a new random token (do not reuse old values). diff --git a/docs/BACKUP_RESTORE_RUNBOOK.md b/docs/BACKUP_RESTORE_RUNBOOK.md new file mode 100644 index 0000000..fdf79d4 --- /dev/null +++ b/docs/BACKUP_RESTORE_RUNBOOK.md @@ -0,0 +1,107 @@ +# Backup + Restore Runbook (Neon + Prisma) + +Date: 2026-03-02 +Owner: Ops / Engineering + +## Objectives + +- Keep production customer data recoverable from accidental deletion, schema mistakes, and provider incidents. +- Define explicit recovery targets: + - **RPO**: <= 15 minutes (via Neon point-in-time recovery) + - **RTO**: <= 60 minutes for partial incident, <= 120 minutes for full environment recovery +- Run and record a restore drill at least **monthly**. + +## Backup Policy + +1. Primary protection: Neon managed backups / point-in-time recovery enabled on production project. +2. Secondary protection: periodic logical exports for independent restoreability checks. +3. Retention targets: + - Neon PITR window: keep provider default or higher, never below 7 days. + - Logical backup artifacts: retain at least 30 days in secure storage. + +## Required Environment + +- `DATABASE_URL` (pooled runtime) +- `DIRECT_DATABASE_URL` (direct connection for Prisma + admin tooling) +- PostgreSQL CLI tools installed locally/CI (`pg_dump`, `psql`, `pg_restore` if custom format is used) + +## Logical Backup Procedure (Non-Destructive) + +Use a direct Postgres connection for dump operations. + +```bash +export BACKUP_TS=$(date -u +%Y%m%dT%H%M%SZ) +export BACKUP_FILE="outputs/backups/callbackcloser-${BACKUP_TS}.sql.gz" +mkdir -p outputs/backups + +pg_dump "$DIRECT_DATABASE_URL" \ + --no-owner \ + --no-privileges \ + --format=plain \ + | gzip > "$BACKUP_FILE" + +gzip -t "$BACKUP_FILE" +ls -lh "$BACKUP_FILE" +``` + +## Restore Drill Procedure (Monthly) + +Run against a non-production restore target only. + +1. Provision an empty restore target database (`RESTORE_DATABASE_URL`). +2. Restore the latest backup artifact. + +```bash +gunzip -c "$BACKUP_FILE" | psql "$RESTORE_DATABASE_URL" +``` + +3. Run Prisma and app-level sanity checks against the restored DB: + +```bash +DIRECT_DATABASE_URL="$RESTORE_DATABASE_URL" npx prisma validate +DATABASE_URL="$RESTORE_DATABASE_URL" npm run db:smoke +``` + +4. Validate key tables and counts manually: + +```bash +psql "$RESTORE_DATABASE_URL" -c 'select count(*) as businesses from "Business";' +psql "$RESTORE_DATABASE_URL" -c 'select count(*) as leads from "Lead";' +psql "$RESTORE_DATABASE_URL" -c 'select count(*) as messages from "Message";' +psql "$RESTORE_DATABASE_URL" -c 'select count(*) as calls from "Call";' +``` + +5. Record outcome in drill log (template below). + +## Incident Restore Procedure (Production Event) + +1. Declare incident and freeze deploys/write traffic. +2. Pick restore point timestamp (UTC) based on incident timeline. +3. Restore using Neon PITR/branch restore into a clean recovery target. +4. Run Prisma validation + app smoke checks on recovery target. +5. Cut over app env vars (`DATABASE_URL`, `DIRECT_DATABASE_URL`) to recovered target. +6. Run post-cutover smoke: + - `npm run env:check` + - `npm run db:smoke` + - Twilio inbound/outbound smoke + - Stripe webhook smoke +7. Announce recovery and keep incident watch for at least 1 hour. + +## Alerts + Evidence + +- Track backup job success/failure in CI logs or scheduler logs. +- Alert on: + - failed backup run + - restore drill failure + - missing drill evidence older than 35 days +- Store drill artifacts: + - command transcript (or CI job URL) + - DB count snapshots + - elapsed restore time + - operator + reviewer sign-off + +## Drill Log Template + +| Date (UTC) | Operator | Backup Artifact | Restore Target | Result | Restore Duration | Notes / Follow-ups | +|---|---|---|---|---|---|---| +| YYYY-MM-DD | name | path or object key | env/db name | PASS/FAIL | Xm Ys | links to logs + remediation ticket | diff --git a/docs/DB_NEON_PRISMA.md b/docs/DB_NEON_PRISMA.md index 6810fcd..3aeabf4 100644 --- a/docs/DB_NEON_PRISMA.md +++ b/docs/DB_NEON_PRISMA.md @@ -73,3 +73,8 @@ Recommended: Using the pooled `-pooler` URL for Prisma migrations can cause migration problems or connection behavior issues. Keep migrations on `DIRECT_DATABASE_URL` (direct endpoint) and runtime on `DATABASE_URL` (pooled endpoint). +## Backup / Restore Operations + +For production backup cadence, restore drills, and incident recovery workflow, use: + +- `docs/BACKUP_RESTORE_RUNBOOK.md` diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index f762ae9..74e38e3 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -344,3 +344,36 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `119c217` + +- 2026-03-02 - G9 (DONE) + - Branch: `hardening/g9-backup-restore-runbook` + - What changed: + - Added dedicated production backup/restore runbook: + - `docs/BACKUP_RESTORE_RUNBOOK.md` + - defines RPO/RTO targets, backup cadence, retention expectations, and incident restore sequence. + - Added explicit monthly restore drill procedure with command-level verification: + - logical backup command (`pg_dump` + gzip) + - restore command (`psql` replay into restore target) + - Prisma + app smoke checks (`prisma validate`, `npm run db:smoke`) + - key table-count verification commands for `Business`, `Lead`, `Message`, `Call`. + - Added drill evidence template (date/operator/artifact/result/duration/follow-up) to enforce auditable restore history. + - Linked backup/restore operations from existing ops docs: + - `RUNBOOK.md` + - `docs/DB_NEON_PRISMA.md` + - Ops notes: + - This closes the documentation + drill-checklist gap for data recovery readiness. + - Actual production drill execution remains an operational action and should be recorded using the included template. + - Commands run + results: + - `npm test` -> PASS (30/30) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `docs/BACKUP_RESTORE_RUNBOOK.md` + - `RUNBOOK.md` + - `docs/DB_NEON_PRISMA.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` From cbfeb926492b22e6435fbcce5a34d90f41002c49 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:13:53 -0500 Subject: [PATCH 10/14] docs: record G9 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 74e38e3..6a5e6d8 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -376,4 +376,4 @@ Dependencies: G4 (recommended) - `docs/DB_NEON_PRISMA.md` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `2dc1d7c` From fd9ca8ede93f55918b21d73bb016427ff3e6695b Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:16:44 -0500 Subject: [PATCH 11/14] hardening: G11 enforce production demo-mode guardrail --- .env.example | 2 ++ README.md | 1 + docs/PRODUCTION_ENV.md | 4 +++ docs/PRODUCTION_READINESS_GAPS.md | 42 ++++++++++++++++++++++++++ lib/env.server.ts | 2 ++ lib/portfolio-demo-guardrail.ts | 34 +++++++++++++++++++++ middleware.ts | 28 ++++++++++++++++- scripts/check_env.ts | 9 ++++++ tests/portfolio-demo-guardrail.test.ts | 39 ++++++++++++++++++++++++ 9 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 lib/portfolio-demo-guardrail.ts create mode 100644 tests/portfolio-demo-guardrail.test.ts diff --git a/.env.example b/.env.example index 84dd23c..dce535a 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ TWILIO_VALIDATE_SIGNATURE= # Optional debug / demo DEBUG_ENV_ENDPOINT_TOKEN= PORTFOLIO_DEMO_MODE= +# Break-glass only: allows demo mode in production when explicitly set +ALLOW_PRODUCTION_DEMO_MODE= # Optional rate limiting (defaults are safe for provider webhooks) # RATE_LIMIT_WINDOW_MS=60000 diff --git a/README.md b/README.md index e1dfedd..fd9beeb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ When a customer calls a business's Twilio number and the forwarded call is misse - Call recording enabled on forwarded calls + recording metadata captured on callbacks - Twilio webhook protection: production-enforced `X-Twilio-Signature` validation, with shared-token fallback only in non-production - Webhook observability baseline: correlation IDs (`X-Correlation-Id`), centralized `app.error` reporting, optional alert webhook dispatch +- Production guardrail: `PORTFOLIO_DEMO_MODE` is blocked in production unless `ALLOW_PRODUCTION_DEMO_MODE=true` is explicitly set ## Local Setup diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index a5fc7bd..fe8730c 100644 --- a/docs/PRODUCTION_ENV.md +++ b/docs/PRODUCTION_ENV.md @@ -30,6 +30,7 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s | `TWILIO_VALIDATE_SIGNATURE` | Server-only | Yes (production) | Vercel | Must be `true` in production. Twilio webhooks require valid `X-Twilio-Signature` verification using `TWILIO_AUTH_TOKEN`; production fails closed otherwise. | | `DEBUG_ENV_ENDPOINT_TOKEN` | Server-only | Optional | Vercel | Protects `/api/debug/env` in production. If unset, the endpoint returns `404` in production. | | `PORTFOLIO_DEMO_MODE` | Server-only | Optional | Local / Vercel | Enables demo data/auth bypass mode for portfolio/demo screenshots. Keep disabled in production unless intentionally using demo mode. | +| `ALLOW_PRODUCTION_DEMO_MODE` | Server-only | Optional (break-glass only) | Vercel | Required only when intentionally running demo mode in production. If unset while `PORTFOLIO_DEMO_MODE` is enabled in production, startup is blocked. | | `RATE_LIMIT_WINDOW_MS` | Server-only | Optional | Vercel | Shared rate-limit window in milliseconds. Default `60000`. | | `RATE_LIMIT_TWILIO_AUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for valid/authorized traffic. Default `240`. | | `RATE_LIMIT_TWILIO_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for unauthorized traffic. Default `40`. | @@ -53,6 +54,9 @@ The app now validates required server env vars at runtime in production via `lib - Twilio webhook auth behavior: - Production: `TWILIO_VALIDATE_SIGNATURE=true` is required and token-only auth is rejected - Non-production: signature mode can fall back to shared-token auth for local/dev workflows +- Demo mode safety guard: + - Production blocks startup/request handling if `PORTFOLIO_DEMO_MODE` is enabled without `ALLOW_PRODUCTION_DEMO_MODE=true`. + - Use `ALLOW_PRODUCTION_DEMO_MODE` only as an explicit break-glass override. - Rate limiting defaults are tuned to avoid blocking normal Twilio/Stripe provider traffic while still throttling abusive bursts. Tune limits only if you observe false positives in logs. - Error reporting emits structured `app.error` logs and, when configured, dispatches alert payloads to `ALERT_WEBHOOK_URL`. - `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`. diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 6a5e6d8..5a1e9d4 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -377,3 +377,45 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `2dc1d7c` + +- 2026-03-02 - G11 (DONE) + - Branch: `hardening/g11-production-demo-guardrail` + - What changed: + - Added shared production demo-mode guardrail logic in `lib/portfolio-demo-guardrail.ts`: + - detects production runtime (`NODE_ENV` / `VERCEL_ENV`) + - blocks `PORTFOLIO_DEMO_MODE` unless explicit override `ALLOW_PRODUCTION_DEMO_MODE=true` + - Enforced guardrail in production env validation: + - `lib/env.server.ts` now calls guardrail enforcement during `validateServerEnv()` + - Enforced guardrail at request layer: + - `middleware.ts` now returns `503` fail-safe when production demo mode is enabled without override + - break-glass override logs an explicit warning when enabled + - Extended env preflight checks: + - `scripts/check_env.ts` now fails when production demo mode is enabled without override + - Added focused guardrail tests: + - `tests/portfolio-demo-guardrail.test.ts` + - Updated env/docs to reflect break-glass requirement: + - `.env.example` + - `docs/PRODUCTION_ENV.md` + - `README.md` + - Safety notes: + - Production cannot silently run in portfolio-demo bypass mode anymore. + - Any intentional production demo-mode usage now requires explicit, auditable break-glass env activation. + - Commands run + results: + - `npm test` -> PASS (33/33) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `lib/portfolio-demo-guardrail.ts` + - `lib/env.server.ts` + - `middleware.ts` + - `scripts/check_env.ts` + - `tests/portfolio-demo-guardrail.test.ts` + - `.env.example` + - `docs/PRODUCTION_ENV.md` + - `README.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` diff --git a/lib/env.server.ts b/lib/env.server.ts index d48686c..1db59a5 100644 --- a/lib/env.server.ts +++ b/lib/env.server.ts @@ -1,6 +1,7 @@ import 'server-only'; import { buildNextPublicAppUrlErrorMessage, resolveConfiguredAppBaseUrl } from './app-url'; +import { enforcePortfolioDemoGuardrail } from './portfolio-demo-guardrail'; type EnvSpec = { name: string; @@ -124,6 +125,7 @@ export function validateServerEnv() { } validateTwilioWebhookSecurityMode(); + enforcePortfolioDemoGuardrail(process.env); validateAppUrl(); validateDatabaseUrl(); validated = true; diff --git a/lib/portfolio-demo-guardrail.ts b/lib/portfolio-demo-guardrail.ts new file mode 100644 index 0000000..c9589fa --- /dev/null +++ b/lib/portfolio-demo-guardrail.ts @@ -0,0 +1,34 @@ +function parseBooleanFlag(value: string | undefined) { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +export function isProductionRuntime(env: Record = process.env) { + return env.NODE_ENV === 'production' || env.VERCEL_ENV === 'production'; +} + +export function isPortfolioDemoModeEnabled(env: Record = process.env) { + return parseBooleanFlag(env.PORTFOLIO_DEMO_MODE); +} + +export function isProductionDemoModeOverrideEnabled(env: Record = process.env) { + return parseBooleanFlag(env.ALLOW_PRODUCTION_DEMO_MODE); +} + +export function isPortfolioDemoModeBlockedInProduction(env: Record = process.env) { + return isProductionRuntime(env) && isPortfolioDemoModeEnabled(env) && !isProductionDemoModeOverrideEnabled(env); +} + +export function getPortfolioDemoGuardrailErrorMessage() { + return ( + 'Invalid environment configuration: PORTFOLIO_DEMO_MODE is enabled in production without ALLOW_PRODUCTION_DEMO_MODE=true. ' + + 'Disable demo mode for production or explicitly set ALLOW_PRODUCTION_DEMO_MODE=true for break-glass use.' + ); +} + +export function enforcePortfolioDemoGuardrail(env: Record = process.env) { + if (isPortfolioDemoModeBlockedInProduction(env)) { + throw new Error(getPortfolioDemoGuardrailErrorMessage()); + } +} diff --git a/middleware.ts b/middleware.ts index 4ffa092..fba37b4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,14 +1,40 @@ import { NextResponse } from 'next/server'; import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +import { + getPortfolioDemoGuardrailErrorMessage, + isPortfolioDemoModeBlockedInProduction, + isPortfolioDemoModeEnabled, + isProductionDemoModeOverrideEnabled, +} from '@/lib/portfolio-demo-guardrail'; import { RATE_LIMIT_PROTECTED_API_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; const isProtectedRoute = createRouteMatcher(['/app(.*)', '/api/stripe/checkout(.*)', '/api/stripe/portal(.*)']); const isProtectedApiMutationRoute = createRouteMatcher(['/api/stripe/checkout', '/api/stripe/portal']); +let productionDemoGuardrailLogged = false; +let productionDemoOverrideLogged = false; export default clerkMiddleware(async (auth, req) => { - if (process.env.PORTFOLIO_DEMO_MODE === '1') { + if (isPortfolioDemoModeBlockedInProduction(process.env)) { + if (!productionDemoGuardrailLogged) { + productionDemoGuardrailLogged = true; + console.error(getPortfolioDemoGuardrailErrorMessage(), { + nodeEnv: process.env.NODE_ENV ?? null, + vercelEnv: process.env.VERCEL_ENV ?? null, + }); + } + return NextResponse.json({ error: getPortfolioDemoGuardrailErrorMessage() }, { status: 503 }); + } + + if (isPortfolioDemoModeEnabled(process.env)) { + if (isProductionDemoModeOverrideEnabled(process.env) && !productionDemoOverrideLogged) { + productionDemoOverrideLogged = true; + console.warn('Production demo mode override is enabled (break-glass).', { + nodeEnv: process.env.NODE_ENV ?? null, + vercelEnv: process.env.VERCEL_ENV ?? null, + }); + } return; } diff --git a/scripts/check_env.ts b/scripts/check_env.ts index 17b50b6..05d2a7f 100644 --- a/scripts/check_env.ts +++ b/scripts/check_env.ts @@ -12,6 +12,8 @@ const loadedFiles = loadLocalEnvFiles(); const signatureValidationEnabled = readBooleanEnv('TWILIO_VALIDATE_SIGNATURE'); const productionNodeEnv = process.env.NODE_ENV === 'production'; +const demoModeEnabled = readBooleanEnv('PORTFOLIO_DEMO_MODE'); +const demoModeOverrideEnabled = readBooleanEnv('ALLOW_PRODUCTION_DEMO_MODE'); const requirements: EnvRequirement[] = [ { name: 'NEXT_PUBLIC_APP_URL', required: true, reason: 'Canonical app URL / webhook URL generation' }, @@ -39,6 +41,7 @@ const requirements: EnvRequirement[] = [ }, { name: 'DEBUG_ENV_ENDPOINT_TOKEN', required: false, reason: 'Optional debug endpoint token' }, { name: 'PORTFOLIO_DEMO_MODE', required: false, reason: 'Optional demo mode' }, + { name: 'ALLOW_PRODUCTION_DEMO_MODE', required: false, reason: 'Optional break-glass override for demo mode in production' }, ]; const missing = requirements.filter((item) => item.required && !process.env[item.name]?.trim()); @@ -48,9 +51,15 @@ if (productionNodeEnv && !signatureValidationEnabled) { configErrors.push('TWILIO_VALIDATE_SIGNATURE must be true when NODE_ENV=production'); } +if (productionNodeEnv && demoModeEnabled && !demoModeOverrideEnabled) { + configErrors.push('PORTFOLIO_DEMO_MODE cannot be enabled in production without ALLOW_PRODUCTION_DEMO_MODE=true'); +} + console.log('CallbackCloser env check'); console.log(`- Loaded env files: ${loadedFiles.join(', ') || '(none)'}`); console.log(`- TWILIO_VALIDATE_SIGNATURE: ${signatureValidationEnabled ? 'enabled' : 'disabled'}`); +console.log(`- PORTFOLIO_DEMO_MODE: ${demoModeEnabled ? 'enabled' : 'disabled'}`); +console.log(`- ALLOW_PRODUCTION_DEMO_MODE: ${demoModeOverrideEnabled ? 'enabled' : 'disabled'}`); if (missing.length === 0 && configErrors.length === 0) { console.log('- Result: PASS (all required env vars are present)'); diff --git a/tests/portfolio-demo-guardrail.test.ts b/tests/portfolio-demo-guardrail.test.ts new file mode 100644 index 0000000..4c7b840 --- /dev/null +++ b/tests/portfolio-demo-guardrail.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + enforcePortfolioDemoGuardrail, + isPortfolioDemoModeBlockedInProduction, +} from '../lib/portfolio-demo-guardrail.ts'; + +test('blocks production when demo mode is enabled without override', () => { + const env = { + NODE_ENV: 'production', + PORTFOLIO_DEMO_MODE: '1', + ALLOW_PRODUCTION_DEMO_MODE: '', + }; + + assert.equal(isPortfolioDemoModeBlockedInProduction(env), true); + assert.throws(() => enforcePortfolioDemoGuardrail(env)); +}); + +test('allows production demo mode only with explicit override', () => { + const env = { + NODE_ENV: 'production', + PORTFOLIO_DEMO_MODE: 'true', + ALLOW_PRODUCTION_DEMO_MODE: 'true', + }; + + assert.equal(isPortfolioDemoModeBlockedInProduction(env), false); + assert.doesNotThrow(() => enforcePortfolioDemoGuardrail(env)); +}); + +test('does not block demo mode in non-production', () => { + const env = { + NODE_ENV: 'development', + PORTFOLIO_DEMO_MODE: '1', + ALLOW_PRODUCTION_DEMO_MODE: '', + }; + + assert.equal(isPortfolioDemoModeBlockedInProduction(env), false); +}); From b354d39b2250f61aa788fa85f7b742577e490aaa Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:16:55 -0500 Subject: [PATCH 12/14] docs: record G11 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 5a1e9d4..c78674a 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -418,4 +418,4 @@ Dependencies: G4 (recommended) - `README.md` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `fd9ca8e` From 91846f3fe2d040daee3bcce1555b56158c9e0f92 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:19:31 -0500 Subject: [PATCH 13/14] hardening: G12 add public legal pages --- README.md | 3 ++ app/page.tsx | 80 ++++++++++++++++++------------- app/privacy/page.tsx | 51 ++++++++++++++++++++ app/refund/page.tsx | 51 ++++++++++++++++++++ app/terms/page.tsx | 51 ++++++++++++++++++++ docs/PRODUCTION_READINESS_GAPS.md | 34 +++++++++++++ tests/legal-pages.test.ts | 18 +++++++ 7 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 app/privacy/page.tsx create mode 100644 app/refund/page.tsx create mode 100644 app/terms/page.tsx create mode 100644 tests/legal-pages.test.ts diff --git a/README.md b/README.md index fd9beeb..94fb231 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,9 @@ Prisma models included: ## Useful Routes - `/` - landing page +- `/terms` - terms of service +- `/privacy` - privacy policy +- `/refund` - refund policy - `/sign-in` - Clerk sign-in - `/sign-up` - Clerk sign-up - `/app/onboarding` - create business record diff --git a/app/page.tsx b/app/page.tsx index 85e34f1..bd4a1d7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,39 +6,53 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com export default function LandingPage() { return (
-
-
-

- CallbackCloser -

-

- Missed Call to Booked Job with automated SMS follow-up. -

-

- When a customer calls and nobody answers, CallbackCloser texts them instantly, captures the job details, and alerts the owner with a lead summary. -

-
- - - - - - -
-
- - - How it works - Built for home service businesses using Twilio, Stripe, Clerk, and Prisma. - - -
1. Incoming call hits your Twilio number and forwards to your business line.
-
2. If unanswered, a lead is created and SMS qualification starts automatically.
-
3. Owner gets a summary text and can track leads inside the dashboard.
-
-
+
+
+
+

+ CallbackCloser +

+

+ Missed Call to Booked Job with automated SMS follow-up. +

+

+ When a customer calls and nobody answers, CallbackCloser texts them instantly, captures the job details, and alerts the owner with a lead summary. +

+
+ + + + + + +
+
+ + + How it works + Built for home service businesses using Twilio, Stripe, Clerk, and Prisma. + + +
1. Incoming call hits your Twilio number and forwards to your business line.
+
2. If unanswered, a lead is created and SMS qualification starts automatically.
+
3. Owner gets a summary text and can track leads inside the dashboard.
+
+
+
+
+ Legal: + + Terms + + + Privacy + + + Refund + +
); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..8e20b25 --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; + +const EFFECTIVE_DATE = 'March 2, 2026'; + +export default function PrivacyPage() { + return ( +
+
+
+

Privacy Policy

+

Effective date: {EFFECTIVE_DATE}

+
+ +
+

Information We Collect

+

+ We collect account details, call/message metadata, lead qualification responses, and billing-related identifiers needed to operate CallbackCloser. +

+
+ +
+

How We Use Data

+

+ Data is used to deliver automation workflows, surface leads in the dashboard, maintain service reliability, and support account operations. +

+
+ +
+

Data Sharing

+

+ CallbackCloser uses service providers (for example Twilio, Stripe, Clerk, and Neon) solely to provide the platform. We do not sell your data. +

+
+ +
+

Data Requests

+

+ For access, correction, or deletion requests, contact support and include your business name and account email. +

+
+ + +
+
+ ); +} diff --git a/app/refund/page.tsx b/app/refund/page.tsx new file mode 100644 index 0000000..2ba796e --- /dev/null +++ b/app/refund/page.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; + +const EFFECTIVE_DATE = 'March 2, 2026'; + +export default function RefundPolicyPage() { + return ( +
+
+
+

Refund Policy

+

Effective date: {EFFECTIVE_DATE}

+
+ +
+

Subscription Charges

+

+ CallbackCloser is billed as a recurring subscription through Stripe. Charges apply according to your selected plan and billing cycle. +

+
+ +
+

Cancellation Timing

+

+ You may cancel at any time. Cancellation stops future renewals and access continues through the current paid period unless otherwise stated. +

+
+ +
+

Refund Requests

+

+ Refunds are reviewed case-by-case for duplicate billing, platform defects, or accidental charges. Approved refunds are issued to the original payment method. +

+
+ +
+

How to Request

+

+ Email support with your account email, business name, charge date, and the reason for your request. +

+
+ + +
+
+ ); +} diff --git a/app/terms/page.tsx b/app/terms/page.tsx new file mode 100644 index 0000000..e2202dc --- /dev/null +++ b/app/terms/page.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; + +const EFFECTIVE_DATE = 'March 2, 2026'; + +export default function TermsPage() { + return ( +
+
+
+

Terms of Service

+

Effective date: {EFFECTIVE_DATE}

+
+ +
+

Service Scope

+

+ CallbackCloser provides automation tools for missed-call follow-up workflows, including SMS messaging and lead tracking. +

+
+ +
+

Acceptable Use

+

+ You are responsible for lawful use of the platform, including consent, opt-out compliance, and messaging rules required by your jurisdiction. +

+
+ +
+

Billing

+

+ Subscription charges are processed through Stripe. Plan changes and cancellations are handled through the billing portal. +

+
+ +
+

Limitation of Liability

+

+ The service is provided as-is. CallbackCloser is not liable for indirect or consequential damages arising from use of the platform. +

+
+ + +
+
+ ); +} diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index c78674a..52dacd6 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -419,3 +419,37 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `fd9ca8e` + +- 2026-03-02 - G12 (DONE) + - Branch: `hardening/g12-legal-pages` + - What changed: + - Added public legal pages: + - `app/terms/page.tsx` + - `app/privacy/page.tsx` + - `app/refund/page.tsx` + - Added legal links to the public landing page footer: + - `app/page.tsx` + - Added lightweight route/content coverage test: + - `tests/legal-pages.test.ts` + - Updated route docs: + - `README.md` + - Verification notes: + - Build output confirms static generation for `/terms`, `/privacy`, and `/refund`. + - Landing page now exposes direct legal navigation links for compliance visibility. + - Commands run + results: + - `npm test` -> PASS (34/34) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `app/terms/page.tsx` + - `app/privacy/page.tsx` + - `app/refund/page.tsx` + - `app/page.tsx` + - `tests/legal-pages.test.ts` + - `README.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `PENDING` diff --git a/tests/legal-pages.test.ts b/tests/legal-pages.test.ts new file mode 100644 index 0000000..ef57819 --- /dev/null +++ b/tests/legal-pages.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +function read(relativePath: string) { + return readFileSync(path.join(process.cwd(), relativePath), 'utf8'); +} + +test('legal public pages exist with required headings', () => { + const terms = read('app/terms/page.tsx'); + const privacy = read('app/privacy/page.tsx'); + const refund = read('app/refund/page.tsx'); + + assert.match(terms, /Terms of Service/); + assert.match(privacy, /Privacy Policy/); + assert.match(refund, /Refund Policy/); +}); From 7ce6880856be940a8439fc74a1248d052b38305b Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 21:19:42 -0500 Subject: [PATCH 14/14] docs: record G12 commit sha in gap changelog --- docs/PRODUCTION_READINESS_GAPS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 52dacd6..09e6e4d 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -452,4 +452,4 @@ Dependencies: G4 (recommended) - `README.md` - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - - `PENDING` + - `91846f3`