From 79c5b733065e598db2e6a1b14b5c1e765f3eb9ef Mon Sep 17 00:00:00 2001 From: manazoid4 <157256328+manazoid4@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:21:34 +0100 Subject: [PATCH 1/5] docs(v2): establish audited zero-cost foundation --- .github/workflows/ci.yml | 3 + DESIGN.md | 24 ++++++ docs/v2/phase-0-audit.md | 60 +++++++++++++++ docs/v2/route-migration-matrix.md | 22 ++++++ docs/v2/source-manifest.md | 10 +++ package.json | 1 + src/lib/revenueRescueDemo.ts | 77 +++++++++++++++++++ .../revenue-rescue-demo-regression.mjs | 30 ++++++++ 8 files changed, 227 insertions(+) create mode 100644 DESIGN.md create mode 100644 docs/v2/phase-0-audit.md create mode 100644 docs/v2/route-migration-matrix.md create mode 100644 docs/v2/source-manifest.md create mode 100644 src/lib/revenueRescueDemo.ts create mode 100644 tests/regression/revenue-rescue-demo-regression.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43bb9a61..ceb6ea0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ 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: Find a Tender OCDS regression run: npm run test:fts diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..aa2922ef --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,24 @@ +# 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/docs/v2/phase-0-audit.md b/docs/v2/phase-0-audit.md new file mode 100644 index 00000000..1b916243 --- /dev/null +++ b/docs/v2/phase-0-audit.md @@ -0,0 +1,60 @@ +# 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 | WhatsApp webhook authentication is optional when `WHATSAPP_APP_SECRET` is missing. It also logs sender/message PII and treats a failed outbound fetch as success. | Keep WhatsApp disabled. Before enablement, require the signature secret, use constant-time comparison, deduplicate inbound IDs, persist delivery attempts and remove message-body logs. | +| 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..8b5514ad --- /dev/null +++ b/docs/v2/route-migration-matrix.md @@ -0,0 +1,22 @@ +# 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..326680e2 --- /dev/null +++ b/docs/v2/source-manifest.md @@ -0,0 +1,10 @@ +# 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..3c636cd4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "next start", "clean": "rm -rf .next dist", "lint": "tsc --noEmit", + "test:v2-demo": "tsx tests/regression/revenue-rescue-demo-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..39c3312c --- /dev/null +++ b/src/lib/revenueRescueDemo.ts @@ -0,0 +1,77 @@ +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/tests/regression/revenue-rescue-demo-regression.mjs b/tests/regression/revenue-rescue-demo-regression.mjs new file mode 100644 index 00000000..330f9b49 --- /dev/null +++ b/tests/regression/revenue-rescue-demo-regression.mjs @@ -0,0 +1,30 @@ +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'); + From 2c372afe4d7740818e76180af604e8420ef0f2c3 Mon Sep 17 00:00:00 2001 From: manazoid4 <157256328+manazoid4@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:26:27 +0100 Subject: [PATCH 2/5] feat(v2): add zero-cost revenue rescue walkthrough --- app/demo/revenue-rescue/page.tsx | 16 ++++ src/pages/RevenueRescueDemoPage.tsx | 143 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 app/demo/revenue-rescue/page.tsx create mode 100644 src/pages/RevenueRescueDemoPage.tsx diff --git a/app/demo/revenue-rescue/page.tsx b/app/demo/revenue-rescue/page.tsx new file mode 100644 index 00000000..0c4f6dc1 --- /dev/null +++ b/app/demo/revenue-rescue/page.tsx @@ -0,0 +1,16 @@ +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/src/pages/RevenueRescueDemoPage.tsx b/src/pages/RevenueRescueDemoPage.tsx new file mode 100644 index 00000000..02b8fa58 --- /dev/null +++ b/src/pages/RevenueRescueDemoPage.tsx @@ -0,0 +1,143 @@ +'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}
+
+ ); +} + From 6311be197f88922a9657627049c6bef6dea3ab15 Mon Sep 17 00:00:00 2001 From: manazoid4 <157256328+manazoid4@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:29:51 +0100 Subject: [PATCH 3/5] fix(security): fail closed on inbound WhatsApp --- .env.example | 1 + .github/workflows/ci.yml | 3 ++ app/api/whatsapp/webhook/route.ts | 33 ++++++++++++------- docs/v2/phase-0-audit.md | 3 +- package.json | 1 + src/lib/whatsappSignature.ts | 14 ++++++++ .../whatsapp-webhook-security-regression.mjs | 23 +++++++++++++ 7 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 src/lib/whatsappSignature.ts create mode 100644 tests/regression/whatsapp-webhook-security-regression.mjs 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 ceb6ea0e..d9fc30a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - 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/app/api/whatsapp/webhook/route.ts b/app/api/whatsapp/webhook/route.ts index 6f591a7f..fec3e781 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,16 @@ 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 }); + return NextResponse.json({ ok: false, error: 'Outbound reply failed' }, { status: 502 }); + } + } else { + return NextResponse.json({ ok: false, error: 'WhatsApp delivery is not configured' }, { status: 503 }); } } diff --git a/docs/v2/phase-0-audit.md b/docs/v2/phase-0-audit.md index 1b916243..c2a61119 100644 --- a/docs/v2/phase-0-audit.md +++ b/docs/v2/phase-0-audit.md @@ -36,7 +36,7 @@ No production configuration, database, subscription, message or deployment was c | Severity | Verification | Finding | Required action | |---|---|---|---| -| CRITICAL | VERIFIED in source | WhatsApp webhook authentication is optional when `WHATSAPP_APP_SECRET` is missing. It also logs sender/message PII and treats a failed outbound fetch as success. | Keep WhatsApp disabled. Before enablement, require the signature secret, use constant-time comparison, deduplicate inbound IDs, persist delivery attempts and remove message-body logs. | +| 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 surfaces delivery failure. 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. | @@ -57,4 +57,3 @@ Gate 0 is **not fully passed**. The local build/security baseline is verified, b 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/package.json b/package.json index 3c636cd4..21b82d2c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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/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/tests/regression/whatsapp-webhook-security-regression.mjs b/tests/regression/whatsapp-webhook-security-regression.mjs new file mode 100644 index 00000000..9916fdf8 --- /dev/null +++ b/tests/regression/whatsapp-webhook-security-regression.mjs @@ -0,0 +1,23 @@ +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/); + +console.log('whatsapp webhook security regression passed'); From 5b6203b198d97c6c456575559e668fb24e2ebaf1 Mon Sep 17 00:00:00 2001 From: manazoid4 <157256328+manazoid4@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:31:12 +0100 Subject: [PATCH 4/5] chore(v2): satisfy pre-push checks --- DESIGN.md | 1 - app/demo/revenue-rescue/page.tsx | 1 - docs/v2/phase-0-audit.md | 6 ++++-- docs/v2/route-migration-matrix.md | 1 - docs/v2/source-manifest.md | 1 - src/lib/revenueRescueDemo.ts | 1 - src/pages/RevenueRescueDemoPage.tsx | 1 - tests/regression/revenue-rescue-demo-regression.mjs | 1 - 8 files changed, 4 insertions(+), 9 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index aa2922ef..7ed8542c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -21,4 +21,3 @@ - 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/demo/revenue-rescue/page.tsx b/app/demo/revenue-rescue/page.tsx index 0c4f6dc1..8a1afdc6 100644 --- a/app/demo/revenue-rescue/page.tsx +++ b/app/demo/revenue-rescue/page.tsx @@ -13,4 +13,3 @@ export const metadata: Metadata = { export default function Page() { return ; } - diff --git a/docs/v2/phase-0-audit.md b/docs/v2/phase-0-audit.md index c2a61119..fda7490d 100644 --- a/docs/v2/phase-0-audit.md +++ b/docs/v2/phase-0-audit.md @@ -1,7 +1,9 @@ # JobFilter V2 Phase 0 audit -Date: 24 August 2026 -Branch: `agents/jobfilter-v2-foundation` +Date: 24 August 2026 + +Branch: `agents/jobfilter-v2-foundation` + Base: `origin/main` at `5489192` ## Access statement diff --git a/docs/v2/route-migration-matrix.md b/docs/v2/route-migration-matrix.md index 8b5514ad..a107e88a 100644 --- a/docs/v2/route-migration-matrix.md +++ b/docs/v2/route-migration-matrix.md @@ -19,4 +19,3 @@ No route is deleted during the foundation slice. `KEEP` means the URL remains st | 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 index 326680e2..0efda090 100644 --- a/docs/v2/source-manifest.md +++ b/docs/v2/source-manifest.md @@ -7,4 +7,3 @@ Every adapted source file must be recorded here before its implementation PR is | 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/src/lib/revenueRescueDemo.ts b/src/lib/revenueRescueDemo.ts index 39c3312c..244c412e 100644 --- a/src/lib/revenueRescueDemo.ts +++ b/src/lib/revenueRescueDemo.ts @@ -74,4 +74,3 @@ export function formatDemoMoney(minorUnits: number) { currency: REVENUE_RESCUE_DEMO.currency, }).format(minorUnits / 100); } - diff --git a/src/pages/RevenueRescueDemoPage.tsx b/src/pages/RevenueRescueDemoPage.tsx index 02b8fa58..5e26966a 100644 --- a/src/pages/RevenueRescueDemoPage.tsx +++ b/src/pages/RevenueRescueDemoPage.tsx @@ -140,4 +140,3 @@ function SummaryRow({ term, value }: { term: string; value: string }) { ); } - diff --git a/tests/regression/revenue-rescue-demo-regression.mjs b/tests/regression/revenue-rescue-demo-regression.mjs index 330f9b49..f627b572 100644 --- a/tests/regression/revenue-rescue-demo-regression.mjs +++ b/tests/regression/revenue-rescue-demo-regression.mjs @@ -27,4 +27,3 @@ assert.ok( ); console.log('revenue rescue demo regression passed'); - From 45ca22f79e7a7567478675cca84198a36ef49c3c Mon Sep 17 00:00:00 2001 From: manazoid4 <157256328+manazoid4@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:37:51 +0100 Subject: [PATCH 5/5] fix(webhook): avoid retries after intake side effects --- app/api/whatsapp/webhook/route.ts | 4 +++- docs/v2/phase-0-audit.md | 2 +- tests/regression/whatsapp-webhook-security-regression.mjs | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/api/whatsapp/webhook/route.ts b/app/api/whatsapp/webhook/route.ts index fec3e781..caa05ce8 100644 --- a/app/api/whatsapp/webhook/route.ts +++ b/app/api/whatsapp/webhook/route.ts @@ -89,7 +89,9 @@ export async function POST(request: NextRequest) { if (!response?.ok) { console.error('[whatsapp/webhook] outbound reply failed', { status: response?.status ?? 0 }); - return NextResponse.json({ ok: false, error: 'Outbound reply failed' }, { status: 502 }); + // 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/docs/v2/phase-0-audit.md b/docs/v2/phase-0-audit.md index fda7490d..13c42b96 100644 --- a/docs/v2/phase-0-audit.md +++ b/docs/v2/phase-0-audit.md @@ -38,7 +38,7 @@ No production configuration, database, subscription, message or deployment was c | 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 surfaces delivery failure. Durable inbound deduplication/outbox work is still required before enablement. | +| 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. | diff --git a/tests/regression/whatsapp-webhook-security-regression.mjs b/tests/regression/whatsapp-webhook-security-regression.mjs index 9916fdf8..d40ce0e2 100644 --- a/tests/regression/whatsapp-webhook-security-regression.mjs +++ b/tests/regression/whatsapp-webhook-security-regression.mjs @@ -19,5 +19,7 @@ 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');