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
+