diff --git a/.env.example b/.env.example index e9da8875..65e09893 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,7 @@ ANTHROPIC_MODEL=claude-sonnet-4-6 OPENAI_API_KEY= # WhatsApp delivery (Meta WhatsApp Cloud API) +WHATSAPP_INBOUND_ENABLED=false WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_ACCESS_TOKEN= WHATSAPP_TO= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43bb9a61..d9fc30a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,12 @@ jobs: - name: Lead quality regression run: npx tsx tests/regression/lead-engine-quality-regression.mjs + - name: Revenue Rescue demo contract + run: npm run test:v2-demo + + - name: WhatsApp webhook security regression + run: npm run test:whatsapp-security + - name: Find a Tender OCDS regression run: npm run test:fts diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..7ed8542c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,23 @@ +# JobFilter design direction + +## Direction contract + +| Decision | Direction | +|---|---| +| Product mode | Operate for the signed-in product; persuade through concrete evidence on public demos | +| Audience and cadence | UK trade-business owners and office managers, often on a phone between jobs; repeated use must be fast and legible | +| Visual world | **Site-office control board** — direct, high-contrast and evidence-led rather than polished corporate SaaS | +| Palette family | Existing Brutalist-Yellow tokens: paper, black ink and restrained yellow; green only for verified success and orange for action/failure | +| Type treatment | Existing Barlow body, Barlow Condensed operational headings and JetBrains Mono for IDs, times and money evidence | +| Composition | Focused workbench: current event, one next action, then progressively disclosed evidence | +| Shape language | Crisp two-pixel borders, near-square corners and hard shadows already defined by `jf-box` and `jf-button` | +| Anti-references | Generic gradient SaaS dashboards, glass cards, decorative AI motifs, status-chip clutter, fictional live-data claims and dense desktop-only tables | + +## V2 product rules + +- Preserve existing design tokens and shared components; do not create a second visual system. +- Label simulated, test and live states explicitly. A demo must never imply that a message, payment or phone event occurred. +- Put the current state and one primary action in the first viewport. +- Keep provider diagnostics, raw event payloads and advanced settings out of the default operator view. +- Show evidence for money and delivery claims: timestamps, provider state, quote version and attribution source. +- At 320–430px, workflows become one column without horizontal scrolling or truncated action copy. diff --git a/app/api/whatsapp/webhook/route.ts b/app/api/whatsapp/webhook/route.ts index 6f591a7f..caa05ce8 100644 --- a/app/api/whatsapp/webhook/route.ts +++ b/app/api/whatsapp/webhook/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import crypto from 'crypto'; import { getSupabaseServiceClient } from '../../../../src/lib/supabase/server'; +import { isValidMetaSignature } from '../../../../src/lib/whatsappSignature'; export async function GET(request: NextRequest) { const url = new URL(request.url); @@ -15,15 +16,19 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + if (process.env.WHATSAPP_INBOUND_ENABLED !== 'true') { + return new NextResponse('WhatsApp inbound is disabled', { status: 503 }); + } + const raw = await request.text(); const appSecret = process.env.WHATSAPP_APP_SECRET; - if (appSecret) { - const signature = request.headers.get('x-hub-signature-256'); - const expected = `sha256=${crypto.createHmac('sha256', appSecret).update(raw).digest('hex')}`; - if (!signature || signature !== expected) { - return new NextResponse('Invalid signature', { status: 403 }); - } + if (!appSecret) { + return new NextResponse('WhatsApp signature verification is not configured', { status: 503 }); + } + + if (!isValidMetaSignature(raw, request.headers.get('x-hub-signature-256'), appSecret)) { + return new NextResponse('Invalid signature', { status: 403 }); } const body = (() => { try { return JSON.parse(raw); } catch { return null; } })(); @@ -36,8 +41,6 @@ export async function POST(request: NextRequest) { // Ignore status updates if (!text) return NextResponse.json({ ok: true }); - console.log(`[whatsapp/webhook] incoming from ${fromPhone}: ${text}`); - // Simple Rule-Based Receptionist const postcodeRegex = /[A-Z]{1,2}[0-9][0-9A-Z]?\s?[0-9][A-Z]{2}/i; const hasPostcode = postcodeRegex.test(text); @@ -68,7 +71,7 @@ export async function POST(request: NextRequest) { replyText = "Hi! I'm the automated receptionist. To help us get you a quote quickly, please reply with:\n1. The type of work you need\n2. Your postcode\n3. When you need it done"; } - await fetch(`https://graph.facebook.com/v21.0/${phoneId}/messages`, { + const response = await fetch(`https://graph.facebook.com/v21.0/${phoneId}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -80,8 +83,18 @@ export async function POST(request: NextRequest) { to: fromPhone, type: 'text', text: { preview_url: false, body: replyText } - }) - }).catch(console.error); + }), + signal: AbortSignal.timeout(8_000), + }).catch(() => null); + + if (!response?.ok) { + console.error('[whatsapp/webhook] outbound reply failed', { status: response?.status ?? 0 }); + // The intake row may already exist. A retriable response here would let Meta + // repeat the event and duplicate side effects until a durable inbox/outbox is live. + return NextResponse.json({ received: true, replyDelivered: false }); + } + } else { + return NextResponse.json({ ok: false, error: 'WhatsApp delivery is not configured' }, { status: 503 }); } } diff --git a/app/demo/revenue-rescue/page.tsx b/app/demo/revenue-rescue/page.tsx new file mode 100644 index 00000000..8a1afdc6 --- /dev/null +++ b/app/demo/revenue-rescue/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import { RevenueRescueDemoPage } from '../../../src/pages/RevenueRescueDemoPage'; + +export const metadata: Metadata = { + title: 'Revenue Rescue Demo | JobFilter', + description: 'A synthetic walkthrough of JobFilter enquiry qualification, quoting, follow-up and revenue attribution.', + robots: { + index: false, + follow: false, + }, +}; + +export default function Page() { + return ; +} diff --git a/docs/v2/phase-0-audit.md b/docs/v2/phase-0-audit.md new file mode 100644 index 00000000..13c42b96 --- /dev/null +++ b/docs/v2/phase-0-audit.md @@ -0,0 +1,61 @@ +# JobFilter V2 Phase 0 audit + +Date: 24 August 2026 + +Branch: `agents/jobfilter-v2-foundation` + +Base: `origin/main` at `5489192` + +## Access statement + +| System | Status | Evidence | +|---|---|---| +| GitHub repository, branches, PRs and Actions | VERIFIED | Authenticated `gh` access; repository and CI metadata inspected | +| Local repository | VERIFIED | Fresh isolated worktree; the dirty `agents/jobfilter-find-a-tender` tree was not modified | +| Vercel project and production deployment | VERIFIED, read-only | Authenticated CLI; `job-filter-v1` is linked to `jobfilter.uk` and production was Ready | +| Vercel production environment names | VERIFIED | Supabase, Stripe and Resend variables exist; Twilio, WhatsApp and Anthropic variables were absent | +| Vercel billing plan and remaining allowance | UNVERIFIABLE | Not exposed by the inspected project metadata | +| Supabase project existence and region | VERIFIED | `Jobfilter.uk`, Central EU (Frankfurt), project ref `nfjwuwsuaapufmkppoeo` | +| Applied remote schema, RLS behaviour and backups | UNVERIFIABLE | Project is not locally linked and no database credential was available to the audit process | +| Stripe products, live subscriptions, MRR and churn | UNVERIFIABLE | Environment variable names exist, but Stripe CLI/API credentials were not available to the audit process | +| Resend account state and delivery | UNVERIFIABLE | Production key name exists; account and live delivery were not accessed | +| WhatsApp Business account/templates | UNVERIFIABLE / NOT CONFIGURED IN VERCEL | Required production variables are absent | +| UK telephony provider, number and forwarding | NOT CONFIGURED | No Twilio variables or provider configuration found | +| External nightly PR generator | UNVERIFIABLE | Only the CI workflow exists in this repository; the automation producing `nightly/*` PRs is external | + +No production configuration, database, subscription, message or deployment was changed during this audit. + +## Verified baseline + +- `npm ci`: 181 packages installed, zero audit vulnerabilities. +- TypeScript check passed. +- Production source-safety, postcode, source-readiness, scanner-redaction, lead-quality, FTS and production-runtime regressions passed. +- Next.js production build passed and generated 120 static pages plus dynamic routes. +- The graph index found 39 route definitions across the Next and legacy Express surfaces. +- Production currently uses Next.js 16, React 19, Supabase, Stripe, Resend and Vercel Analytics. + +## Security and architecture findings + +| Severity | Verification | Finding | Required action | +|---|---|---|---| +| CRITICAL | VERIFIED in source; contained on this branch | WhatsApp webhook authentication was optional when `WHATSAPP_APP_SECRET` was missing. It also logged sender/message PII and treated a failed outbound fetch as success. | This branch makes inbound WhatsApp disabled by default, requires the secret, uses constant-time signature comparison, removes message-body logs and records delivery failure without requesting a retry after side effects. Durable inbound deduplication/outbox work is still required before enablement. | +| CRITICAL | UNVERIFIABLE remotely | The repository has user-scoped RLS migrations, but the applied remote schema and two-user isolation were not tested. | Link a non-production Supabase branch and run an adversarial two-user suite before any V2 tenant data is applied. | +| HIGH | VERIFIED in schema | The current model is user-owned, not organisation-owned. There are no organisation or membership tables for multi-user firms. | Design organisations/memberships now; apply physical tables only after the commercial gate. | +| HIGH | VERIFIED in source | Public intake uses service-role writes and stores phone, postcode and IP. Rate limiting depends on the database insert/count/delete path and fails open when Supabase is unavailable. | Replace with an atomic database function or durable rate-limit boundary before live acquisition traffic. | +| HIGH | VERIFIED in source | WhatsApp inbound rows are keyed only by phone and have no tenant identity, consent state, provider event ID or delivery state. | Do not reuse this table as the V2 conversation model. | +| MEDIUM | VERIFIED in source | Stripe webhook signing and event idempotency are implemented, but live subscriber/product state could not be checked. | Pull aggregate Stripe state through approved access before changing pricing or customer treatment. | +| MEDIUM | VERIFIED in CI | CI runs a hand-maintained list of regression scripts and has no general unit-test discovery or E2E suite. | Add each V2 contract test explicitly now; introduce a coherent test runner before multi-tenant mutations grow. | +| MEDIUM | VERIFIED in build | Both App Router handlers and a Pages catch-all Express API remain active. | Assign one owner to every API during route migration; do not create duplicate V2 endpoints. | +| LOW | VERIFIED in build | Next.js reports the `middleware` convention as deprecated in favour of `proxy`. | Migrate separately; it is not part of the first V2 sales-demo slice. | + +## Gate 0 verdict + +Gate 0 is **not fully passed**. The local build/security baseline is verified, but these items remain required before production schema or pricing changes: + +1. aggregate Stripe subscriber/product state; +2. applied Supabase schema and a real two-user RLS test; +3. explicit treatment of any existing paying tender customers; +4. identification and pausing of the external nightly PR generator; +5. Vercel and Supabase plan/backup confirmation. + +Safe work may continue on synthetic, no-send, no-payment sales demonstrations and documentation. Live messaging, production migrations, pricing replacement and customer-data writes remain gated. diff --git a/docs/v2/route-migration-matrix.md b/docs/v2/route-migration-matrix.md new file mode 100644 index 00000000..a107e88a --- /dev/null +++ b/docs/v2/route-migration-matrix.md @@ -0,0 +1,21 @@ +# V2 route migration matrix + +No route is deleted during the foundation slice. `KEEP` means the URL remains stable until analytics and customer state are verified. `REDIRECT` is a planned change that must ship with a tested permanent redirect. `RETIRE` routes are already intended to be unavailable in production. + +| Decision | Routes | Notes | +|---|---|---| +| KEEP | `/`, `/pricing`, `/trust`, `/login`, `/signup`, `/forgot-password`, `/reset-password`, `/auth/callback`, `/account`, `/activation-pending`, `/dashboard`, `/leads`, `/leads/[id]` | Core public/authenticated journeys; copy and ownership change only in later slices | +| KEEP | `/find-jobs`, `/signals`, `/signals/weekly`, `/territories`, `/territory`, `/intelligence/[city]` | Retained Opportunities module | +| KEEP | `/microsite`, `/pro/[slug]`, `/[slug]`, `/my-link` | Public URL commitments; never break without an explicit slug-level 301 map | +| KEEP | `/methodology`, `/whats-new`, `/privacy`, `/terms`, `/faq`, `/news`, `/tips`, `/for-your-trade`, `/free-tools` | Public trust/content routes | +| KEEP | `/smart-quote`, `/post-job`, `/tradie-zone`, `/tradiestack`, `/vantage`, `/vicinity`, `/trade-map`, `/uk-grid`, `/epc` | Existing tools remain until usage is known | +| KEEP | `/2builduk-alternative`, `/vs/bark`, `/vs/buildalert`, `/vs/checkatrade`, `/vs/mybuilder`, `/vs/rated-people`, `/vs/trustatrader` | Existing comparison SEO routes | +| KEEP | `/construction-leads/birmingham`, `/construction-leads/bristol`, `/construction-leads/glasgow`, `/construction-leads/leeds`, `/construction-leads/london`, `/construction-leads/manchester` | Existing location SEO routes | +| KEEP | `/trade/asbestos-surveyors`, `/trade/builders`, `/trade/cctv-security`, `/trade/damp-proofers`, `/trade/data-cabling`, `/trade/decorators`, `/trade/electricians`, `/trade/ev-charger-installers`, `/trade/fibre-installers`, `/trade/fire-safety`, `/trade/gas-engineers`, `/trade/groundworkers`, `/trade/heat-pump-installers`, `/trade/hvac-engineers`, `/trade/plumbers`, `/trade/quantity-surveyors`, `/trade/roofers`, `/trade/scaffolders`, `/trade/smart-home-installers`, `/trade/solar-pv-installers`, `/trade/structural-engineers` | Existing trade SEO routes; replacement claims remain restricted for regulated trades | +| KEEP | `/acm-report-pack`, `/calc-pack`, `/cctv-compliance-pack`, `/dno-brief`, `/fra-template`, `/gas-safe-kit`, `/material-price-engine`, `/nasc-pack`, `/om-builder`, `/ozev-grant-pack`, `/swmp-template`, `/wayleave-pack` | Existing tool/content commitments; reassess with analytics before any redirect | +| KEEP | `/claim`, `/features/admin-guard`, `/dashboard/admin-guard` | Existing acquisition/product surfaces | +| REDIRECT | `/blueprint` → `/methodology` | Both are labelled “How It Works”; redirect only after content-parity review | +| RETIRE | `/test`, `/test/intake`, `/dev-portal`, `/codex` | Must remain unavailable in production; remove source only in a separate verified change | +| ADD | `/demo/revenue-rescue` | Synthetic, no-send sales walkthrough; `noindex` until the product gate passes | + +All existing API paths remain `KEEP` during the foundation slice. The App Router and legacy `/api/[[...path]]` Express catch-all require endpoint-by-endpoint ownership before consolidation. diff --git a/docs/v2/source-manifest.md b/docs/v2/source-manifest.md new file mode 100644 index 00000000..0efda090 --- /dev/null +++ b/docs/v2/source-manifest.md @@ -0,0 +1,9 @@ +# V2 source adaptation manifest + +Every adapted source file must be recorded here before its implementation PR is approved. + +| JobFilter destination | Upstream repository | Exact commit | Upstream path | Licence | Adaptation | Notice required | +|---|---|---|---|---|---|---| +| None yet | — | — | — | — | The first V2 demo is independently implemented from the approved requirements; no third-party source was copied. | No | + +Copyleft, source-available, enterprise-only and ambiguously licensed repositories may inform independently written requirements, but their application source must not enter implementation context. diff --git a/package.json b/package.json index b398e4f8..21b82d2c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "next start", "clean": "rm -rf .next dist", "lint": "tsc --noEmit", + "test:v2-demo": "tsx tests/regression/revenue-rescue-demo-regression.mjs", + "test:whatsapp-security": "tsx tests/regression/whatsapp-webhook-security-regression.mjs", "test:fts": "tsx tests/regression/fts-ocds-regression.mjs", "benchmark:fts": "tsx scripts/fts-benchmark.ts", "gate:fts": "tsx scripts/fts-coverage-gate.ts", diff --git a/src/lib/revenueRescueDemo.ts b/src/lib/revenueRescueDemo.ts new file mode 100644 index 00000000..244c412e --- /dev/null +++ b/src/lib/revenueRescueDemo.ts @@ -0,0 +1,76 @@ +export type RevenueRescueStage = { + id: 'missed' | 'qualified' | 'quoted' | 'followed-up' | 'recovered'; + label: string; + time: string; + title: string; + summary: string; + evidence: string; +}; + +export const REVENUE_RESCUE_DEMO = { + mode: 'simulation' as const, + customer: 'Sarah Mitchell', + firm: 'Oak & Ridge Roofing', + job: 'Storm-damaged pitched roof', + postcode: 'DE12', + quoteMinorUnits: 348000, + depositMinorUnits: 69600, + currency: 'GBP' as const, + stages: [ + { + id: 'missed', + label: 'Missed call', + time: '09:12', + title: 'The call was not answered', + summary: 'A customer called while the owner was on a roof. The demo creates an enquiry and prepares a one-segment acknowledgement.', + evidence: 'SIMULATED EVENT · No call or message has been sent', + }, + { + id: 'qualified', + label: 'Qualified', + time: '09:18', + title: 'Enough detail to make a decision', + summary: 'Roof damage, DE12, work needed this week, photos available and a £2,500–£4,000 budget range.', + evidence: '5 required fields captured · Safety and opt-out rules passed', + }, + { + id: 'quoted', + label: 'Quote sent', + time: '10:04', + title: 'A versioned quote is ready', + summary: 'Quote v1 totals £3,480 including a £696 deposit. The customer would receive a secure acceptance link in the live product.', + evidence: 'QUOTE v1 · £3,480.00 · Deposit £696.00', + }, + { + id: 'followed-up', + label: 'Followed up', + time: 'Next day', + title: 'The quote did not disappear into a chat', + summary: 'A bounded follow-up becomes due. In production it pauses immediately on reply, acceptance, decline or opt-out.', + evidence: '1 reminder scheduled · 0 duplicate attempts · Budget protected', + }, + { + id: 'recovered', + label: 'Recovered', + time: '14:26', + title: 'The customer accepted', + summary: 'The accepted quote is attributed back to the original missed enquiry so the owner can see what progressed and why.', + evidence: 'VALUE RECEIPT · £3,480 progressed · Source: missed enquiry', + }, + ] satisfies RevenueRescueStage[], +}; + +export function clampDemoStage(index: number) { + return Math.max(0, Math.min(Math.trunc(index), REVENUE_RESCUE_DEMO.stages.length - 1)); +} + +export function nextDemoStage(index: number) { + return clampDemoStage(index + 1); +} + +export function formatDemoMoney(minorUnits: number) { + return new Intl.NumberFormat('en-GB', { + style: 'currency', + currency: REVENUE_RESCUE_DEMO.currency, + }).format(minorUnits / 100); +} diff --git a/src/lib/whatsappSignature.ts b/src/lib/whatsappSignature.ts new file mode 100644 index 00000000..7daa62b4 --- /dev/null +++ b/src/lib/whatsappSignature.ts @@ -0,0 +1,14 @@ +import crypto from 'node:crypto'; + +const META_SIGNATURE_PREFIX = 'sha256='; + +export function isValidMetaSignature(rawBody: string, signature: string | null, appSecret: string) { + if (!appSecret || !signature?.startsWith(META_SIGNATURE_PREFIX)) return false; + + const providedHex = signature.slice(META_SIGNATURE_PREFIX.length); + if (!/^[a-f0-9]{64}$/i.test(providedHex)) return false; + + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + const provided = Buffer.from(providedHex, 'hex'); + return provided.length === expected.length && crypto.timingSafeEqual(provided, expected); +} diff --git a/src/pages/RevenueRescueDemoPage.tsx b/src/pages/RevenueRescueDemoPage.tsx new file mode 100644 index 00000000..5e26966a --- /dev/null +++ b/src/pages/RevenueRescueDemoPage.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useState } from 'react'; +import { + REVENUE_RESCUE_DEMO, + formatDemoMoney, + nextDemoStage, +} from '../lib/revenueRescueDemo'; + +export function RevenueRescueDemoPage() { + const [stageIndex, setStageIndex] = useState(0); + const current = REVENUE_RESCUE_DEMO.stages[stageIndex]; + const isComplete = stageIndex === REVENUE_RESCUE_DEMO.stages.length - 1; + + return ( +
+
+
+

Revenue Rescue · Interactive walkthrough

+

+ See one missed enquiry become a quote. +

+

+ Step through a synthetic roofing enquiry. Nothing here contacts a customer, charges a card or writes to production. +

+

+ Simulation only · No calls · No texts · No payments +

+
+
+ +
+
+
+
+
+

Current state

+

{current.label}

+
+

+ {stageIndex + 1}/{REVENUE_RESCUE_DEMO.stages.length} +

+
+ +
    + {REVENUE_RESCUE_DEMO.stages.map((stage, index) => { + const reached = index <= stageIndex; + return ( +
  1. + 0{index + 1} + {stage.label} +
  2. + ); + })} +
+ +
+
+

{current.time}

+

Synthetic record

+
+

{current.title}

+

+ {current.summary} +

+
+ {current.evidence} +
+ +
+ {!isComplete ? ( + + ) : ( + + Ask about the pilot → + + )} + {stageIndex > 0 && ( + + )} +
+
+
+ + +
+
+
+ ); +} + +function SummaryRow({ term, value }: { term: string; value: string }) { + return ( +
+
{term}
+
{value}
+
+ ); +} diff --git a/tests/regression/revenue-rescue-demo-regression.mjs b/tests/regression/revenue-rescue-demo-regression.mjs new file mode 100644 index 00000000..f627b572 --- /dev/null +++ b/tests/regression/revenue-rescue-demo-regression.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { + REVENUE_RESCUE_DEMO, + clampDemoStage, + formatDemoMoney, + nextDemoStage, +} from '../../src/lib/revenueRescueDemo.ts'; + +assert.equal(REVENUE_RESCUE_DEMO.mode, 'simulation'); +assert.deepEqual( + REVENUE_RESCUE_DEMO.stages.map((stage) => stage.id), + ['missed', 'qualified', 'quoted', 'followed-up', 'recovered'], +); +assert.equal(nextDemoStage(0), 1); +assert.equal(nextDemoStage(4), 4); +assert.equal(clampDemoStage(-5), 0); +assert.equal(clampDemoStage(99), 4); +assert.equal(formatDemoMoney(REVENUE_RESCUE_DEMO.quoteMinorUnits), '£3,480.00'); +assert.equal( + REVENUE_RESCUE_DEMO.depositMinorUnits * 5, + REVENUE_RESCUE_DEMO.quoteMinorUnits, + 'the demo deposit must remain exactly 20% of the quote', +); +assert.ok( + REVENUE_RESCUE_DEMO.stages.every((stage) => stage.evidence.length > 0), + 'every demo stage must show evidence instead of an unsupported claim', +); + +console.log('revenue rescue demo regression passed'); diff --git a/tests/regression/whatsapp-webhook-security-regression.mjs b/tests/regression/whatsapp-webhook-security-regression.mjs new file mode 100644 index 00000000..d40ce0e2 --- /dev/null +++ b/tests/regression/whatsapp-webhook-security-regression.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { isValidMetaSignature } from '../../src/lib/whatsappSignature.ts'; + +const body = JSON.stringify({ entry: [{ id: 'test' }] }); +const secret = 'regression-secret'; +const validSignature = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`; + +assert.equal(isValidMetaSignature(body, validSignature, secret), true); +assert.equal(isValidMetaSignature(`${body}tampered`, validSignature, secret), false); +assert.equal(isValidMetaSignature(body, null, secret), false); +assert.equal(isValidMetaSignature(body, 'sha256=bad', secret), false); +assert.equal(isValidMetaSignature(body, validSignature, ''), false); + +const routeSource = fs.readFileSync(new URL('../../app/api/whatsapp/webhook/route.ts', import.meta.url), 'utf8'); +assert.match(routeSource, /WHATSAPP_INBOUND_ENABLED !== 'true'/); +assert.match(routeSource, /if \(!appSecret\)/); +assert.match(routeSource, /isValidMetaSignature/); +assert.doesNotMatch(routeSource, /incoming from/); +assert.match(routeSource, /response\?\.ok/); +assert.match(routeSource, /received: true, replyDelivered: false/); +assert.doesNotMatch(routeSource, /Outbound reply failed' \}, \{ status: 502/); + +console.log('whatsapp webhook security regression passed');