diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e99e039f..d41c06f32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + env: + # Server pages read the growth form policy while rendering, so the build + # needs the same switch the deployed environment sets. It is not a secret. + GROWTH_FORM_POLICY: growth_v1 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 53b437899..f0a900869 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -1,5 +1,11 @@ import { test, expect } from '@playwright/test'; +// Mirrored from apps/website/src/lib/growth/form-policy.ts, which is server-only +// and therefore cannot be imported into a Playwright spec. +const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-01'; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + const docsRoute = '/docs/langgraph/getting-started/introduction'; async function expectNoHorizontalOverflow( @@ -88,7 +94,7 @@ test('contact page submits a lead payload and renders success state', async ({ p }); }); - await page.goto('/contact?source=e2e_contact&track=enterprise'); + await page.goto('/contact'); const contactForm = page.locator('main form').first(); await contactForm.getByRole('textbox', { name: 'Email', exact: true }).fill('jane@acme.com'); await contactForm.getByRole('textbox', { name: 'Name' }).fill('Jane Smith'); @@ -98,13 +104,14 @@ test('contact page submits a lead payload and renders success state', async ({ p await expect(page.getByText("Thanks. We'll be in touch within one business day.")).toBeVisible(); expect(leadPayload).toMatchObject({ + form_kind: 'contact', email: 'jane@acme.com', name: 'Jane Smith', company: 'Acme', message: 'We are evaluating Threadplane.', - source_page: 'e2e_contact', - track: 'enterprise', + policy_version: GROWTH_FORM_POLICY_VERSION, }); + expect(leadPayload?.['submission_id']).toMatch(UUID_V4); }); test('pricing lead form posts to /api/leads and renders success state', async ({ page }) => { @@ -128,11 +135,14 @@ test('pricing lead form posts to /api/leads and renders success state', async ({ await expect(page.getByText(/we'll be in touch within one business day/i)).toBeVisible(); expect(leadPayload).toMatchObject({ + form_kind: 'pricing', email: 'jane@acme.com', name: 'Jane Smith', company: 'Acme', message: 'Volume seats and security review.', + policy_version: GROWTH_FORM_POLICY_VERSION, }); + expect(leadPayload?.['submission_id']).toMatch(UUID_V4); }); test('footer newsletter form posts to /api/newsletter and renders success state', async ({ page }) => { @@ -152,7 +162,11 @@ test('footer newsletter form posts to /api/newsletter and renders success state' await footer.getByRole('button', { name: 'Subscribe' }).click(); await expect(page.getByText("✓ You're subscribed!")).toBeVisible(); - expect(payload).toEqual({ email: 'reader@acme.com' }); + expect(payload).toMatchObject({ + email: 'reader@acme.com', + policy_version: GROWTH_FORM_POLICY_VERSION, + }); + expect(payload?.['submission_id']).toMatch(UUID_V4); }); test('whitepaper signup form posts to /api/whitepaper-signup and renders success state', async ({ page }) => { @@ -171,7 +185,12 @@ test('whitepaper signup form posts to /api/whitepaper-signup and renders success await page.locator('#whitepaper-block').getByRole('button', { name: 'Download (free)' }).click(); await expect(page.getByText(/check your inbox/i)).toBeVisible(); - expect(payload).toEqual({ email: 'reader@acme.com', paper: 'chat' }); + expect(payload).toMatchObject({ + email: 'reader@acme.com', + paper: 'chat', + policy_version: GROWTH_FORM_POLICY_VERSION, + }); + expect(payload?.['submission_id']).toMatch(UUID_V4); }); test('docs page renders sidebar and content', async ({ page }) => { diff --git a/apps/website/emails/angular-download.ts b/apps/website/emails/angular-download.ts deleted file mode 100644 index e3d60e6d8..000000000 --- a/apps/website/emails/angular-download.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { wrapEmail, esc } from './email-wrapper'; - -const DOWNLOAD_URL = 'https://threadplane.ai/whitepapers/angular.pdf'; - -export function angularDownloadHtml(name?: string): string { - return wrapEmail({ - body: ` -

Your Enterprise Guide to Agent UI in Angular

-

${name ? `Hi ${esc(name)}, t` : 'T'}he guide covers six chapters: the last-mile problem, the agent() API, thread persistence, interrupt flows, full LangGraph feature coverage, and deterministic testing.

-
- Download the Guide -
- `, - }); -} diff --git a/apps/website/emails/chat-download.ts b/apps/website/emails/chat-download.ts deleted file mode 100644 index 64de83197..000000000 --- a/apps/website/emails/chat-download.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { wrapEmail, esc } from './email-wrapper'; - -const DOWNLOAD_URL = 'https://threadplane.ai/whitepapers/chat.pdf'; - -export function chatDownloadHtml(name?: string): string { - return wrapEmail({ - body: ` -

Your Enterprise Guide to Agent Chat Interfaces

-

${name ? `Hi ${esc(name)}, t` : 'T'}he guide covers five chapters: the sprint tax, batteries-included components, theming and design system integration, generative UI in chat, and debug tooling.

-
- Download the Guide -
- `, - }); -} diff --git a/apps/website/emails/drip-angular-followup.ts b/apps/website/emails/drip-angular-followup.ts deleted file mode 100644 index 353472b56..000000000 --- a/apps/website/emails/drip-angular-followup.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { wrapEmail } from './email-wrapper'; - -export function dripAngularFollowupHtml(day: number): { subject: string; html: string } { - if (day === 2) { - return { - subject: 'Did you read Chapter 2 on the agent() API?', - html: wrapEmail({ - body: ` -

Angular Guide Follow-up

-

Did you read Chapter 2 on the agent() API?

-

Chapter 2 dives into the agent() API — the signal-native primitive that connects your Angular component directly to a LangGraph streaming run. It's the chapter most teams bookmark first when they see how little boilerplate is required.

- Read the Docs → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 5) { - return { - subject: 'LangGraph Angular SDK vs @threadplane/langgraph', - html: wrapEmail({ - body: ` -

Comparison

-

LangGraph Angular SDK vs @threadplane/langgraph

-

The LangGraph JS SDK gives you a streaming client. @threadplane/langgraph gives you signal-native state, thread persistence, interrupt flows, and a full test harness — all wired together and optimized for Angular's change detection model. See the full comparison on our product page.

- See the Comparison → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 10) { - return { - subject: 'An optional eight-week path from pilot to production', - html: wrapEmail({ - body: ` -

Pilot Program

-

An optional eight-week path from pilot to production

-

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

-
-

Week 1 · Integration & first stream

-

Month 1 · First agent in staging

-

Week 8 · Production readiness

-
- Learn About the Pilot → - `, - showUnsubscribe: true, - }), - }; - } - - // day === 20 - return { - subject: "Ready to ship your LangGraph agent? Let's talk.", - html: wrapEmail({ - body: ` -

Let's Connect

-

Ready to ship your LangGraph agent? Let's talk.

-

If your team is evaluating how to take an Angular + LangGraph agent to production, I'd love to hear what you're building. Reply to this email or schedule a conversation — no pitch, just a technical discussion about your use case.

- `, - showUnsubscribe: true, - }), - }; -} diff --git a/apps/website/emails/drip-chat-followup.ts b/apps/website/emails/drip-chat-followup.ts deleted file mode 100644 index 78f30674a..000000000 --- a/apps/website/emails/drip-chat-followup.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { wrapEmail } from './email-wrapper'; - -export function dripChatFollowupHtml(day: number): { subject: string; html: string } { - if (day === 2) { - return { - subject: 'Did you read Chapter 2 on batteries-included components?', - html: wrapEmail({ - body: ` -

Chat Guide Follow-up

-

Did you read Chapter 2 on batteries-included components?

-

Chapter 2 covers the batteries-included component library — message bubbles, streaming indicators, thread lists, and input controls that are pre-wired to agent state. Drop them in and your chat UI works on day one.

- Read the Docs → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 5) { - return { - subject: 'The sprint tax: why every team rebuilds chat from scratch', - html: wrapEmail({ - body: ` -

The Sprint Tax

-

The sprint tax: why every team rebuilds chat from scratch

-

Most teams spend 2–4 sprints building a chat UI before a single agent feature lands. Streaming state management, optimistic updates, thread history, error recovery — it's the same work every time. @threadplane/chat eliminates the sprint tax so your team ships features from day one.

- See How It Works → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 10) { - return { - subject: 'An optional eight-week path from pilot to production', - html: wrapEmail({ - body: ` -

Pilot Program

-

An optional eight-week path from pilot to production

-

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

-
-

Week 1 · Integration & first stream

-

Month 1 · First agent in staging

-

Week 8 · Production readiness

-
- Learn About the Pilot → - `, - showUnsubscribe: true, - }), - }; - } - - // day === 20 - return { - subject: "Ready to ship your agent chat? Let's talk.", - html: wrapEmail({ - body: ` -

Let's Connect

-

Ready to ship your agent chat? Let's talk.

-

If your team is building an agent chat interface and wants to skip the sprint tax, I'd love to hear what you're working on. Reply to this email or schedule a conversation — no pitch, just a technical discussion about your use case.

- `, - showUnsubscribe: true, - }), - }; -} diff --git a/apps/website/emails/drip-render-followup.ts b/apps/website/emails/drip-render-followup.ts deleted file mode 100644 index 8d4b8a575..000000000 --- a/apps/website/emails/drip-render-followup.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { wrapEmail } from './email-wrapper'; - -export function dripRenderFollowupHtml(day: number): { subject: string; html: string } { - if (day === 2) { - return { - subject: 'Did you read Chapter 2 on declarative UI specs?', - html: wrapEmail({ - body: ` -

Render Guide Follow-up

-

Did you read Chapter 2 on declarative UI specs?

-

Chapter 2 covers declarative UI specs — how agents emit structured JSON that maps directly to your component registry instead of generating raw HTML. It's the foundation that makes generative UI predictable and testable.

- Read the Docs → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 5) { - return { - subject: 'Why tight coupling between agents and UI kills iteration speed', - html: wrapEmail({ - body: ` -

Architecture

-

Why tight coupling between agents and UI kills iteration speed

-

When an agent generates UI directly — raw HTML, string templates, hardcoded component names — every model change breaks the frontend and every UI change breaks the prompt. Decoupling via a declarative spec layer means agents and UI teams can iterate independently. See how @threadplane/render makes this the default.

- See How It Works → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 10) { - return { - subject: 'An optional eight-week path from pilot to production', - html: wrapEmail({ - body: ` -

Pilot Program

-

An optional eight-week path from pilot to production

-

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

-
-

Week 1 · Integration & first stream

-

Month 1 · First agent in staging

-

Week 8 · Production readiness

-
- Learn About the Pilot → - `, - showUnsubscribe: true, - }), - }; - } - - // day === 20 - return { - subject: "Ready to decouple your agent UI? Let's talk.", - html: wrapEmail({ - body: ` -

Let's Connect

-

Ready to decouple your agent UI? Let's talk.

-

If your team is evaluating how to make generative UI predictable and maintainable in production, I'd love to hear what you're building. Reply to this email or schedule a conversation — no pitch, just a technical discussion about your use case.

- `, - showUnsubscribe: true, - }), - }; -} diff --git a/apps/website/emails/drip-whitepaper-followup.ts b/apps/website/emails/drip-whitepaper-followup.ts deleted file mode 100644 index 2ecad2f3b..000000000 --- a/apps/website/emails/drip-whitepaper-followup.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { wrapEmail } from './email-wrapper'; - -export function dripWhitepaperFollowupHtml(day: number): { subject: string; html: string } { - if (day === 2) { - return { - subject: 'Did you get a chance to read Chapter 3?', - html: wrapEmail({ - body: ` -

Whitepaper Follow-up

-

Did you get a chance to read Chapter 3?

-

Chapter 3 covers tool-call rendering — how to surface agent actions as real UI instead of raw JSON. It's the chapter most teams bookmark first.

- Read the Guide → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 5) { - return { - subject: 'The gap between demo and production', - html: wrapEmail({ - body: ` -

Production Readiness

-

The gap between demo and production

-

Half of GenAI projects die after proof of concept. The gap isn't the model — it's the frontend production path: streaming state, thread persistence, human approval flows, and deterministic testing.

- See How It Works → - `, - showUnsubscribe: true, - }), - }; - } - - if (day === 10) { - return { - subject: 'An optional eight-week path from pilot to production', - html: wrapEmail({ - body: ` -

Pilot Program

-

An optional eight-week path from pilot to production

-

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

-
-

Week 1 · Integration & first stream

-

Month 1 · First agent in staging

-

Week 8 · Production readiness

-
- Learn About the Pilot → - `, - showUnsubscribe: true, - }), - }; - } - - // day === 20 - return { - subject: "Ready to ship your agent? Let's talk.", - html: wrapEmail({ - body: ` -

Let's Connect

-

Ready to ship your agent? Let's talk.

-

If your team is evaluating how to take an Angular + LangGraph agent to production, I'd love to hear what you're building. Reply to this email or schedule a conversation — no pitch, just a technical discussion about your use case.

- `, - showUnsubscribe: true, - }), - }; -} diff --git a/apps/website/emails/email-wrapper.ts b/apps/website/emails/email-wrapper.ts deleted file mode 100644 index 5103eba34..000000000 --- a/apps/website/emails/email-wrapper.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Shared HTML wrapper for all email templates. - * - * Brand pass: drops the pastel gradient header band (legacy aesthetic) for - * a clean white card with hairline borders, matching the Statusbrew-inspired - * marketing surface. Inline-only styles for cross-client compatibility. - */ -export function wrapEmail(opts: { - body: string; - showUnsubscribe?: boolean; -}): string { - return ` - -
-
-
🛩️ Threadplane
-
-
- ${opts.body} -
-

Threadplane — Production-ready chat, threads, and generative UI for AI agents.

- ${opts.showUnsubscribe ? '

Unsubscribe

' : ''} -
-
-
-`; -} - -export function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/apps/website/emails/lead-notification.ts b/apps/website/emails/lead-notification.ts deleted file mode 100644 index 9a9583256..000000000 --- a/apps/website/emails/lead-notification.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { wrapEmail, esc } from './email-wrapper'; - -interface LeadNotificationProps { - name?: string; - email: string; - company?: string; - message?: string; - ts: string; -} - -export function leadNotificationHtml({ name, email, company, message, ts }: LeadNotificationProps): string { - const displayName = name && name.length > 0 ? name : 'No name provided'; - return wrapEmail({ - body: ` -

New Lead

-

${esc(displayName)}

-

${esc(email)}${company ? ` — ${esc(company)}` : ''}

- ${message ? `

${esc(message)}

` : ''} -
-

Received ${esc(ts)}

-
- `, - }); -} diff --git a/apps/website/emails/newsletter-welcome.ts b/apps/website/emails/newsletter-welcome.ts deleted file mode 100644 index 797720656..000000000 --- a/apps/website/emails/newsletter-welcome.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { wrapEmail } from './email-wrapper'; - -export function newsletterWelcomeHtml(): string { - return wrapEmail({ - body: ` -

Welcome to Threadplane updates

-

You'll receive updates on new capabilities, production patterns, and Angular agent best practices. We keep it focused and infrequent — no spam.

- Explore the Docs - `, - }); -} diff --git a/apps/website/emails/render-download.ts b/apps/website/emails/render-download.ts deleted file mode 100644 index 5c808af84..000000000 --- a/apps/website/emails/render-download.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { wrapEmail, esc } from './email-wrapper'; - -const DOWNLOAD_URL = 'https://threadplane.ai/whitepapers/render.pdf'; - -export function renderDownloadHtml(name?: string): string { - return wrapEmail({ - body: ` -

Your Enterprise Guide to Generative UI

-

${name ? `Hi ${esc(name)}, t` : 'T'}he guide covers five chapters: the coupling problem, declarative UI specs with Vercel's json-render standard, the component registry, streaming JSON patches, and state management.

-
- Download the Guide -
- `, - }); -} diff --git a/apps/website/emails/whitepaper-download.ts b/apps/website/emails/whitepaper-download.ts deleted file mode 100644 index f0386cd66..000000000 --- a/apps/website/emails/whitepaper-download.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { wrapEmail, esc } from './email-wrapper'; - -const DOWNLOAD_URL = 'https://threadplane.ai/whitepaper.pdf'; - -export function whitepaperDownloadHtml(name?: string): string { - return wrapEmail({ - body: ` -

Your Enterprise Agent UI Guide for Angular

-

${name ? `Hi ${esc(name)}, t` : 'T'}he guide covers six production-readiness dimensions: streaming state, thread persistence, tool-call rendering, human approval flows, generative UI, and deterministic testing.

-
- Download the Guide -
- `, - }); -} diff --git a/apps/website/lib/drip.ts b/apps/website/lib/drip.ts deleted file mode 100644 index ac7c5991d..000000000 --- a/apps/website/lib/drip.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { sendEmail, FROM } from './resend'; -import { dripWhitepaperFollowupHtml } from '../emails/drip-whitepaper-followup'; -import { dripAngularFollowupHtml } from '../emails/drip-angular-followup'; -import { dripRenderFollowupHtml } from '../emails/drip-render-followup'; -import { dripChatFollowupHtml } from '../emails/drip-chat-followup'; - -export type PaperId = 'overview' | 'angular' | 'render' | 'chat'; - -const DRIP_DAYS = [2, 5, 10, 20]; - -const DRIP_GENERATORS: Record { subject: string; html: string }> = { - overview: dripWhitepaperFollowupHtml, - angular: dripAngularFollowupHtml, - render: dripRenderFollowupHtml, - chat: dripChatFollowupHtml, -}; - -function daysFromNow(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - d.setHours(9, 0, 0, 0); // Send at 9am - return d.toISOString(); -} - -/** Schedule the whitepaper drip sequence for a contact. Best-effort. */ -export async function scheduleWhitepaperDrip(email: string, paper: PaperId = 'overview') { - const generator = DRIP_GENERATORS[paper] ?? DRIP_GENERATORS.overview; - for (const day of DRIP_DAYS) { - const { subject, html } = generator(day); - const personalizedHtml = html.replace('email=RECIPIENT', `email=${encodeURIComponent(email)}`); - try { - await sendEmail({ - from: FROM, - to: email, - subject, - html: personalizedHtml, - scheduledAt: daysFromNow(day), - }); - } catch (err) { - console.error(`[drip] Failed to schedule day-${day} ${paper} email for ${email}:`, err); - } - } -} diff --git a/apps/website/lib/loops.ts b/apps/website/lib/loops.ts deleted file mode 100644 index f259d1dab..000000000 --- a/apps/website/lib/loops.ts +++ /dev/null @@ -1,60 +0,0 @@ -const LOOPS_API_KEY = process.env.LOOPS_API_KEY || ''; -const LOOPS_BASE = 'https://app.loops.so/api/v1'; - -/** Create or update a contact in Loops. Fails silently. */ -export async function loopsUpsertContact(opts: { - email: string; - firstName?: string; - source?: string; - properties?: Record; -}) { - if (!LOOPS_API_KEY) { - console.info('[loops] skipped (no API key):', opts.email); - return; - } - try { - const body: Record = { - email: opts.email, - source: opts.source || 'website', - subscribed: true, - }; - if (opts.firstName) body.firstName = opts.firstName; - if (opts.properties) Object.assign(body, opts.properties); - - await fetch(`${LOOPS_BASE}/contacts/update`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${LOOPS_API_KEY}`, - }, - body: JSON.stringify(body), - }); - } catch (err) { - console.error('[loops] upsertContact failed:', err); - } -} - -/** Send an event to trigger a Loops workflow. Fails silently. */ -export async function loopsSendEvent(opts: { - email: string; - eventName: string; - properties?: Record; -}) { - if (!LOOPS_API_KEY) return; - try { - await fetch(`${LOOPS_BASE}/events/send`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${LOOPS_API_KEY}`, - }, - body: JSON.stringify({ - email: opts.email, - eventName: opts.eventName, - ...(opts.properties ? { eventProperties: opts.properties } : {}), - }), - }); - } catch (err) { - console.error('[loops] sendEvent failed:', err); - } -} diff --git a/apps/website/lib/resend.ts b/apps/website/lib/resend.ts deleted file mode 100644 index f31e892ba..000000000 --- a/apps/website/lib/resend.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Resend } from 'resend'; - -/** Lazy-init Resend client — returns null when API key is missing (dev without keys). */ -let _resend: Resend | null = null; -function getResend(): Resend | null { - if (_resend) return _resend; - const key = process.env.RESEND_API_KEY; - if (!key) return null; - _resend = new Resend(key); - return _resend; -} - -export const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID || ''; -export const FROM = process.env.RESEND_FROM || 'Threadplane '; -export const NOTIFY_TO = process.env.RESEND_NOTIFY_TO || 'hello@cacheplane.ai'; - -/** Send an email via Resend. No-ops when API key is missing. */ -export async function sendEmail(opts: { from: string; to: string; subject: string; html: string; scheduledAt?: string }) { - const client = getResend(); - if (!client) { - console.info('[resend] skipped (no API key):', opts.subject); - return; - } - await client.emails.send({ - from: opts.from, - to: opts.to, - subject: opts.subject, - html: opts.html, - ...(opts.scheduledAt ? { scheduledAt: opts.scheduledAt } : {}), - }); -} - -/** Add a contact to the Resend audience. Fails silently. */ -export async function addToAudience(email: string, firstName?: string) { - if (!AUDIENCE_ID) return; - const client = getResend(); - if (!client) return; - try { - await client.contacts.create({ - audienceId: AUDIENCE_ID, - email, - firstName: firstName || undefined, - }); - } catch (err) { - console.error('[resend] addToAudience failed:', err); - } -} diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index e3f0b2b11..2aa28d9a9 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -70,6 +70,9 @@ export const createWebsitePlaywrightConfig = ( cwd: '../..', url: localURL, reuseExistingServer, + // Server pages read the growth form policy while rendering, so the + // local server carries the switch the deployed environment sets. + env: { GROWTH_FORM_POLICY: 'growth_v1' }, timeout: bfcacheRuntimeTest ? 180_000 : 60_000, }, { diff --git a/apps/website/src/app/ag-ui/page.spec.tsx b/apps/website/src/app/ag-ui/page.spec.tsx index 9f09b5a2d..7bf5243f9 100644 --- a/apps/website/src/app/ag-ui/page.spec.tsx +++ b/apps/website/src/app/ag-ui/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import AgUiPage from './page'; import { BACKENDS } from '../../components/landing/ag-ui/BackendsGrid'; diff --git a/apps/website/src/app/ag-ui/page.tsx b/apps/website/src/app/ag-ui/page.tsx index 74aad1c1d..fbf35aad2 100644 --- a/apps/website/src/app/ag-ui/page.tsx +++ b/apps/website/src/app/ag-ui/page.tsx @@ -14,6 +14,7 @@ import { StackDiagramSection } from '../../components/landing/StackDiagramSectio import { createPageMetadata, SHORT_POSITIONING_DESCRIPTION } from '../../lib/site-metadata'; import { SECTION_MEDIA } from '../../lib/section-media'; import { buildPanes } from '../../lib/build-panes'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: '@threadplane/ag-ui — Threadplane', @@ -23,6 +24,7 @@ export const metadata = createPageMetadata({ }); export default async function AgUiPage() { + const formPolicy = getFormPolicy(); const panes = await buildPanes(SECTION_MEDIA.libAgUi, SECTION_MEDIA.libAgUi.video?.url ?? ''); return ( @@ -102,7 +104,7 @@ export default async function AgUiPage() { visual={} /> - + ); diff --git a/apps/website/src/app/api/_internal/read-bounded-body.spec.ts b/apps/website/src/app/api/_internal/read-bounded-body.spec.ts new file mode 100644 index 000000000..debdec5a1 --- /dev/null +++ b/apps/website/src/app/api/_internal/read-bounded-body.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { readBoundedBody } from './read-bounded-body'; + +describe('readBoundedBody', () => { + it('treats a null request body as empty', async () => { + const request = new Request('https://threadplane.ai/api/unsubscribe', { + method: 'POST', + }); + + await expect(readBoundedBody(request, 2_048)).resolves.toBe(''); + }); + + it('maps stream read failures to a rejected body result', async () => { + const body = new ReadableStream({ + pull(controller) { + controller.error(new Error('stream failed')); + }, + }); + const request = new Request('https://threadplane.ai/api/unsubscribe', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit); + + await expect(readBoundedBody(request, 2_048)).resolves.toBeNull(); + expect(request.body?.locked).toBe(false); + }); + + it('streams a body up to the exact byte cap and releases the reader', async () => { + const encoder = new TextEncoder(); + const chunks = [encoder.encode('{"'), encoder.encode('ok":"✓"}')]; + const byteLength = chunks.reduce( + (total, chunk) => total + chunk.byteLength, + 0 + ); + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + }); + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit); + + await expect(readBoundedBody(request, byteLength)).resolves.toBe( + '{"ok":"✓"}' + ); + expect(request.body?.locked).toBe(false); + }); + + it('rejects a lying content length when the streamed bytes exceed the cap', async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('1234')); + controller.enqueue(new TextEncoder().encode('5')); + }, + cancel() { + cancelled = true; + }, + }); + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + headers: { 'content-length': '4' }, + body, + duplex: 'half', + } as RequestInit); + + await expect(readBoundedBody(request, 4)).resolves.toBeNull(); + expect(cancelled).toBe(true); + expect(request.body?.locked).toBe(false); + }); + + it.each(['5', '-1', 'not-a-number'])( + 'rejects an invalid or oversized declared length before reading: %s', + async (contentLength) => { + let cancelled = false; + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + headers: { 'content-length': contentLength }, + body: new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + duplex: 'half', + } as RequestInit); + + await expect(readBoundedBody(request, 4)).resolves.toBeNull(); + expect(cancelled).toBe(true); + expect(request.body?.locked).toBe(false); + } + ); + + it('rejects malformed UTF-8 and releases the reader', async () => { + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + body: new Uint8Array([0xc3, 0x28]), + duplex: 'half', + } as RequestInit); + + await expect(readBoundedBody(request, 2)).resolves.toBeNull(); + expect(request.body?.locked).toBe(false); + }); +}); diff --git a/apps/website/src/app/api/_internal/read-bounded-body.ts b/apps/website/src/app/api/_internal/read-bounded-body.ts new file mode 100644 index 000000000..6278b6501 --- /dev/null +++ b/apps/website/src/app/api/_internal/read-bounded-body.ts @@ -0,0 +1,52 @@ +export async function readBoundedBody( + request: Request, + maximumBytes: number +): Promise { + const rejectUnreadBody = async (): Promise => { + if (request.body !== null && !request.body.locked) { + await request.body.cancel().catch(() => undefined); + } + return null; + }; + + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) { + return rejectUnreadBody(); + } + + const declaredLength = request.headers.get('content-length'); + if (declaredLength !== null) { + const normalizedLength = declaredLength.trim(); + if (!/^\d+$/u.test(normalizedLength)) return rejectUnreadBody(); + const byteLength = Number(normalizedLength); + if (!Number.isSafeInteger(byteLength) || byteLength > maximumBytes) { + return rejectUnreadBody(); + } + } + + if (request.body === null) return ''; + + const reader = request.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + const decoded: string[] = []; + let bytesRead = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytesRead += value.byteLength; + if (bytesRead > maximumBytes) { + await reader.cancel().catch(() => undefined); + return null; + } + decoded.push(decoder.decode(value, { stream: true })); + } + decoded.push(decoder.decode()); + return decoded.join(''); + } catch { + await reader.cancel().catch(() => undefined); + return null; + } finally { + reader.releaseLock(); + } +} diff --git a/apps/website/src/app/api/cron/lifecycle/route.spec.ts b/apps/website/src/app/api/cron/lifecycle/route.spec.ts new file mode 100644 index 000000000..688e8bf9d --- /dev/null +++ b/apps/website/src/app/api/cron/lifecycle/route.spec.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPOSITORY_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../../../../..' +); + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { createLifecycleCronRoute } from './route'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('GET /api/cron/lifecycle', () => { + it.each([undefined, '', 'Bearer wrong', 'bearer cron-secret'])( + 'rejects a missing or wrong Vercel cron token: %s', + async (authorization) => { + const invoke = vi.fn(); + const route = createLifecycleCronRoute({ invoke }); + vi.stubEnv('CRON_SECRET', 'cron-secret'); + const response = await route( + new Request('https://threadplane.ai/api/cron/lifecycle', { + headers: authorization ? { authorization } : {}, + }) + ); + + expect(response.status).toBe(401); + expect(invoke).not.toHaveBeenCalled(); + } + ); + + it('invokes Dawn with server-only configuration and returns a bounded result', async () => { + vi.stubEnv('CRON_SECRET', 'cron-secret'); + vi.stubEnv('LIFECYCLE_CRON_ENABLED', 'true'); + vi.stubEnv('LIFECYCLE_DAWN_URL', 'https://lifecycle.example'); + vi.stubEnv('LIFECYCLE_SERVICE_SECRET', 'service-secret'); + const invoke = vi.fn().mockResolvedValue({ + operatorAlerts: [], + threadId: '00000000-0000-4000-8000-000000000001', + }); + const route = createLifecycleCronRoute({ invoke }); + + const response = await route( + new Request('https://threadplane.ai/api/cron/lifecycle', { + headers: { authorization: 'Bearer cron-secret' }, + }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + accepted: true, + operator_alerts: [], + }); + expect(invoke).toHaveBeenCalledWith({ + baseUrl: 'https://lifecycle.example', + serviceSecret: 'service-secret', + timeoutMs: 15_000, + trigger: 'cron', + }); + }); + + it('surfaces only the closed recovery alert to operators', async () => { + vi.stubEnv('CRON_SECRET', 'cron-secret'); + vi.stubEnv('LIFECYCLE_CRON_ENABLED', 'true'); + vi.stubEnv('LIFECYCLE_DAWN_URL', 'https://lifecycle.example'); + vi.stubEnv('LIFECYCLE_SERVICE_SECRET', 'service-secret'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const route = createLifecycleCronRoute({ + invoke: vi.fn().mockResolvedValue({ + operatorAlerts: ['mailbox_recovery_required'], + threadId: '00000000-0000-4000-8000-000000000001', + }), + }); + + const response = await route( + new Request('https://threadplane.ai/api/cron/lifecycle', { + headers: { authorization: 'Bearer cron-secret' }, + }) + ); + + expect(await response.json()).toEqual({ + accepted: true, + operator_alerts: ['mailbox_recovery_required'], + }); + expect(warn).toHaveBeenCalledWith( + '[lifecycle-operator-alert]', + 'mailbox_recovery_required' + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain( + '00000000-0000-4000-8000-000000000001' + ); + warn.mockRestore(); + }); + + it('fails closed on missing service configuration or a bounded upstream error', async () => { + vi.stubEnv('CRON_SECRET', 'cron-secret'); + vi.stubEnv('LIFECYCLE_CRON_ENABLED', 'true'); + const route = createLifecycleCronRoute({ + invoke: vi.fn().mockRejectedValue(new Error('upstream secret detail')), + }); + const response = await route( + new Request('https://threadplane.ai/api/cron/lifecycle', { + headers: { authorization: 'Bearer cron-secret' }, + }) + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ accepted: false }); + }); + + it('keeps cron dispatch disabled until the dogfood gate is explicitly enabled', async () => { + vi.stubEnv('CRON_SECRET', 'cron-secret'); + vi.stubEnv('LIFECYCLE_DAWN_URL', 'https://lifecycle.example'); + vi.stubEnv('LIFECYCLE_SERVICE_SECRET', 'service-secret'); + const invoke = vi.fn(); + const route = createLifecycleCronRoute({ invoke }); + + const response = await route( + new Request('https://threadplane.ai/api/cron/lifecycle', { + headers: { authorization: 'Bearer cron-secret' }, + }) + ); + + expect(response.status).toBe(503); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('registers exactly one every-minute root Vercel cron without public secrets', () => { + const config = JSON.parse( + readFileSync(resolve(REPOSITORY_ROOT, 'vercel.json'), 'utf8') + ) as Record; + expect(config['crons']).toEqual([ + { path: '/api/cron/lifecycle', schedule: '* * * * *' }, + ]); + expect(JSON.stringify(config)).not.toMatch( + /NEXT_PUBLIC_(?:CRON|LIFECYCLE)/u + ); + }); +}); diff --git a/apps/website/src/app/api/cron/lifecycle/route.ts b/apps/website/src/app/api/cron/lifecycle/route.ts new file mode 100644 index 000000000..c45bb329e --- /dev/null +++ b/apps/website/src/app/api/cron/lifecycle/route.ts @@ -0,0 +1,81 @@ +import { timingSafeEqual } from 'node:crypto'; + +import { + invokeLifecycle, + type InvokeLifecycleInput, + type InvokeLifecycleResult, + type LifecycleOperatorAlert, +} from '../../../../lib/growth/lifecycle-client'; + +export const dynamic = 'force-dynamic'; + +interface LifecycleCronDependencies { + invoke: (input: InvokeLifecycleInput) => Promise; +} + +const defaultDependencies: LifecycleCronDependencies = { + invoke: invokeLifecycle, +}; + +function exactBearer( + value: string | null, + secret: string | undefined +): boolean { + if (!value || !secret) return false; + const actual = Buffer.from(value, 'utf8'); + const expected = Buffer.from(`Bearer ${secret}`, 'utf8'); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function response( + body: { + accepted: boolean; + operator_alerts?: LifecycleOperatorAlert[]; + }, + status: number +): Response { + return Response.json(body, { + status, + headers: { 'cache-control': 'no-store' }, + }); +} + +export function createLifecycleCronRoute( + dependencies: LifecycleCronDependencies = defaultDependencies +): (request: Request) => Promise { + return async (request: Request): Promise => { + if ( + !exactBearer( + request.headers.get('authorization'), + process.env['CRON_SECRET'] + ) + ) { + return response({ accepted: false }, 401); + } + if (process.env['LIFECYCLE_CRON_ENABLED'] !== 'true') { + return response({ accepted: false }, 503); + } + const baseUrl = process.env['LIFECYCLE_DAWN_URL']?.trim(); + const serviceSecret = process.env['LIFECYCLE_SERVICE_SECRET']; + if (!baseUrl || !serviceSecret) return response({ accepted: false }, 503); + try { + const result = await dependencies.invoke({ + baseUrl, + serviceSecret, + timeoutMs: 15_000, + trigger: 'cron', + }); + for (const alert of result.operatorAlerts) { + console.warn('[lifecycle-operator-alert]', alert); + } + return response( + { accepted: true, operator_alerts: result.operatorAlerts }, + 200 + ); + } catch { + return response({ accepted: false }, 503); + } + }; +} + +export const GET = createLifecycleCronRoute(); diff --git a/apps/website/src/app/api/email-preview/route.ts b/apps/website/src/app/api/email-preview/route.ts deleted file mode 100644 index 2d393afc0..000000000 --- a/apps/website/src/app/api/email-preview/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Dev-only email template preview route. - * Visit /api/email-preview?template=whitepaper-download to preview a template. - * Available templates: whitepaper-download, newsletter-welcome, lead-notification, - * drip-day-2, drip-day-5, drip-day-10, drip-day-20 - */ -import { NextRequest, NextResponse } from 'next/server'; -import { whitepaperDownloadHtml } from '../../../../emails/whitepaper-download'; -import { newsletterWelcomeHtml } from '../../../../emails/newsletter-welcome'; -import { leadNotificationHtml } from '../../../../emails/lead-notification'; -import { dripWhitepaperFollowupHtml } from '../../../../emails/drip-whitepaper-followup'; - -const TEMPLATES: Record { subject: string; html: string }> = { - 'whitepaper-download': () => ({ - subject: 'Your Enterprise Agent UI Guide for Angular', - html: whitepaperDownloadHtml('Brian'), - }), - 'newsletter-welcome': () => ({ - subject: 'Welcome to Threadplane updates', - html: newsletterWelcomeHtml(), - }), - 'lead-notification': () => ({ - subject: 'New lead: Brian at Threadplane', - html: leadNotificationHtml({ - name: 'Sample Lead', - email: 'demo@example.com', - company: 'Example Corp', - message: 'Interested in the pilot program for our Angular + LangGraph project.', - ts: new Date().toISOString(), - }), - }), - 'drip-day-2': () => dripWhitepaperFollowupHtml(2), - 'drip-day-5': () => dripWhitepaperFollowupHtml(5), - 'drip-day-10': () => dripWhitepaperFollowupHtml(10), - 'drip-day-20': () => dripWhitepaperFollowupHtml(20), -}; - -export async function GET(req: NextRequest) { - const template = req.nextUrl.searchParams.get('template'); - - // Index page — show all templates - if (!template) { - const links = Object.keys(TEMPLATES).map( - (t) => `${t}` - ).join(''); - - return new NextResponse( - `Email Previews - -

Email Template Previews

-

Click a template to preview it as rendered HTML.

- ${links} - `, - { headers: { 'Content-Type': 'text/html' } } - ); - } - - const factory = TEMPLATES[template]; - if (!factory) { - return new NextResponse(`Unknown template: ${template}`, { status: 404 }); - } - - const { subject, html } = factory(); - - // Wrap in a preview frame showing subject line - const preview = `Preview: ${subject} - -
-
- Subject: - ${subject} -
- ← All templates -
-
- ${html} -
- `; - - return new NextResponse(preview, { headers: { 'Content-Type': 'text/html' } }); -} diff --git a/apps/website/src/app/api/growth/replies/google/route.spec.ts b/apps/website/src/app/api/growth/replies/google/route.spec.ts new file mode 100644 index 000000000..316a6e07e --- /dev/null +++ b/apps/website/src/app/api/growth/replies/google/route.spec.ts @@ -0,0 +1,256 @@ +import { createHmac } from 'node:crypto'; + +import { describe, expect, it, vi } from 'vitest'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import type { SqlExecutor } from '@threadplane-internal/growth'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { sha256Base64Url } from '@threadplane-internal/growth'; + +import { createGoogleRepliesRoute } from './route'; + +const now = new Date('2026-09-01T12:00:00.000Z'); +const secret = 's'.repeat(32); +const nonce = 'nonce_0123456789abcdef'; +const rawBody = JSON.stringify({ + kind: 'reply', + version: 1, + gmail_message_id: '18cafe123abd', + rfc_message_id: '', + occurred_at: now.toISOString(), + from: 'developer@example.com', + in_reply_to: '', + references: [''], +}); + +function signedRequest( + body = rawBody, + overrides: Record = {} +): Request { + const timestamp = + overrides['x-threadplane-timestamp'] ?? String(now.getTime()); + const requestNonce = overrides['x-threadplane-nonce'] ?? nonce; + const signature = `v1=${createHmac('sha256', secret) + .update(`${timestamp}\n${requestNonce}\n${sha256Base64Url(body)}`) + .digest('base64url')}`; + return new Request('https://threadplane.ai/api/growth/replies/google', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-threadplane-timestamp': timestamp, + 'x-threadplane-nonce': requestNonce, + 'x-threadplane-signature': signature, + ...overrides, + }, + body, + }); +} + +function harness() { + const order: string[] = []; + const database = { close: vi.fn() } as unknown as SqlExecutor; + const verifySignature = vi.fn(() => order.push('verify')); + const parseEvent = vi.fn(() => { + order.push('parse'); + return { kind: 'reply' } as never; + }); + const createDatabase = vi.fn(() => { + order.push('database'); + return database; + }); + const processEvent = vi.fn(() => { + order.push('process'); + return Promise.resolve({ + applied: true, + outcome: 'reply_stopped', + } as const); + }); + const route = createGoogleRepliesRoute({ + now: () => now, + loadSecret: () => secret, + verifySignature, + parseEvent, + createDatabase, + processEvent, + }); + return { + ...route, + order, + database, + verifySignature, + parseEvent, + createDatabase, + processEvent, + }; +} + +describe('/api/growth/replies/google', () => { + it('verifies exact raw bounded bytes before parsing and database access', async () => { + const test = harness(); + const response = await test.POST(signedRequest()); + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(test.order).toEqual(['verify', 'parse', 'database', 'process']); + expect(test.verifySignature).toHaveBeenCalledWith({ + rawBody, + timestamp: String(now.getTime()), + nonce, + signature: expect.stringMatching(/^v1=/u), + secret, + now, + }); + expect(test.processEvent).toHaveBeenCalledWith(test.database, { + event: { kind: 'reply' }, + nonce, + timestamp: String(now.getTime()), + requestDigest: sha256Base64Url(rawBody), + receivedAt: now, + }); + expect(test.database.close).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'x-threadplane-timestamp', + 'x-threadplane-nonce', + 'x-threadplane-signature', + ])( + 'rejects a missing %s before verification, parsing, or DB access', + async (header) => { + const test = harness(); + const response = await test.POST( + signedRequest(rawBody, { [header]: '' }) + ); + expect(response.status).toBe(400); + expect(test.verifySignature).not.toHaveBeenCalled(); + expect(test.parseEvent).not.toHaveBeenCalled(); + expect(test.createDatabase).not.toHaveBeenCalled(); + } + ); + + it('rejects non-JSON content and malformed, stale, future, tampered, or wrong-secret requests uniformly', async () => { + const cases = [ + signedRequest(rawBody, { 'content-type': 'text/plain' }), + signedRequest('{'), + signedRequest(rawBody, { + 'x-threadplane-timestamp': String(now.getTime() - 300_001), + }), + signedRequest(rawBody, { + 'x-threadplane-timestamp': String(now.getTime() + 300_001), + }), + ]; + for (const request of cases) { + const test = harness(); + if (request.headers.get('content-type') === 'application/json') { + test.verifySignature.mockImplementationOnce(() => { + throw new Error('invalid'); + }); + } + const response = await test.POST(request); + expect(response.status).toBe(400); + expect(await response.text()).toBe('Unable to process request'); + expect(test.createDatabase).not.toHaveBeenCalled(); + } + }); + + it('rejects declared, streamed, and invalid UTF-8 oversized bodies before verification', async () => { + const declared = harness(); + expect( + (await declared.POST(signedRequest('{}', { 'content-length': '32769' }))) + .status + ).toBe(413); + expect(declared.verifySignature).not.toHaveBeenCalled(); + + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(20_000)); + controller.enqueue(new Uint8Array(20_000)); + }, + cancel, + }); + const streamed = new Request( + 'https://threadplane.ai/api/growth/replies/google', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-threadplane-timestamp': String(now.getTime()), + 'x-threadplane-nonce': nonce, + 'x-threadplane-signature': `v1=${'A'.repeat(43)}`, + }, + body: stream, + duplex: 'half', + } as RequestInit & { duplex: 'half' } + ); + const streamedHarness = harness(); + expect((await streamedHarness.POST(streamed)).status).toBe(413); + expect(cancel).toHaveBeenCalledTimes(1); + expect(streamedHarness.verifySignature).not.toHaveBeenCalled(); + + const invalidUtf8 = new Request( + 'https://threadplane.ai/api/growth/replies/google', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-threadplane-timestamp': String(now.getTime()), + 'x-threadplane-nonce': nonce, + 'x-threadplane-signature': `v1=${'A'.repeat(43)}`, + }, + body: new Uint8Array([0xff]), + } + ); + const invalidHarness = harness(); + expect((await invalidHarness.POST(invalidUtf8)).status).toBe(413); + expect(invalidHarness.verifySignature).not.toHaveBeenCalled(); + }); + + it('returns safe terminal responses for envelope/schema failures and 503 for retryable processing failures', async () => { + for (const stage of ['verify', 'parse', 'process'] as const) { + const test = harness(); + if (stage === 'verify') + test.verifySignature.mockImplementationOnce(() => { + throw new Error('replay'); + }); + if (stage === 'parse') + test.parseEvent.mockImplementationOnce(() => { + throw new Error('schema'); + }); + if (stage === 'process') + test.processEvent.mockRejectedValueOnce(new Error('conflict')); + const response = await test.POST(signedRequest()); + expect(response.status).toBe(stage === 'process' ? 503 : 400); + expect(await response.text()).toBe('Unable to process request'); + } + }); + + it('acknowledges a durably ignored deleted-contact event so polling can progress', async () => { + const test = harness(); + test.processEvent.mockResolvedValueOnce({ + applied: true, + outcome: 'ignored_deleted', + }); + + const response = await test.POST(signedRequest()); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('Accepted'); + expect(test.database.close).toHaveBeenCalledTimes(1); + }); + + it('acknowledges a durably recorded invalid matched-recipient rejection', async () => { + const test = harness(); + test.processEvent.mockResolvedValueOnce({ + applied: true, + outcome: 'rejected_terminal', + rejectionReason: 'reply_binding_invalid', + }); + + const response = await test.POST(signedRequest()); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('Accepted'); + }); +}); diff --git a/apps/website/src/app/api/growth/replies/google/route.ts b/apps/website/src/app/api/growth/replies/google/route.ts new file mode 100644 index 000000000..1523640f2 --- /dev/null +++ b/apps/website/src/app/api/growth/replies/google/route.ts @@ -0,0 +1,142 @@ +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + createDatabaseExecutor, + parseGoogleMailboxEvent, + processGoogleMailboxEvent, + sha256Base64Url, + verifyGoogleReplySignature, + type GoogleMailboxEvent, + type ProcessGoogleMailboxEventInput, + type ProcessGoogleMailboxEventResult, + type SqlExecutor, +} from '@threadplane-internal/growth'; + +import { readBoundedBody } from '../../../_internal/read-bounded-body'; + +const MAX_BODY_BYTES = 32_768; +const MAX_HEADER_LENGTH = 256; + +interface GoogleRepliesRouteDependencies { + now: () => Date; + loadSecret: () => string; + verifySignature: typeof verifyGoogleReplySignature; + parseEvent: (rawBody: string) => GoogleMailboxEvent; + createDatabase: () => SqlExecutor; + processEvent: ( + executor: SqlExecutor, + input: ProcessGoogleMailboxEventInput + ) => Promise; +} + +function response(status: number): Response { + return new Response( + status === 200 ? 'Accepted' : 'Unable to process request', + { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + } + ); +} + +function requiredHeader(request: Request, name: string): string | null { + const value = request.headers.get(name)?.trim() ?? ''; + if ( + value.length === 0 || + value.length > MAX_HEADER_LENGTH || + /[\r\n\0]/u.test(value) + ) { + return null; + } + return value; +} + +function isJson(request: Request): boolean { + return ( + request.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase() === 'application/json' + ); +} + +function defaultDependencies(): GoogleRepliesRouteDependencies { + return { + now: () => new Date(), + loadSecret: () => process.env['GOOGLE_REPLY_HMAC_SECRET'] ?? '', + verifySignature: verifyGoogleReplySignature, + parseEvent: parseGoogleMailboxEvent, + createDatabase: () => createDatabaseExecutor(), + processEvent: processGoogleMailboxEvent, + }; +} + +export function createGoogleRepliesRoute( + dependencies: GoogleRepliesRouteDependencies = defaultDependencies() +): { POST: (request: Request) => Promise } { + return { + async POST(request: Request): Promise { + if (!isJson(request)) return response(400); + const timestamp = requiredHeader(request, 'x-threadplane-timestamp'); + const nonce = requiredHeader(request, 'x-threadplane-nonce'); + const signature = requiredHeader(request, 'x-threadplane-signature'); + if (!timestamp || !nonce || !signature) return response(400); + + const rawBody = await readBoundedBody(request, MAX_BODY_BYTES); + if (rawBody === null) return response(413); + + let secret: string; + const receivedAt = dependencies.now(); + try { + secret = dependencies.loadSecret(); + dependencies.verifySignature({ + rawBody, + timestamp, + nonce, + signature, + secret, + now: receivedAt, + }); + } catch { + return response(400); + } + + let event: GoogleMailboxEvent; + try { + event = dependencies.parseEvent(rawBody); + } catch { + return response(400); + } + + let database: SqlExecutor; + try { + database = dependencies.createDatabase(); + } catch { + return response(503); + } + try { + await dependencies.processEvent(database, { + event, + nonce, + timestamp, + requestDigest: sha256Base64Url(rawBody), + receivedAt, + }); + return response(200); + } catch { + // Authentication/schema failures are terminal 400 responses above. + // Once the request reaches persistence, failures are retryable + // infrastructure errors; authenticated domain rejections resolve 200. + return response(503); + } finally { + await database.close?.(); + } + }, + }; +} + +export const { POST } = createGoogleRepliesRoute(); diff --git a/apps/website/src/app/api/growth/stop/route.spec.ts b/apps/website/src/app/api/growth/stop/route.spec.ts new file mode 100644 index 000000000..b1889b640 --- /dev/null +++ b/apps/website/src/app/api/growth/stop/route.spec.ts @@ -0,0 +1,310 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + FOUNDER_STOP_TOKEN_MAX_AGE_SECONDS, + createGrowthActionToken, + type GrowthTokenKeyring, + type SqlExecutor, +} from '@threadplane-internal/growth'; + +import { createFounderStopRoute } from './route'; + +const contactId = '018f47a2-4a2b-4f86-9f03-3dca36f26e55'; +const now = new Date('2026-09-01T12:00:00.000Z'); +const keyring: GrowthTokenKeyring = { + active: { version: 8, secret: 'founder-stop-route-secret-material!' }, + previous: [{ version: 7, secret: 'previous-founder-stop-route-key!' }], +}; + +function executor(): SqlExecutor { + return { + execute: vi.fn(), + transaction: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + } as unknown as SqlExecutor; +} + +function founderToken( + purpose: 'founder_stop' | 'unsubscribe' = 'founder_stop', + issuedAt = now +): string { + return createGrowthActionToken( + { + contactId, + purpose, + issuedAt, + eventNonce: 'founder-review-42', + reason: 'founder_review', + }, + keyring.active + ); +} + +function routeHarness() { + const database = executor(); + const loadTokenKeyring = vi.fn(() => keyring); + const createDatabase = vi.fn(() => database); + const stopContact = vi.fn().mockResolvedValue({ + applied: true, + effective: true, + }); + const route = createFounderStopRoute({ + now: () => now, + loadTokenKeyring, + createDatabase, + stopContact, + }); + return { + ...route, + database, + loadTokenKeyring, + createDatabase, + stopContact, + }; +} + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://threadplane.ai${path}`, init); +} + +describe('/api/growth/stop', () => { + it('offers a nonmutating GET confirmation with no raw email or cookie', async () => { + const harness = routeHarness(); + const token = founderToken(); + + const response = await harness.GET( + request(`/api/growth/stop?token=${token}`) as never + ); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(body).toContain('Confirm contact stop'); + expect(body).toContain(`name="token" value="${token}"`); + expect(body).not.toMatch(/@|%40/iu); + expect(harness.createDatabase).not.toHaveBeenCalled(); + expect(harness.stopContact).not.toHaveBeenCalled(); + }); + + it('uses a purpose-bound short-lived POST to invoke the canonical founder stop', async () => { + const harness = routeHarness(); + const issuedAt = new Date('2026-09-01T11:30:00.000Z'); + const token = founderToken('founder_stop', issuedAt); + + const response = await harness.POST( + request('/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }) as never + ); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(harness.stopContact).toHaveBeenCalledWith(harness.database, { + contactId, + reason: 'manual_suppression', + eventKey: `token:founder_stop:${contactId}:${issuedAt.getTime()}:founder-review-42`, + occurredAt: now, + source: 'signed_founder_stop', + provenance: { + actor: 'founder', + kind: 'founder_action', + policyVersion: 'growth-lifecycle-v1', + }, + }); + expect(harness.database.close).toHaveBeenCalledTimes(1); + }); + + it('returns one failure shape for wrong-purpose, expired, and unknown-contact requests', async () => { + const wrongPurpose = founderToken('unsubscribe'); + const expired = founderToken( + 'founder_stop', + new Date(now.getTime() - (FOUNDER_STOP_TOKEN_MAX_AGE_SECONDS + 1) * 1_000) + ); + const responses = []; + + for (const token of [wrongPurpose, expired]) { + const harness = routeHarness(); + responses.push( + await harness.POST( + request('/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }) as never + ) + ); + expect(harness.createDatabase).not.toHaveBeenCalled(); + } + + const unknownContact = routeHarness(); + unknownContact.stopContact.mockRejectedValueOnce( + new Error(`Growth contact not found: ${contactId}`) + ); + responses.push( + await unknownContact.POST( + request('/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: founderToken() }).toString(), + }) as never + ) + ); + + const shapes = await Promise.all( + responses.map(async (response) => ({ + body: await response.text(), + status: response.status, + })) + ); + expect(new Set(shapes.map(({ body }) => body)).size).toBe(1); + expect(new Set(shapes.map(({ status }) => status))).toEqual(new Set([400])); + expect(shapes[0]?.body).not.toContain(contactId); + }); + + it('rejects missing tokens before loading keys or database configuration', async () => { + const harness = routeHarness(); + + const response = await harness.POST( + request('/api/growth/stop', { method: 'POST' }) as never + ); + + expect(response.status).toBe(400); + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + }); + + it('rejects declared and streamed byte-overflow bodies before loading keys or database state', async () => { + const declared = routeHarness(); + const declaredResponse = await declared.POST( + request('/api/growth/stop', { + method: 'POST', + headers: { + 'content-length': '2049', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'token=x', + }) as never + ); + + let chunk = 0; + const cancel = vi.fn(); + const stream = new ReadableStream({ + pull(controller) { + const value = [ + new TextEncoder().encode('é'.repeat(1024)), + new TextEncoder().encode('x'), + ][chunk++]; + if (value) controller.enqueue(value); + else controller.close(); + }, + cancel, + }); + const streamed = routeHarness(); + const streamedResponse = await streamed.POST( + new Request('https://threadplane.ai/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: stream, + duplex: 'half', + } as RequestInit) as never + ); + + expect([declaredResponse.status, streamedResponse.status]).toEqual([ + 400, 400, + ]); + expect(cancel).toHaveBeenCalledTimes(1); + for (const harness of [declared, streamed]) { + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + } + }); + + it('accepts founder confirmation only from a token-only form body', async () => { + const token = founderToken(); + const invalidRequests = [ + request(`/api/growth/stop?token=${token}`, { method: 'POST' }), + request(`/api/growth/stop?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: '', + }), + request(`/api/growth/stop?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }), + request('/api/growth/stop', { + method: 'POST', + body: new URLSearchParams({ token }).toString(), + }), + request('/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }), + request('/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: new URLSearchParams({ token }).toString(), + }), + request('/api/growth/stop', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, extra: '1' }).toString(), + }), + ]; + const shapes = []; + + for (const invalidRequest of invalidRequests) { + const harness = routeHarness(); + const response = await harness.POST(invalidRequest as never); + shapes.push({ body: await response.text(), status: response.status }); + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + expect(harness.stopContact).not.toHaveBeenCalled(); + } + + expect(new Set(shapes.map(({ status }) => status))).toEqual(new Set([400])); + expect(new Set(shapes.map(({ body }) => body)).size).toBe(1); + }); + + it('escapes a verified token before echoing it into the confirmation form', async () => { + // The signature check is the real gate, so reaching this branch needs a + // token the verifier accepts. Mocking only the verifier proves the escaping + // itself rather than re-testing the signature. + vi.resetModules(); + vi.doMock('@threadplane-internal/growth', async (importOriginal) => ({ + ...(await importOriginal>()), + verifyGrowthActionToken: () => ({ + contactId, + purpose: 'founder_stop', + issuedAt: now, + eventNonce: 'campaign-v1-step-1', + }), + })); + const { createFounderStopRoute } = await import('./route'); + const hostile = '">'; + const route = createFounderStopRoute({ + now: () => now, + loadTokenKeyring: () => keyring, + createDatabase: () => executor(), + stopContact: vi.fn(), + }); + + const response = await route.GET( + request( + `/api/growth/stop?token=${encodeURIComponent(hostile)}` + ) as never + ); + const body = await response.text(); + + expect(body).not.toContain(''); + expect(body).toContain('"><script>'); + vi.doUnmock('@threadplane-internal/growth'); + vi.resetModules(); + }); +}); diff --git a/apps/website/src/app/api/growth/stop/route.ts b/apps/website/src/app/api/growth/stop/route.ts new file mode 100644 index 000000000..b18110b42 --- /dev/null +++ b/apps/website/src/app/api/growth/stop/route.ts @@ -0,0 +1,187 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + FOUNDER_STOP_TOKEN_MAX_AGE_SECONDS, + createDatabaseExecutor, + growthStopEventKey, + loadGrowthTokenKeyring, + stopContact, + verifyGrowthActionToken, + type GrowthTokenKeyring, + type SqlExecutor, + type StopContactInput, + type StopContactResult, +} from '@threadplane-internal/growth'; + +import { readBoundedBody } from '../../_internal/read-bounded-body'; + +const POLICY_VERSION = 'growth-lifecycle-v1'; +const MAX_REQUEST_BODY_LENGTH = 2_048; +const FAILURE_BODY = 'Unable to process this request.'; + +interface FounderStopRouteDependencies { + now: () => Date; + loadTokenKeyring: () => GrowthTokenKeyring; + createDatabase: () => SqlExecutor; + stopContact: ( + executor: SqlExecutor, + input: StopContactInput + ) => Promise>; +} + +function escapeHtmlAttribute(value: string): string { + return value.replace(/[&<>"'`]/gu, (character) => { + switch (character) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return '`'; + } + }); +} + +function htmlResponse(title: string, message: string, form = ''): NextResponse { + return new NextResponse( + `${title}

${title}

${message}

${form}
`, + { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + }, + } + ); +} + +function failureResponse(): NextResponse { + return new NextResponse(FAILURE_BODY, { + status: 400, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); +} + +function confirmationResponse(token: string): NextResponse { + return htmlResponse( + 'Confirm contact stop', + 'Submit this form to stop automated contact.', + `
` + ); +} + +function successResponse(): NextResponse { + return htmlResponse('Contact stop recorded', 'The request was recorded.'); +} + +function defaultDependencies(): FounderStopRouteDependencies { + return { + now: () => new Date(), + loadTokenKeyring: () => loadGrowthTokenKeyring(), + createDatabase: () => createDatabaseExecutor(), + stopContact, + }; +} + +async function readToken(request: Request): Promise { + if ([...new URL(request.url).searchParams].length > 0) return null; + const contentType = request.headers.get('content-type'); + if ( + contentType?.split(';', 1)[0]?.trim().toLowerCase() !== + 'application/x-www-form-urlencoded' + ) { + return null; + } + + let bodyText: string; + try { + const boundedBody = await readBoundedBody(request, MAX_REQUEST_BODY_LENGTH); + if (boundedBody === null) return null; + bodyText = boundedBody; + } catch { + return null; + } + if (bodyText.length === 0) { + return null; + } + const entries = [...new URLSearchParams(bodyText).entries()]; + const token = entries[0]?.[1].trim() ?? ''; + return entries.length === 1 && entries[0]?.[0] === 'token' && token.length > 0 + ? token + : null; +} + +export function createFounderStopRoute( + overrides: Partial = {} +): { + GET: (request: NextRequest) => Promise; + POST: (request: NextRequest) => Promise; +} { + const dependencies = { ...defaultDependencies(), ...overrides }; + + function verify(token: string, verifiedAt = dependencies.now()) { + try { + return verifyGrowthActionToken(token, { + expectedPurpose: 'founder_stop', + keyring: dependencies.loadTokenKeyring(), + now: verifiedAt, + maxAgeSeconds: FOUNDER_STOP_TOKEN_MAX_AGE_SECONDS, + }); + } catch { + return null; + } + } + + return { + async GET(request) { + const token = new URL(request.url).searchParams.get('token')?.trim(); + if (!token) return failureResponse(); + return verify(token) ? confirmationResponse(token) : failureResponse(); + }, + + async POST(request) { + const receivedAt = dependencies.now(); + const token = await readToken(request); + if (!token) return failureResponse(); + const payload = verify(token, receivedAt); + if (!payload) return failureResponse(); + + const executor = dependencies.createDatabase(); + try { + await dependencies.stopContact(executor, { + contactId: payload.contactId, + reason: 'manual_suppression', + eventKey: growthStopEventKey(payload), + occurredAt: receivedAt, + source: 'signed_founder_stop', + provenance: { + actor: 'founder', + kind: 'founder_action', + policyVersion: POLICY_VERSION, + }, + }); + return successResponse(); + } catch { + return failureResponse(); + } finally { + await executor.close?.(); + } + }, + }; +} + +const route = createFounderStopRoute(); + +export const GET = route.GET; +export const POST = route.POST; diff --git a/apps/website/src/app/api/leads/route.spec.ts b/apps/website/src/app/api/leads/route.spec.ts index d7a455d2e..a23fe2af0 100644 --- a/apps/website/src/app/api/leads/route.spec.ts +++ b/apps/website/src/app/api/leads/route.spec.ts @@ -1,192 +1,411 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const sendEmailMock = vi.hoisted(() => vi.fn()); -const addToAudienceMock = vi.hoisted(() => vi.fn()); -const loopsUpsertContactMock = vi.hoisted(() => vi.fn()); -const loopsSendEventMock = vi.hoisted(() => vi.fn()); -const scheduleWhitepaperDripMock = vi.hoisted(() => vi.fn()); -const captureLeadConversionMock = vi.hoisted(() => vi.fn()); -const captureLeadQualifiedMock = vi.hoisted(() => vi.fn()); -const captureNewsletterConversionMock = vi.hoisted(() => vi.fn()); -const captureWhitepaperConversionMock = vi.hoisted(() => vi.fn()); -const mkdirSyncMock = vi.hoisted(() => vi.fn()); -const appendFileSyncMock = vi.hoisted(() => vi.fn()); - -vi.mock('fs', () => ({ - default: { - mkdirSync: mkdirSyncMock, - appendFileSync: appendFileSyncMock, - }, -})); +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + acceptFormSubmission, + type ApproveContactFromFormInput, + type FormApprovalControlState, + type SqlExecutor, + type SqlTransaction, +} from '@threadplane-internal/growth'; -vi.mock('../../../../lib/resend', () => ({ - FROM: 'Threadplane ', - NOTIFY_TO: 'hello@cacheplane.ai', - sendEmail: sendEmailMock, - addToAudience: addToAudienceMock, -})); +vi.mock('server-only', () => ({})); -vi.mock('../../../../lib/loops', () => ({ - loopsUpsertContact: loopsUpsertContactMock, - loopsSendEvent: loopsSendEventMock, +const seam = vi.hoisted(() => ({ + accept: vi.fn(), + close: vi.fn(), + createDatabase: vi.fn(), + getPolicy: vi.fn(), + loadKeyring: vi.fn(), + now: vi.fn(), + nudge: vi.fn(), })); -vi.mock('../../../../lib/drip', () => ({ - scheduleWhitepaperDrip: scheduleWhitepaperDripMock, +vi.mock('../../../lib/growth/form-route', async (importOriginal) => ({ + ...(await importOriginal()), + defaultGrowthFormRouteDependencies: () => ({ + accept: seam.accept, + createDatabase: seam.createDatabase, + getPolicy: seam.getPolicy, + loadKeyring: seam.loadKeyring, + now: seam.now, + nudge: seam.nudge, + }), })); -vi.mock('../../../lib/analytics/server', () => ({ - captureLeadConversion: captureLeadConversionMock, - captureLeadQualified: captureLeadQualifiedMock, - captureNewsletterConversion: captureNewsletterConversionMock, - captureWhitepaperConversion: captureWhitepaperConversionMock, -})); +import type { PublicFormPolicy } from '../../../lib/growth/form-policy'; +import { POST } from './route'; -import { POST as postLead } from './route'; -import { POST as postNewsletter } from '../newsletter/route'; -import { POST as postWhitepaperSignup } from '../whitepaper-signup/route'; +const policy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'Contact disclosure', + newsletter: 'Newsletter disclosure', + whitepaper: 'Whitepaper disclosure', + }, +}; +const submissionId = '20000000-0000-4000-8000-000000000002'; +const acquisitionSessionId = '30000000-0000-4000-8000-000000000003'; +const occurredAt = new Date('2026-09-01T18:00:00.000Z'); +const keyring = { + active: { + version: 1, + secret: 'route-test-secret-that-is-at-least-32-bytes-long', + }, +}; -function jsonRequest(path: string, body: unknown): Request { - return new Request(`https://threadplane.ai${path}`, { +function request( + body: BodyInit | unknown, + contentType = 'application/json' +): Request { + return new Request('https://threadplane.ai/api/leads', { method: 'POST', - headers: { - 'content-type': 'application/json', - referer: 'https://threadplane.ai/pricing', - }, - body: JSON.stringify(body), + headers: contentType ? { 'content-type': contentType } : undefined, + body: typeof body === 'string' ? body : JSON.stringify(body), }); } +function validBody(overrides: Record = {}) { + return { + submission_id: submissionId, + policy_version: policy.version, + acquisition_session_id: acquisitionSessionId, + form_kind: 'contact', + email: ' Reader@Acme.COM ', + name: ' Reader ', + company: ' Acme ', + message: ' How do interrupts work? ', + ...overrides, + }; +} + +function expectCommittedBeforeNudge(): void { + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.nudge).toHaveBeenCalledOnce(); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.close.mock.invocationCallOrder[0] as number + ); + expect(seam.close.mock.invocationCallOrder[0]).toBeLessThan( + seam.nudge.mock.invocationCallOrder[0] as number + ); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.nudge.mock.invocationCallOrder[0] as number + ); +} + +function safeError(responseBody: unknown): void { + const serialized = JSON.stringify(responseBody); + expect(serialized).not.toContain('Reader@Acme.COM'); + expect(serialized).not.toContain('route-test-secret'); + expect(serialized).not.toContain('database'); +} + beforeEach(() => { vi.clearAllMocks(); - sendEmailMock.mockResolvedValue(undefined); - addToAudienceMock.mockResolvedValue(undefined); - loopsUpsertContactMock.mockResolvedValue(undefined); - loopsSendEventMock.mockResolvedValue(undefined); - scheduleWhitepaperDripMock.mockResolvedValue(undefined); - captureLeadConversionMock.mockResolvedValue(undefined); - captureLeadQualifiedMock.mockResolvedValue(undefined); - captureNewsletterConversionMock.mockResolvedValue(undefined); - captureWhitepaperConversionMock.mockResolvedValue(undefined); + seam.getPolicy.mockReturnValue(policy); + seam.accept.mockResolvedValue({ + accepted: true, + approved: true, + contactId: '10000000-0000-4000-8000-000000000001', + submissionId, + }); + seam.close.mockResolvedValue(undefined); + seam.createDatabase.mockReturnValue({ close: seam.close }); + seam.loadKeyring.mockReturnValue(keyring); + seam.now.mockReturnValue(occurredAt); + seam.nudge.mockResolvedValue(undefined); }); -describe('/api/leads', () => { - it('persists the lead, notifies the team, syncs audience systems, and records analytics', async () => { - const response = await postLead(jsonRequest('/api/leads', { - name: 'Jane Smith', - email: 'jane@acme.com', - company: 'Acme', - message: 'We are evaluating Threadplane.', - }) as never); +describe('/api/leads growth_v1', () => { + it('commits the disclosed contact submission, closes Neon, then nudges', async () => { + const response = await POST(request(validBody())); expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true }); - expect(appendFileSyncMock).toHaveBeenCalledWith( - expect.stringContaining('data/leads.ndjson'), - expect.stringContaining('"email":"jane@acme.com"'), - 'utf8', + expect(seam.accept).toHaveBeenCalledWith(expect.anything(), { + submissionId, + email: 'reader@acme.com', + displayName: 'Reader', + companyName: 'Acme', + form: { kind: 'contact', message: 'How do interrupts work?' }, + source: 'website', + sourceForm: 'contact', + noticeText: policy.disclosures.contact, + noticeVersion: `${policy.version}.contact`, + policyVersion: policy.version, + acquisitionSessionId, + occurredAt, + keyring, + }); + expect(seam.nudge).toHaveBeenCalledWith({ submissionId }); + expect(JSON.stringify(seam.nudge.mock.calls)).not.toContain( + 'reader@acme.com' ); - expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({ - from: 'Threadplane ', - to: 'hello@cacheplane.ai', - subject: 'New lead: Jane Smith at Acme', - html: expect.stringContaining('jane@acme.com'), - })); - expect(addToAudienceMock).toHaveBeenCalledWith('jane@acme.com', 'Jane Smith'); - expect(loopsUpsertContactMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'jane@acme.com', - firstName: 'Jane Smith', - source: 'lead-form', - properties: { company: 'Acme' }, - })); - expect(loopsSendEventMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'jane@acme.com', - eventName: 'lead_submitted', - })); - expect(captureLeadConversionMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'jane@acme.com', - company: 'Acme', - sourcePage: '/pricing', - })); - expect(captureLeadQualifiedMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'jane@acme.com', - company: 'Acme', - sourcePage: '/pricing', - })); + expectCommittedBeforeNudge(); }); - it('rejects malformed lead emails before sending or persisting anything', async () => { - const response = await postLead(jsonRequest('/api/leads', { email: 'not-an-email' }) as never); + it('commits a pricing submission with its qualifying answers', async () => { + const response = await POST( + request( + validBody({ + form_kind: 'pricing', + team_size: '6-25', + timeline: 'this_quarter', + pilot_interest: 'yes', + }) + ) + ); + + expect(response.status).toBe(200); + expect(seam.accept).toHaveBeenCalledWith(expect.anything(), { + submissionId, + email: 'reader@acme.com', + displayName: 'Reader', + companyName: 'Acme', + form: { + kind: 'pricing', + message: 'How do interrupts work?', + teamSize: '6-25', + timeline: 'this_quarter', + pilotInterest: 'yes', + }, + source: 'website', + sourceForm: 'pricing', + noticeText: policy.disclosures.contact, + noticeVersion: `${policy.version}.pricing`, + policyVersion: policy.version, + acquisitionSessionId, + occurredAt, + keyring, + }); + expectCommittedBeforeNudge(); + }); + + it.each([undefined, 'growth_v1.stale'])( + 'returns the current safe policy for missing or stale version %s', + async (policyVersion) => { + const body = validBody(); + if (policyVersion === undefined) delete body.policy_version; + else body.policy_version = policyVersion; + + const response = await POST(request(body)); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'This form changed. Please retry.', + policy_version: policy.version, + retryable: true, + }); + expect(response.headers.get('retry-after')).toBe('0'); + expect(seam.createDatabase).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['malformed JSON', request('{')], + ['non-object JSON', request('null')], + ['missing content type', request(JSON.stringify(validBody()), '')], + ['invalid content type', request(JSON.stringify(validBody()), 'text/plain')], + [ + 'oversized body', + request(JSON.stringify({ padding: 'x'.repeat(20_000) })), + ], + ])( + 'rejects %s before reading policy or durable state', + async (_label, input) => { + const response = await POST(input); + + expect(response.status).toBe(400); + expect(seam.getPolicy).not.toHaveBeenCalled(); + expect(seam.createDatabase).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['invalid submission UUID', { submission_id: 'not-a-uuid' }], + ['invalid acquisition UUID', { acquisition_session_id: 'not-a-uuid' }], + ['missing form kind', { form_kind: undefined }], + ['unsupported form kind', { form_kind: 'whitepaper' }], + ['form kind wrong type', { form_kind: ['contact'] }], + ['name too long', { name: 'n'.repeat(201) }], + ['name wrong type', { name: { nested: true } }], + ['company too long', { company: 'c'.repeat(201) }], + ['message too long', { message: 'm'.repeat(2_001) }], + ['unsupported team size', { form_kind: 'pricing', team_size: '1000+' }], + ['unsupported timeline', { form_kind: 'pricing', timeline: 'someday' }], + [ + 'unsupported pilot interest', + { form_kind: 'pricing', pilot_interest: 'perhaps' }, + ], + ])('rejects %s before opening Neon', async (_label, overrides) => { + const response = await POST(request(validBody(overrides))); expect(response.status).toBe(400); - expect(sendEmailMock).not.toHaveBeenCalled(); - expect(addToAudienceMock).not.toHaveBeenCalled(); - expect(appendFileSyncMock).not.toHaveBeenCalled(); + expect(seam.createDatabase).not.toHaveBeenCalled(); + expect(seam.accept).not.toHaveBeenCalled(); }); -}); -describe('/api/newsletter', () => { - it('sends the welcome email, adds the contact to Resend, and records analytics', async () => { - const response = await postNewsletter(jsonRequest('/api/newsletter', { email: 'reader@acme.com' }) as never); + it.each([ + 'a@b', + 'a@@example.com', + 'Reader ', + 'reader @example.com', + `${'a'.repeat(250)}@example.com`, + ])('rejects an invalid email without echoing or logging it', async (email) => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const response = await POST(request(validBody({ email }))); + const responseBody = await response.json(); - expect(response.status).toBe(200); - expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({ - from: 'Threadplane ', - to: 'reader@acme.com', - subject: 'Welcome to Threadplane updates', - })); - expect(addToAudienceMock).toHaveBeenCalledWith('reader@acme.com'); - expect(loopsUpsertContactMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'reader@acme.com', - source: 'newsletter', - })); - expect(loopsSendEventMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'reader@acme.com', - eventName: 'newsletter_subscribed', - })); - expect(captureNewsletterConversionMock).toHaveBeenCalledWith({ - email: 'reader@acme.com', - sourcePage: '/pricing', - }); + expect(response.status).toBe(400); + expect(JSON.stringify(responseBody)).not.toContain(email); + expect(consoleError).not.toHaveBeenCalled(); + expect(seam.accept).not.toHaveBeenCalled(); + consoleError.mockRestore(); }); -}); -describe('/api/whitepaper-signup', () => { - it('sends the requested download, schedules drip, syncs the audience, and records analytics', async () => { - const response = await postWhitepaperSignup(jsonRequest('/api/whitepaper-signup', { - name: 'Reader', - email: 'reader@acme.com', - paper: 'chat', - }) as never); + it.each(['database construction', 'keyring setup'])( + 'fails closed when %s fails', + async (failure) => { + if (failure === 'database construction') { + seam.createDatabase.mockImplementation(() => { + throw new Error('sensitive database URL'); + }); + } else { + seam.loadKeyring.mockImplementation(() => { + throw new Error('sensitive key'); + }); + } + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).not.toHaveBeenCalled(); + expect(seam.nudge).not.toHaveBeenCalled(); + } + ); + + it('closes Neon and fails closed when the acceptance transaction fails', async () => { + seam.accept.mockRejectedValue(new Error('sensitive transaction response')); + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.close.mock.invocationCallOrder[0] as number + ); + expect(seam.nudge).not.toHaveBeenCalled(); + }); + + it('fails closed without nudging when Neon cannot close', async () => { + seam.close.mockRejectedValue(new Error('sensitive close failure')); + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.nudge).not.toHaveBeenCalled(); + }); + + it('keeps committed acceptance successful when the lifecycle nudge fails', async () => { + seam.nudge.mockRejectedValue(new Error('sensitive lifecycle URL')); + + const response = await POST(request(validBody())); expect(response.status).toBe(200); - expect(appendFileSyncMock).toHaveBeenCalledWith( - expect.stringContaining('data/whitepaper-signups.ndjson'), - expect.stringContaining('"paper":"chat"'), - 'utf8', + expect(await response.json()).toEqual({ ok: true }); + expectCommittedBeforeNudge(); + }); + + it('replays one submission UUID without duplicate activity or logical jobs', async () => { + const acceptedEvents = new Set(); + const jobKeys = new Set(); + let activityInsertions = 0; + let jobInsertions = 0; + const transaction: SqlTransaction = { + async execute(sql, parameters = []) { + if (!sql.includes('growth:enqueue-form-jobs')) return { rows: [] }; + const replaySubmissionId = String(parameters[2]); + const kinds = + parameters[3] === true + ? ['fulfill', 'enrich', 'notify'] + : ['fulfill']; + for (const kind of kinds) { + const key = `form:${replaySubmissionId}:${kind}`; + if (!jobKeys.has(key)) { + jobKeys.add(key); + jobInsertions += 1; + } + } + return { + rows: kinds.map((kind) => ({ + idempotency_key: `form:${replaySubmissionId}:${kind}`, + })), + }; + }, + }; + const database: SqlExecutor = { + execute: transaction.execute, + transaction: async (operation) => operation(transaction), + close: seam.close, + }; + const approveContact = vi.fn( + async ( + _transaction: SqlTransaction, + input: ApproveContactFromFormInput + ): Promise => { + if (!acceptedEvents.has(input.eventKey)) { + acceptedEvents.add(input.eventKey); + activityInsertions += 1; + } + return { + contactId: '10000000-0000-4000-8000-000000000001', + authorization: 'approved', + canSend: true, + formApprovalGranted: true, + outreachApprovedAt: occurredAt, + latestHardStop: null, + deletedAt: null, + updatedAt: input.occurredAt, + }; + } ); - expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({ - from: 'Threadplane ', - to: 'reader@acme.com', - subject: 'Your Enterprise Guide to Agent Chat Interfaces', - html: expect.stringContaining('https://threadplane.ai/whitepapers/chat.pdf'), - })); - expect(scheduleWhitepaperDripMock).toHaveBeenCalledWith('reader@acme.com', 'chat'); - expect(addToAudienceMock).toHaveBeenCalledWith('reader@acme.com', 'Reader'); - expect(loopsUpsertContactMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'reader@acme.com', - firstName: 'Reader', - source: 'whitepaper-chat', - })); - expect(loopsSendEventMock).toHaveBeenCalledWith(expect.objectContaining({ - email: 'reader@acme.com', - eventName: 'whitepaper_downloaded', - properties: { paper: 'chat' }, - })); - expect(captureWhitepaperConversionMock).toHaveBeenCalledWith({ - email: 'reader@acme.com', - paper: 'chat', - sourcePage: '/pricing', - }); + seam.createDatabase.mockReturnValue(database); + seam.accept.mockImplementation((executor, input) => + acceptFormSubmission(executor, input, { approveContact }) + ); + + const firstResponse = await POST(request(validBody())); + expect(firstResponse.status).toBe(200); + expectCommittedBeforeNudge(); + + vi.clearAllMocks(); + seam.getPolicy.mockReturnValue(policy); + seam.createDatabase.mockReturnValue(database); + seam.loadKeyring.mockReturnValue(keyring); + seam.now.mockReturnValue(new Date('2026-09-01T18:05:00.000Z')); + seam.nudge.mockResolvedValue(undefined); + seam.accept.mockImplementation((executor, input) => + acceptFormSubmission(executor, input, { approveContact }) + ); + + const replayResponse = await POST(request(validBody())); + expect(replayResponse.status).toBe(200); + expectCommittedBeforeNudge(); + expect(activityInsertions).toBe(1); + expect(jobInsertions).toBe(3); }); }); diff --git a/apps/website/src/app/api/leads/route.ts b/apps/website/src/app/api/leads/route.ts index e6b74a52b..5f4666c5e 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -1,73 +1,160 @@ -import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import { sendEmail, FROM, NOTIFY_TO, addToAudience } from '../../../../lib/resend'; -import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops'; -import { leadNotificationHtml } from '../../../../emails/lead-notification'; -import { captureLeadConversion, captureLeadQualified } from '../../../lib/analytics/server'; -import { getSourcePage } from '@threadplane/telemetry/shared'; - -const LEADS_FILE = path.join(process.cwd(), 'data', 'leads.ndjson'); - -export async function POST(req: NextRequest) { - let body: { name?: unknown; email?: unknown; company?: unknown; message?: unknown }; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const sanitize = (v: unknown, max = 500): string => - typeof v === 'string' ? v.slice(0, max).trim() : ''; - - const name = sanitize(body.name, 200); - const email = sanitize(body.email, 320); - const company = sanitize(body.company, 200); - const message = sanitize(body.message, 2000); - - if (!email || !email.includes('@')) { - return NextResponse.json({ error: 'Valid email required' }, { status: 400 }); - } - - const ts = new Date().toISOString(); - const sourcePage = getSourcePage(req.headers.get('referer')); - - // NDJSON backup (always writes, even if Resend fails) - try { - fs.mkdirSync(path.dirname(LEADS_FILE), { recursive: true }); - fs.appendFileSync(LEADS_FILE, JSON.stringify({ name, email, company, message, ts }) + '\n', 'utf8'); - } catch (err) { - console.error('[leads] NDJSON write failed:', err); - } - - // Resend: email notification + audience (best-effort) - try { - await Promise.all([ - sendEmail({ - from: FROM, - to: NOTIFY_TO, - subject: `New lead: ${name || email}${company ? ` at ${company}` : ''}`, - html: leadNotificationHtml({ name, email, company, message, ts }), - }), - addToAudience(email, name), - loopsUpsertContact({ - email, - firstName: name, - source: 'lead-form', - properties: { company }, - }), - loopsSendEvent({ - email, - eventName: 'lead_submitted', - properties: { company }, - }), - ]); - } catch (err) { - console.error('[resend] lead notification failed:', err); - } - - await captureLeadConversion({ email, company, sourcePage }); - await captureLeadQualified({ email, company, sourcePage }); - - return NextResponse.json({ ok: true }); +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + normalizeRecipientEmail, + type FormSubmission, +} from '@threadplane-internal/growth'; + +import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy'; +import { + defaultGrowthFormRouteDependencies, + jsonResponse, + readBoundedJsonObject, + stalePolicyResponse, + strictOptionalEnum, + strictText, + validGrowthFormIdentities, + type GrowthFormRouteDependencies, +} from '../../../lib/growth/form-route'; + +const MAX_BODY_BYTES = 16_384; + +const TEAM_SIZES = ['1-5', '6-25', '26-100', '100+'] as const; +const TIMELINES = [ + 'this_quarter', + 'next_quarter', + '6_plus_months', + 'exploring', +] as const; +const PILOT_INTERESTS = ['yes', 'maybe', 'no'] as const; + +export function createLeadRoute( + dependencies: GrowthFormRouteDependencies = defaultGrowthFormRouteDependencies() +): { POST: (request: Request) => Promise } { + return { + async POST(request: Request): Promise { + const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); + if (!body) return jsonResponse({ error: 'Invalid JSON' }, 400); + + let policy; + try { + policy = dependencies.getPolicy(); + } catch { + return jsonResponse({ error: 'Unable to accept request' }, 503); + } + + try { + const policyVersion = strictText(body, 'policy_version', 100); + if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) { + return stalePolicyResponse(policy); + } + } catch { + return jsonResponse({ error: 'Invalid form submission' }, 400); + } + + const formKind = body['form_kind']; + if (formKind !== 'contact' && formKind !== 'pricing') { + return jsonResponse({ error: 'Invalid form' }, 400); + } + + let submissionId; + let acquisitionSessionId; + let email; + let name; + let company; + let message; + let teamSize; + let timeline; + let pilotInterest; + try { + submissionId = strictText(body, 'submission_id', 36); + acquisitionSessionId = strictText(body, 'acquisition_session_id', 36); + email = strictText(body, 'email', 254); + name = strictText(body, 'name', 200); + company = strictText(body, 'company', 200); + message = strictText(body, 'message', 2_000); + teamSize = strictOptionalEnum(body, 'team_size', TEAM_SIZES); + timeline = strictOptionalEnum(body, 'timeline', TIMELINES); + pilotInterest = strictOptionalEnum( + body, + 'pilot_interest', + PILOT_INTERESTS + ); + } catch { + return jsonResponse({ error: 'Invalid form submission' }, 400); + } + if (!validGrowthFormIdentities(submissionId, acquisitionSessionId)) { + return jsonResponse({ error: 'Invalid submission' }, 400); + } + + let normalizedEmail; + try { + normalizedEmail = normalizeRecipientEmail(email); + } catch { + return jsonResponse({ error: 'Valid email required' }, 400); + } + + const form: FormSubmission = + formKind === 'contact' + ? { kind: 'contact', ...(message ? { message } : {}) } + : { + kind: 'pricing', + ...(message ? { message } : {}), + ...(teamSize ? { teamSize } : {}), + ...(timeline ? { timeline } : {}), + ...(pilotInterest ? { pilotInterest } : {}), + }; + + let database; + let keyring; + try { + keyring = dependencies.loadKeyring(); + database = dependencies.createDatabase(); + } catch { + return jsonResponse({ error: 'Unable to accept request' }, 503); + } + + let accepted = false; + try { + await dependencies.accept(database, { + submissionId, + email: normalizedEmail, + displayName: name || undefined, + companyName: company || undefined, + form, + source: 'website', + sourceForm: formKind, + noticeText: policy.disclosures.contact, + noticeVersion: `${policy.version}.${formKind}`, + policyVersion: policy.version, + acquisitionSessionId: acquisitionSessionId || undefined, + occurredAt: dependencies.now(), + keyring, + }); + accepted = true; + } catch { + // The response below reports the failure without echoing provider detail. + } + + try { + await database.close?.(); + } catch { + return unableToAccept(); + } + if (!accepted) return unableToAccept(); + + // The durable jobs remain available to the scheduled dispatcher. + await dependencies.nudge({ submissionId }).catch(() => undefined); + return jsonResponse({ ok: true }); + }, + }; +} + +function unableToAccept(): Response { + return jsonResponse( + { error: 'Unable to accept request', retryable: true }, + 503 + ); } + +export const { POST } = createLeadRoute(); diff --git a/apps/website/src/app/api/newsletter/route.spec.ts b/apps/website/src/app/api/newsletter/route.spec.ts new file mode 100644 index 000000000..e6dc64962 --- /dev/null +++ b/apps/website/src/app/api/newsletter/route.spec.ts @@ -0,0 +1,343 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + acceptFormSubmission, + type ApproveContactFromFormInput, + type FormApprovalControlState, + type SqlExecutor, + type SqlTransaction, +} from '@threadplane-internal/growth'; + +vi.mock('server-only', () => ({})); + +const seam = vi.hoisted(() => ({ + accept: vi.fn(), + close: vi.fn(), + createDatabase: vi.fn(), + getPolicy: vi.fn(), + loadKeyring: vi.fn(), + now: vi.fn(), + nudge: vi.fn(), +})); + +vi.mock('../../../lib/growth/form-route', async (importOriginal) => ({ + ...(await importOriginal()), + defaultGrowthFormRouteDependencies: () => ({ + accept: seam.accept, + createDatabase: seam.createDatabase, + getPolicy: seam.getPolicy, + loadKeyring: seam.loadKeyring, + now: seam.now, + nudge: seam.nudge, + }), +})); + +import type { PublicFormPolicy } from '../../../lib/growth/form-policy'; +import { POST } from './route'; + +const policy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'Contact disclosure', + newsletter: 'Newsletter disclosure', + whitepaper: 'Whitepaper disclosure', + }, +}; +const submissionId = '20000000-0000-4000-8000-000000000002'; +const acquisitionSessionId = '30000000-0000-4000-8000-000000000003'; +const occurredAt = new Date('2026-09-01T18:00:00.000Z'); +const keyring = { + active: { + version: 1, + secret: 'route-test-secret-that-is-at-least-32-bytes-long', + }, +}; + +function request(body: BodyInit | unknown, contentType = 'application/json'): Request { + return new Request('https://threadplane.ai/api/newsletter', { + method: 'POST', + headers: contentType ? { 'content-type': contentType } : undefined, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +function validBody(overrides: Record = {}) { + return { + submission_id: submissionId, + policy_version: policy.version, + acquisition_session_id: acquisitionSessionId, + email: ' Reader@Acme.COM ', + ...overrides, + }; +} + +function expectCommittedBeforeNudge(): void { + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.nudge).toHaveBeenCalledOnce(); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.close.mock.invocationCallOrder[0] as number + ); + expect(seam.close.mock.invocationCallOrder[0]).toBeLessThan( + seam.nudge.mock.invocationCallOrder[0] as number + ); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.nudge.mock.invocationCallOrder[0] as number + ); +} + +function safeError(responseBody: unknown): void { + const serialized = JSON.stringify(responseBody); + expect(serialized).not.toContain('Reader@Acme.COM'); + expect(serialized).not.toContain('route-test-secret'); + expect(serialized).not.toContain('database'); +} + +beforeEach(() => { + vi.clearAllMocks(); + seam.getPolicy.mockReturnValue(policy); + seam.accept.mockResolvedValue({ + accepted: true, + approved: true, + contactId: '10000000-0000-4000-8000-000000000001', + submissionId, + }); + seam.close.mockResolvedValue(undefined); + seam.createDatabase.mockReturnValue({ close: seam.close }); + seam.loadKeyring.mockReturnValue(keyring); + seam.now.mockReturnValue(occurredAt); + seam.nudge.mockResolvedValue(undefined); +}); + +describe('/api/newsletter growth_v1', () => { + it('commits the disclosed newsletter, closes Neon, then nudges', async () => { + const response = await POST(request(validBody())); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(seam.accept).toHaveBeenCalledWith(expect.anything(), { + submissionId, + email: 'reader@acme.com', + form: { kind: 'newsletter' }, + source: 'website', + sourceForm: 'newsletter', + noticeText: policy.disclosures.newsletter, + noticeVersion: `${policy.version}.newsletter`, + policyVersion: policy.version, + acquisitionSessionId, + occurredAt, + keyring, + }); + expect(seam.nudge).toHaveBeenCalledWith({ submissionId }); + expect(JSON.stringify(seam.nudge.mock.calls)).not.toContain( + 'reader@acme.com' + ); + expectCommittedBeforeNudge(); + }); + + it.each([undefined, 'growth_v1.stale'])( + 'returns the current safe policy for missing or stale version %s', + async (policyVersion) => { + const body = validBody(); + if (policyVersion === undefined) delete body.policy_version; + else body.policy_version = policyVersion; + + const response = await POST(request(body)); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'This form changed. Please retry.', + policy_version: policy.version, + retryable: true, + }); + expect(response.headers.get('retry-after')).toBe('0'); + expect(seam.createDatabase).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['malformed JSON', request('{')], + ['non-object JSON', request('null')], + ['missing content type', request(JSON.stringify(validBody()), '')], + ['invalid content type', request(JSON.stringify(validBody()), 'text/plain')], + ['oversized body', request(JSON.stringify({ padding: 'x'.repeat(10_000) }))], + ])('rejects %s before reading policy or durable state', async (_label, input) => { + const response = await POST(input); + + expect(response.status).toBe(400); + expect(seam.getPolicy).not.toHaveBeenCalled(); + expect(seam.createDatabase).not.toHaveBeenCalled(); + }); + + it.each([ + ['invalid submission UUID', { submission_id: 'not-a-uuid' }], + ['invalid acquisition UUID', { acquisition_session_id: 'not-a-uuid' }], + ])('rejects %s before opening Neon', async (_label, overrides) => { + const response = await POST(request(validBody(overrides))); + + expect(response.status).toBe(400); + expect(seam.createDatabase).not.toHaveBeenCalled(); + expect(seam.accept).not.toHaveBeenCalled(); + }); + + it.each([ + 'a@b', + 'a@@example.com', + 'Reader ', + 'reader @example.com', + `${'a'.repeat(250)}@example.com`, + ])('rejects an invalid email without echoing or logging it', async (email) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const response = await POST(request(validBody({ email }))); + const responseBody = await response.json(); + + expect(response.status).toBe(400); + expect(JSON.stringify(responseBody)).not.toContain(email); + expect(consoleError).not.toHaveBeenCalled(); + expect(seam.accept).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it.each(['database construction', 'keyring setup'])( + 'fails closed when %s fails', + async (failure) => { + if (failure === 'database construction') { + seam.createDatabase.mockImplementation(() => { + throw new Error('sensitive database URL'); + }); + } else { + seam.loadKeyring.mockImplementation(() => { + throw new Error('sensitive key'); + }); + } + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).not.toHaveBeenCalled(); + expect(seam.nudge).not.toHaveBeenCalled(); + } + ); + + it('closes Neon and fails closed when the acceptance transaction fails', async () => { + seam.accept.mockRejectedValue(new Error('sensitive transaction response')); + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.close.mock.invocationCallOrder[0] as number + ); + expect(seam.nudge).not.toHaveBeenCalled(); + }); + + it('fails closed without nudging when Neon cannot close', async () => { + seam.close.mockRejectedValue(new Error('sensitive close failure')); + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.nudge).not.toHaveBeenCalled(); + }); + + it('keeps committed acceptance successful when the lifecycle nudge fails', async () => { + seam.nudge.mockRejectedValue(new Error('sensitive lifecycle URL')); + + const response = await POST(request(validBody())); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expectCommittedBeforeNudge(); + }); + + it('replays one submission UUID without duplicate activity or logical jobs', async () => { + const acceptedEvents = new Set(); + const jobKeys = new Set(); + let activityInsertions = 0; + let jobInsertions = 0; + const transaction: SqlTransaction = { + async execute(sql, parameters = []) { + if (!sql.includes('growth:enqueue-form-jobs')) return { rows: [] }; + const replaySubmissionId = String(parameters[2]); + const kinds = parameters[3] === true + ? ['fulfill', 'enrich', 'notify'] + : ['fulfill']; + for (const kind of kinds) { + const key = `form:${replaySubmissionId}:${kind}`; + if (!jobKeys.has(key)) { + jobKeys.add(key); + jobInsertions += 1; + } + } + return { + rows: kinds.map((kind) => ({ + idempotency_key: `form:${replaySubmissionId}:${kind}`, + })), + }; + }, + }; + const database: SqlExecutor = { + execute: transaction.execute, + transaction: async (operation) => operation(transaction), + close: seam.close, + }; + const approveContact = vi.fn( + async ( + _transaction: SqlTransaction, + input: ApproveContactFromFormInput + ): Promise => { + if (!acceptedEvents.has(input.eventKey)) { + acceptedEvents.add(input.eventKey); + activityInsertions += 1; + } + return { + contactId: '10000000-0000-4000-8000-000000000001', + authorization: 'approved', + canSend: true, + formApprovalGranted: true, + outreachApprovedAt: occurredAt, + latestHardStop: null, + deletedAt: null, + updatedAt: input.occurredAt, + }; + } + ); + seam.createDatabase.mockReturnValue(database); + seam.accept.mockImplementation((executor, input) => + acceptFormSubmission(executor, input, { approveContact }) + ); + + const firstResponse = await POST(request(validBody())); + expect(firstResponse.status).toBe(200); + expectCommittedBeforeNudge(); + + vi.clearAllMocks(); + seam.getPolicy.mockReturnValue(policy); + seam.createDatabase.mockReturnValue(database); + seam.loadKeyring.mockReturnValue(keyring); + seam.now.mockReturnValue(new Date('2026-09-01T18:05:00.000Z')); + seam.nudge.mockResolvedValue(undefined); + seam.accept.mockImplementation((executor, input) => + acceptFormSubmission(executor, input, { approveContact }) + ); + + const replayResponse = await POST(request(validBody())); + expect(replayResponse.status).toBe(200); + expectCommittedBeforeNudge(); + expect(activityInsertions).toBe(1); + expect(jobInsertions).toBe(3); + }); +}); diff --git a/apps/website/src/app/api/newsletter/route.ts b/apps/website/src/app/api/newsletter/route.ts index 07277aef4..52ffd4424 100644 --- a/apps/website/src/app/api/newsletter/route.ts +++ b/apps/website/src/app/api/newsletter/route.ts @@ -1,49 +1,108 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { sendEmail, FROM, addToAudience } from '../../../../lib/resend'; -import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops'; -import { newsletterWelcomeHtml } from '../../../../emails/newsletter-welcome'; -import { captureNewsletterConversion } from '../../../lib/analytics/server'; -import { getSourcePage } from '@threadplane/telemetry/shared'; - -export async function POST(req: NextRequest) { - let body: { email?: string }; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const email = (body.email || '').trim().slice(0, 320); - const sourcePage = getSourcePage(req.headers.get('referer')); - - if (!email || !email.includes('@')) { - return NextResponse.json({ error: 'Valid email required' }, { status: 400 }); - } - - // Resend: welcome email + audience (best-effort) - try { - await Promise.all([ - sendEmail({ - from: FROM, - to: email, - subject: 'Welcome to Threadplane updates', - html: newsletterWelcomeHtml(), - }), - addToAudience(email), - loopsUpsertContact({ - email, - source: 'newsletter', - }), - loopsSendEvent({ - email, - eventName: 'newsletter_subscribed', - }), - ]); - } catch (err) { - console.error('[resend] newsletter signup failed:', err); - } - - await captureNewsletterConversion({ email, sourcePage }); - - return NextResponse.json({ ok: true }); +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { normalizeRecipientEmail } from '@threadplane-internal/growth'; + +import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy'; +import { + defaultGrowthFormRouteDependencies, + jsonResponse, + readBoundedJsonObject, + stalePolicyResponse, + strictText, + validGrowthFormIdentities, + type GrowthFormRouteDependencies, +} from '../../../lib/growth/form-route'; + +const MAX_BODY_BYTES = 8_192; + +export function createNewsletterRoute( + dependencies: GrowthFormRouteDependencies = defaultGrowthFormRouteDependencies() +): { POST: (request: Request) => Promise } { + return { + async POST(request: Request): Promise { + const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); + if (!body) return jsonResponse({ error: 'Invalid JSON' }, 400); + + let policy; + try { + policy = dependencies.getPolicy(); + } catch { + return jsonResponse({ error: 'Unable to accept request' }, 503); + } + + let submissionId; + let acquisitionSessionId; + let email; + try { + const policyVersion = strictText(body, 'policy_version', 100); + if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) { + return stalePolicyResponse(policy); + } + submissionId = strictText(body, 'submission_id', 36); + acquisitionSessionId = strictText(body, 'acquisition_session_id', 36); + email = strictText(body, 'email', 254); + } catch { + return jsonResponse({ error: 'Invalid form submission' }, 400); + } + if (!validGrowthFormIdentities(submissionId, acquisitionSessionId)) { + return jsonResponse({ error: 'Invalid submission' }, 400); + } + + let normalizedEmail; + try { + normalizedEmail = normalizeRecipientEmail(email); + } catch { + return jsonResponse({ error: 'Valid email required' }, 400); + } + + let database; + let keyring; + try { + keyring = dependencies.loadKeyring(); + database = dependencies.createDatabase(); + } catch { + return jsonResponse({ error: 'Unable to accept request' }, 503); + } + + let accepted = false; + try { + await dependencies.accept(database, { + submissionId, + email: normalizedEmail, + form: { kind: 'newsletter' }, + source: 'website', + sourceForm: 'newsletter', + noticeText: policy.disclosures.newsletter, + noticeVersion: `${policy.version}.newsletter`, + policyVersion: policy.version, + acquisitionSessionId: acquisitionSessionId || undefined, + occurredAt: dependencies.now(), + keyring, + }); + accepted = true; + } catch { + // The response below reports the failure without echoing provider detail. + } + + try { + await database.close?.(); + } catch { + return unableToAccept(); + } + if (!accepted) return unableToAccept(); + + // The durable jobs remain available to the scheduled dispatcher. + await dependencies.nudge({ submissionId }).catch(() => undefined); + return jsonResponse({ ok: true }); + }, + }; } + +function unableToAccept(): Response { + return jsonResponse( + { error: 'Unable to accept request', retryable: true }, + 503 + ); +} + +export const { POST } = createNewsletterRoute(); diff --git a/apps/website/src/app/api/unsubscribe/route.spec.ts b/apps/website/src/app/api/unsubscribe/route.spec.ts new file mode 100644 index 000000000..41e67f045 --- /dev/null +++ b/apps/website/src/app/api/unsubscribe/route.spec.ts @@ -0,0 +1,541 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + createGrowthActionToken, + type EmailHmacKeyring, + type GrowthTokenKeyring, + type SqlExecutor, +} from '@threadplane-internal/growth'; + +import { createUnsubscribeRoute } from './route'; + +const contactId = '018f47a2-4a2b-4f86-9f03-3dca36f26e55'; +const now = new Date('2026-09-01T12:00:00.000Z'); +const tokenKeyring: GrowthTokenKeyring = { + active: { version: 3, secret: 'unsubscribe-route-token-secret!!' }, + previous: [{ version: 2, secret: 'previous-route-token-secret-data' }], +}; +const emailKeyring: EmailHmacKeyring = { + active: { version: 4, secret: 'email-lookup-route-secret-material' }, + previous: [{ version: 3, secret: 'old-email-route-secret-material!!' }], +}; + +function executor(): SqlExecutor { + return { + execute: vi.fn(), + transaction: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + } as unknown as SqlExecutor; +} + +function request(path: string, init: RequestInit = { method: 'GET' }): Request { + return new Request(`https://threadplane.ai${path}`, init); +} + +function unsubscribeToken( + overrides: Partial<{ + issuedAt: Date; + key: GrowthTokenKeyring['active']; + }> = {} +): string { + return createGrowthActionToken( + { + contactId, + purpose: 'unsubscribe', + issuedAt: overrides.issuedAt ?? now, + eventNonce: 'campaign-v1-step-1', + }, + overrides.key ?? tokenKeyring.active + ); +} + +function routeHarness() { + const database = executor(); + const loadTokenKeyring = vi.fn(() => tokenKeyring); + const loadEmailKeyring = vi.fn(() => emailKeyring); + const createDatabase = vi.fn(() => database); + const stopLegacyEmailUnsubscribe = vi.fn().mockResolvedValue({ + applied: true, + contactMatched: true, + effective: true, + }); + const stopContact = vi.fn().mockResolvedValue({ + applied: true, + effective: true, + }); + const route = createUnsubscribeRoute({ + now: () => now, + loadTokenKeyring, + loadEmailKeyring, + createDatabase, + stopLegacyEmailUnsubscribe, + stopContact, + }); + return { + ...route, + database, + loadTokenKeyring, + loadEmailKeyring, + createDatabase, + stopLegacyEmailUnsubscribe, + stopContact, + }; +} + +describe('/api/unsubscribe', () => { + it('renders signed GET confirmation without mutation, database access, or cookies', async () => { + const harness = routeHarness(); + const token = unsubscribeToken(); + + const response = await harness.GET( + request(`/api/unsubscribe?token=${token}`) as never + ); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(body).toContain('Confirm email preference'); + expect(body).toContain(`name="token" value="${token}"`); + expect(body).not.toMatch(/@|%40/iu); + expect(harness.loadTokenKeyring).toHaveBeenCalledTimes(1); + expect(harness.createDatabase).not.toHaveBeenCalled(); + expect(harness.stopContact).not.toHaveBeenCalled(); + }); + + it('performs RFC one-click POST without cookies or CSRF state', async () => { + const harness = routeHarness(); + const issuedAt = new Date('2026-08-01T12:00:00.000Z'); + const token = unsubscribeToken({ issuedAt }); + const response = await harness.POST( + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'List-Unsubscribe=One-Click', + }) as never + ); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(await response.text()).toContain('Email preference updated'); + expect(harness.stopContact).toHaveBeenCalledWith( + harness.database, + expect.objectContaining({ + contactId, + reason: 'unsubscribe', + eventKey: `token:unsubscribe:${contactId}:${issuedAt.getTime()}:campaign-v1-step-1`, + occurredAt: now, + source: 'signed_unsubscribe', + provenance: { + actor: 'recipient', + kind: 'one_click', + policyVersion: 'growth-lifecycle-v1', + }, + }) + ); + expect(harness.database.close).toHaveBeenCalledTimes(1); + }); + + it('maps one-click and ordinary confirmation replays to the same canonical stop envelope', async () => { + const harness = routeHarness(); + const token = unsubscribeToken(); + const oneClick = request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'List-Unsubscribe=One-Click', + }); + const ordinaryConfirmation = request('/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }); + + const first = await harness.POST(oneClick as never); + const second = await harness.POST(ordinaryConfirmation as never); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(harness.stopContact).toHaveBeenCalledTimes(2); + expect(harness.stopContact.mock.calls[0]?.[1]).toEqual( + harness.stopContact.mock.calls[1]?.[1] + ); + expect(harness.stopContact.mock.calls[0]?.[1]).toMatchObject({ + provenance: { + actor: 'recipient', + kind: 'one_click', + policyVersion: 'growth-lifecycle-v1', + }, + }); + }); + + it('returns one non-enumerating shape for token failures and unknown contacts', async () => { + const valid = unsubscribeToken(); + const tampered = `${valid.slice(0, -1)}${valid.endsWith('a') ? 'b' : 'a'}`; + const wrongPurpose = createGrowthActionToken( + { contactId, purpose: 'founder_stop', issuedAt: now }, + tokenKeyring.active + ); + const expired = unsubscribeToken({ + issuedAt: new Date('2019-01-01T00:00:00.000Z'), + }); + const unknownKey = unsubscribeToken({ + key: { version: 99, secret: 'unknown-unsubscribe-route-secret!!' }, + }); + const failureResponses = []; + + for (const token of [tampered, wrongPurpose, expired, unknownKey]) { + const harness = routeHarness(); + failureResponses.push( + await harness.POST( + request('/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }) as never + ) + ); + expect(harness.createDatabase).not.toHaveBeenCalled(); + } + + const bodies = await Promise.all( + failureResponses.map(async (response) => ({ + body: await response.text(), + contentType: response.headers.get('content-type'), + setCookie: response.headers.get('set-cookie'), + status: response.status, + })) + ); + expect(new Set(bodies.map(({ body }) => body)).size).toBe(1); + expect(new Set(bodies.map(({ status }) => status))).toEqual(new Set([400])); + expect(bodies.every(({ setCookie }) => setCookie === null)).toBe(true); + expect(bodies[0]?.body).not.toContain(contactId); + }); + + it.each([ + ['canonical stop failure', 'stop'], + ['database close failure', 'close'], + ])( + 'answers a signed one-click %s with the retryable server shape', + async (_label, failure) => { + const harness = routeHarness(); + if (failure === 'stop') { + harness.stopContact.mockRejectedValueOnce( + new Error(`Growth contact not found: ${contactId}`) + ); + } else { + vi.mocked( + harness.database.close as () => Promise + ).mockRejectedValueOnce(new Error('connection reset')); + } + + const response = await harness.POST( + request('/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: unsubscribeToken() }).toString(), + }) as never + ); + const body = await response.text(); + + expect(response.status).toBe(503); + expect(body).not.toContain(contactId); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(harness.database.close).toHaveBeenCalledTimes(1); + } + ); + + it('keeps invalid no-argument requests compatible without loading environment state', async () => { + const harness = routeHarness(); + + const response = await harness.GET(request('/api/unsubscribe') as never); + + expect(response.status).toBe(400); + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.loadEmailKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + }); + + it('keeps legacy raw-email GET mutating through alias-aware lookup and the canonical stop path', async () => { + const harness = routeHarness(); + + const response = await harness.GET( + request('/api/unsubscribe?email=Legacy%40Example.com') as never + ); + + expect(response.status).toBe(200); + expect(harness.stopLegacyEmailUnsubscribe).toHaveBeenCalledWith( + harness.database, + expect.objectContaining({ + email: 'Legacy@Example.com', + keyring: emailKeyring, + occurredAt: now, + source: 'legacy_raw_email_unsubscribe', + policyVersion: 'growth-lifecycle-v1', + }) + ); + expect(harness.stopContact).not.toHaveBeenCalled(); + expect(harness.database.close).toHaveBeenCalledTimes(1); + }); + + it('returns one legacy success shape for known and unknown healthy outcomes', async () => { + const known = routeHarness(); + const unknown = routeHarness(); + unknown.stopLegacyEmailUnsubscribe.mockResolvedValueOnce({ + applied: false, + contactMatched: false, + effective: false, + }); + + const responses = await Promise.all( + [known, unknown].map((harness) => + harness.GET( + request('/api/unsubscribe?email=recipient%40example.com') as never + ) + ) + ); + const shapes = await Promise.all( + responses.map(async (response) => ({ + body: await response.text(), + contentType: response.headers.get('content-type'), + status: response.status, + })) + ); + + expect(new Set(shapes.map(({ status }) => status))).toEqual(new Set([200])); + expect(new Set(shapes.map(({ body }) => body)).size).toBe(1); + expect(new Set(shapes.map(({ contentType }) => contentType)).size).toBe(1); + }); + + it.each([ + ['stop failure', 'stop'], + ['database close failure', 'close'], + ])( + 'never claims a legacy raw-email %s succeeded', + async (_label, failure) => { + const harness = routeHarness(); + if (failure === 'stop') { + harness.stopLegacyEmailUnsubscribe.mockRejectedValueOnce( + new Error('lookup unavailable') + ); + } else { + vi.mocked( + harness.database.close as () => Promise + ).mockRejectedValueOnce(new Error('connection reset')); + } + + const response = await harness.GET( + request('/api/unsubscribe?email=recipient%40example.com') as never + ); + const body = await response.text(); + + expect(response.status).toBe(503); + expect(body).not.toContain('recipient@example.com'); + expect(body).not.toContain('lookup unavailable'); + expect(harness.database.close).toHaveBeenCalledTimes(1); + } + ); + + it('rejects malformed legacy email syntax without loading environment or database state', async () => { + const harness = routeHarness(); + + const response = await harness.GET( + request('/api/unsubscribe?email=not-an-address') as never + ); + + expect(response.status).toBe(400); + expect(harness.loadEmailKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + expect(harness.stopLegacyEmailUnsubscribe).not.toHaveBeenCalled(); + }); + + it('rejects declared and streamed byte-overflow bodies before loading keys or database state', async () => { + const declared = routeHarness(); + const declaredResponse = await declared.POST( + request('/api/unsubscribe', { + method: 'POST', + headers: { + 'content-length': '2049', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'token=x', + }) as never + ); + + let chunk = 0; + const cancel = vi.fn(); + const stream = new ReadableStream({ + pull(controller) { + const value = [ + new TextEncoder().encode('é'.repeat(1024)), + new TextEncoder().encode('x'), + ][chunk++]; + if (value) controller.enqueue(value); + else controller.close(); + }, + cancel, + }); + const streamed = routeHarness(); + const streamedResponse = await streamed.POST( + new Request('https://threadplane.ai/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: stream, + duplex: 'half', + } as RequestInit) as never + ); + + expect([declaredResponse.status, streamedResponse.status]).toEqual([ + 400, 400, + ]); + expect(cancel).toHaveBeenCalledTimes(1); + for (const harness of [declared, streamed]) { + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + } + }); + + it('rejects malformed one-click bodies before database access', async () => { + const harness = routeHarness(); + const token = unsubscribeToken(); + const response = await harness.POST( + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'List-Unsubscribe=No', + }) as never + ); + + expect(response.status).toBe(400); + expect(harness.createDatabase).not.toHaveBeenCalled(); + }); + + it('rejects query-token POSTs unless they are exact form-encoded RFC one-click requests', async () => { + const token = unsubscribeToken(); + const requests = [ + request(`/api/unsubscribe?token=${token}`, { method: 'POST' }), + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: '', + }), + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'List-Unsubscribe=', + }), + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'List-Unsubscribe=One-Click&extra=1', + }), + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }).toString(), + }), + request(`/api/unsubscribe?token=${token}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }), + ]; + + const shapes = []; + for (const invalidRequest of requests) { + const harness = routeHarness(); + const response = await harness.POST(invalidRequest as never); + shapes.push({ body: await response.text(), status: response.status }); + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + expect(harness.stopContact).not.toHaveBeenCalled(); + } + + expect(new Set(shapes.map(({ status }) => status))).toEqual(new Set([400])); + expect(new Set(shapes.map(({ body }) => body)).size).toBe(1); + }); + + it('accepts human confirmation only as a token-only form body with an explicit content type', async () => { + const token = unsubscribeToken(); + const invalidRequests = [ + request('/api/unsubscribe', { + method: 'POST', + body: new URLSearchParams({ token }).toString(), + }), + request('/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }), + request('/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: new URLSearchParams({ token }).toString(), + }), + request('/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, extra: '1' }).toString(), + }), + ]; + + for (const invalidRequest of invalidRequests) { + const harness = routeHarness(); + const response = await harness.POST(invalidRequest as never); + expect(response.status).toBe(400); + expect(harness.loadTokenKeyring).not.toHaveBeenCalled(); + expect(harness.createDatabase).not.toHaveBeenCalled(); + } + + const validHarness = routeHarness(); + const valid = await validHarness.POST( + request('/api/unsubscribe', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: new URLSearchParams({ token }).toString(), + }) as never + ); + expect(valid.status).toBe(200); + expect(validHarness.stopContact).toHaveBeenCalledTimes(1); + }); + + it('escapes a verified token before echoing it into the confirmation form', async () => { + // The signature check is the real gate, so reaching this branch needs a + // token the verifier accepts. Mocking only the verifier proves the escaping + // itself rather than re-testing the signature. + vi.resetModules(); + vi.doMock('@threadplane-internal/growth', async (importOriginal) => ({ + ...(await importOriginal>()), + verifyGrowthActionToken: () => ({ + contactId, + purpose: 'unsubscribe', + issuedAt: now, + eventNonce: 'campaign-v1-step-1', + }), + })); + const { createUnsubscribeRoute } = await import('./route'); + const hostile = '">'; + const route = createUnsubscribeRoute({ + now: () => now, + loadTokenKeyring: () => tokenKeyring, + createDatabase: () => executor(), + stopLegacyEmailUnsubscribe: vi.fn(), + stopContact: vi.fn(), + }); + + const response = await route.GET( + request( + `/api/unsubscribe?token=${encodeURIComponent(hostile)}` + ) as never + ); + const body = await response.text(); + + expect(body).not.toContain(''); + expect(body).toContain('"><script>'); + vi.doUnmock('@threadplane-internal/growth'); + vi.resetModules(); + }); +}); diff --git a/apps/website/src/app/api/unsubscribe/route.ts b/apps/website/src/app/api/unsubscribe/route.ts index 13e1b1802..daaa371a3 100644 --- a/apps/website/src/app/api/unsubscribe/route.ts +++ b/apps/website/src/app/api/unsubscribe/route.ts @@ -1,32 +1,334 @@ -import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; +import { NextResponse, type NextRequest } from 'next/server'; -const UNSUB_FILE = path.join(process.cwd(), 'data', 'unsubscribed.ndjson'); +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + createDatabaseExecutor, + growthStopEventKey, + loadGrowthTokenKeyring, + normalizeEmail, + stopContact, + stopLegacyEmailUnsubscribe, + verifyGrowthActionToken, + type EmailHmacKeyring, + type GrowthTokenKeyring, + type SqlExecutor, + type StopContactInput, + type StopContactResult, +} from '@threadplane-internal/growth'; -export async function GET(req: NextRequest) { - const email = req.nextUrl.searchParams.get('email')?.trim().toLowerCase(); +import { readBoundedBody } from '../_internal/read-bounded-body'; - if (!email || !email.includes('@')) { - return new NextResponse('Invalid email', { status: 400 }); +const UNSUBSCRIBE_TOKEN_MAX_AGE_SECONDS = 5 * 365 * 24 * 60 * 60; +const POLICY_VERSION = 'growth-lifecycle-v1'; +const MAX_REQUEST_BODY_LENGTH = 2_048; +const FAILURE_BODY = 'Unable to process this request.'; + +interface UnsubscribeRouteDependencies { + now: () => Date; + loadTokenKeyring: () => GrowthTokenKeyring; + loadEmailKeyring: () => EmailHmacKeyring; + createDatabase: () => SqlExecutor; + stopLegacyEmailUnsubscribe: typeof stopLegacyEmailUnsubscribe; + stopContact: ( + executor: SqlExecutor, + input: StopContactInput + ) => Promise>; +} + +interface PostInput { + token: string; +} + +function escapeHtmlAttribute(value: string): string { + return value.replace(/[&<>"'`]/gu, (character) => { + switch (character) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return '`'; + } + }); +} + +function htmlResponse(title: string, message: string, form = ''): NextResponse { + return new NextResponse( + `${title}

${title}

${message}

${form}
`, + { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + }, + } + ); +} + +function failureResponse(): NextResponse { + return new NextResponse(FAILURE_BODY, { + status: 400, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); +} + +function confirmationResponse(token: string): NextResponse { + return htmlResponse( + 'Confirm email preference', + 'Submit this form to update the contact preference.', + `
` + ); +} + +function successResponse(): NextResponse { + return htmlResponse('Email preference updated', 'The request was recorded.'); +} + +/** + * A server fault is never reported as a recorded preference. The recipient gets + * a retryable answer with no internal detail, and the caller can try again. + */ +function retryableFailureResponse(): NextResponse { + return new NextResponse(FAILURE_BODY, { + status: 503, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + 'Retry-After': '30', + }, + }); +} + +function parseVersion(value: string | undefined): number { + if (!value || !/^\d+$/u.test(value)) { + throw new Error('Growth email HMAC active version is required'); } + const version = Number(value); + if (!Number.isSafeInteger(version) || version <= 0 || version > 32_767) { + throw new Error('Growth email HMAC active version is invalid'); + } + return version; +} + +function loadEmailHmacKeyring( + environment: NodeJS.ProcessEnv = process.env +): EmailHmacKeyring { + const version = parseVersion(environment['GROWTH_EMAIL_HMAC_ACTIVE_VERSION']); + const secret = environment['GROWTH_EMAIL_HMAC_ACTIVE_SECRET']; + if (!secret) throw new Error('Growth email HMAC active secret is required'); + + let previous: { version: number; secret: string }[] = []; + const rawPrevious = environment['GROWTH_EMAIL_HMAC_PREVIOUS_KEYS']; + if (rawPrevious) { + const parsed = JSON.parse(rawPrevious) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('Growth email HMAC previous keys must be an array'); + } + previous = parsed.map((candidate) => { + if ( + candidate === null || + typeof candidate !== 'object' || + Array.isArray(candidate) + ) { + throw new Error('Growth email HMAC previous key is invalid'); + } + const record = candidate as Record; + if ( + typeof record['version'] !== 'number' || + typeof record['secret'] !== 'string' + ) { + throw new Error('Growth email HMAC previous key is invalid'); + } + return { version: record['version'], secret: record['secret'] }; + }); + } + return { + active: { version, secret }, + ...(previous.length === 0 ? {} : { previous }), + }; +} - // Persist unsubscribe +function defaultDependencies(): UnsubscribeRouteDependencies { + return { + now: () => new Date(), + loadTokenKeyring: () => loadGrowthTokenKeyring(), + loadEmailKeyring: () => loadEmailHmacKeyring(), + createDatabase: () => createDatabaseExecutor(), + stopLegacyEmailUnsubscribe, + stopContact, + }; +} + +async function readPostInput(request: Request): Promise { + const url = new URL(request.url); + const contentType = request.headers.get('content-type'); + if ( + contentType?.split(';', 1)[0]?.trim().toLowerCase() !== + 'application/x-www-form-urlencoded' + ) { + return null; + } + + let bodyText: string; try { - fs.mkdirSync(path.dirname(UNSUB_FILE), { recursive: true }); - fs.appendFileSync(UNSUB_FILE, JSON.stringify({ email, ts: new Date().toISOString() }) + '\n', 'utf8'); - } catch (err) { - console.error('[unsubscribe] write failed:', err); + const boundedBody = await readBoundedBody(request, MAX_REQUEST_BODY_LENGTH); + if (boundedBody === null) return null; + bodyText = boundedBody; + } catch { + return null; } + const queryEntries = [...url.searchParams.entries()]; + const formEntries = [...new URLSearchParams(bodyText).entries()]; - // Return a simple confirmation page - return new NextResponse( - `Unsubscribed - -
-

You've been unsubscribed

-

You won't receive any more emails from us.

-
`, - { headers: { 'Content-Type': 'text/html' } } - ); + if (queryEntries.length > 0) { + const queryToken = queryEntries[0]?.[1].trim() ?? ''; + return queryEntries.length === 1 && + queryEntries[0]?.[0] === 'token' && + queryToken.length > 0 && + formEntries.length === 1 && + formEntries[0]?.[0] === 'List-Unsubscribe' && + formEntries[0]?.[1] === 'One-Click' + ? { token: queryToken } + : null; + } + + const bodyToken = formEntries[0]?.[1].trim() ?? ''; + return formEntries.length === 1 && + formEntries[0]?.[0] === 'token' && + bodyToken.length > 0 + ? { token: bodyToken } + : null; +} + +async function withDatabase( + dependencies: UnsubscribeRouteDependencies, + operation: (executor: SqlExecutor) => Promise +): Promise { + const executor = dependencies.createDatabase(); + try { + return await operation(executor); + } finally { + await executor.close?.(); + } } + +function signedStopInput( + payload: NonNullable>, + receivedAt: Date +): StopContactInput { + return { + contactId: payload.contactId, + reason: 'unsubscribe', + eventKey: growthStopEventKey(payload), + occurredAt: receivedAt, + source: 'signed_unsubscribe', + provenance: { + actor: 'recipient', + kind: 'one_click', + policyVersion: POLICY_VERSION, + }, + }; +} + +export function createUnsubscribeRoute( + overrides: Partial = {} +): { + GET: (request: NextRequest) => Promise; + POST: (request: NextRequest) => Promise; +} { + const dependencies = { ...defaultDependencies(), ...overrides }; + + return { + async GET(request) { + const url = new URL(request.url); + const token = url.searchParams.get('token')?.trim(); + const legacyEmail = url.searchParams.get('email'); + + if (token) { + let payload; + try { + payload = verifyGrowthActionToken(token, { + expectedPurpose: 'unsubscribe', + keyring: dependencies.loadTokenKeyring(), + now: dependencies.now(), + maxAgeSeconds: UNSUBSCRIBE_TOKEN_MAX_AGE_SECONDS, + }); + } catch { + return failureResponse(); + } + return payload ? confirmationResponse(token) : failureResponse(); + } + + if (legacyEmail === null) return failureResponse(); + try { + normalizeEmail(legacyEmail); + } catch { + return failureResponse(); + } + + try { + const occurredAt = dependencies.now(); + await withDatabase(dependencies, async (executor) => { + await dependencies.stopLegacyEmailUnsubscribe(executor, { + email: legacyEmail, + keyring: dependencies.loadEmailKeyring(), + occurredAt, + source: 'legacy_raw_email_unsubscribe', + policyVersion: POLICY_VERSION, + }); + }); + // Known and unknown addresses share this shape, so the link still does + // not reveal whether the address is on file. + return successResponse(); + } catch { + return retryableFailureResponse(); + } + }, + + async POST(request) { + const receivedAt = dependencies.now(); + const input = await readPostInput(request); + if (!input) return failureResponse(); + + let payload; + try { + payload = verifyGrowthActionToken(input.token, { + expectedPurpose: 'unsubscribe', + keyring: dependencies.loadTokenKeyring(), + now: receivedAt, + maxAgeSeconds: UNSUBSCRIBE_TOKEN_MAX_AGE_SECONDS, + }); + } catch { + return failureResponse(); + } + if (!payload) return failureResponse(); + + try { + await withDatabase(dependencies, (executor) => + dependencies.stopContact( + executor, + signedStopInput(payload, receivedAt) + ) + ); + return successResponse(); + } catch { + return retryableFailureResponse(); + } + }, + }; +} + +const route = createUnsubscribeRoute(); + +export const GET = route.GET; +export const POST = route.POST; diff --git a/apps/website/src/app/api/webhooks/resend/route.spec.ts b/apps/website/src/app/api/webhooks/resend/route.spec.ts new file mode 100644 index 000000000..be4f1010b --- /dev/null +++ b/apps/website/src/app/api/webhooks/resend/route.spec.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import type { SqlExecutor } from '@threadplane-internal/growth'; + +import { createResendWebhookRoute } from './route'; + +const rawPayload = JSON.stringify({ + type: 'email.delivered', + created_at: '2026-09-01T12:00:00.000Z', + data: { email_id: 'resend-email-1' }, +}); + +function request( + body = rawPayload, + headers: Record = {} +): Request { + return new Request('https://threadplane.ai/api/webhooks/resend', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'svix-id': 'msg_123', + 'svix-timestamp': '1788264000', + 'svix-signature': 'v1,signature', + ...headers, + }, + body, + }); +} + +function harness() { + const order: string[] = []; + const verify = vi.fn((input: unknown) => { + void input; + order.push('verify'); + return JSON.parse(rawPayload) as unknown; + }); + const database = { + close: vi.fn().mockResolvedValue(undefined), + } as unknown as SqlExecutor; + const createDatabase = vi.fn(() => { + order.push('database'); + return database; + }); + const processVerifiedResendWebhook = vi.fn().mockImplementation(() => { + order.push('process'); + return Promise.resolve({ applied: true }); + }); + const route = createResendWebhookRoute({ + loadWebhookSecret: () => 'whsec_test-secret', + verify, + createDatabase, + processVerifiedResendWebhook, + }); + return { + ...route, + order, + verify, + database, + createDatabase, + processVerifiedResendWebhook, + }; +} + +describe('/api/webhooks/resend', () => { + it('verifies the raw text and exact Svix headers before JSON/schema processing or DB creation', async () => { + const test = harness(); + const body = rawPayload.replace('email.delivered', 'email.sent'); + test.verify.mockImplementationOnce(() => { + test.order.push('verify'); + return { type: 'email.sent', data: { email_id: 'resend-email-1' } }; + }); + + const response = await test.POST(request(body) as never); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(test.verify).toHaveBeenCalledWith({ + payload: body, + headers: { + id: 'msg_123', + timestamp: '1788264000', + signature: 'v1,signature', + }, + webhookSecret: 'whsec_test-secret', + }); + expect(test.order).toEqual(['verify', 'database', 'process']); + expect(test.processVerifiedResendWebhook).toHaveBeenCalledWith( + test.database, + { + providerEventId: 'msg_123', + payload: { type: 'email.sent', data: { email_id: 'resend-email-1' } }, + } + ); + expect(test.database.close).toHaveBeenCalledTimes(1); + }); + + it.each(['svix-id', 'svix-timestamp', 'svix-signature'])( + 'rejects a missing %s before verification or database access', + async (missing) => { + const test = harness(); + const headers = { + 'svix-id': 'msg_123', + 'svix-timestamp': '1788264000', + 'svix-signature': 'v1,signature', + [missing]: '', + }; + const response = await test.POST(request(rawPayload, headers) as never); + expect(response.status).toBe(400); + expect(test.verify).not.toHaveBeenCalled(); + expect(test.createDatabase).not.toHaveBeenCalled(); + } + ); + + it('rejects a forged or stale signature without parsing or DB mutation', async () => { + const test = harness(); + test.verify.mockImplementationOnce(() => { + throw new Error('signature rejected'); + }); + const response = await test.POST(request() as never); + expect(response.status).toBe(400); + expect(test.createDatabase).not.toHaveBeenCalled(); + expect(test.processVerifiedResendWebhook).not.toHaveBeenCalled(); + }); + + it('fails closed when the webhook secret is missing', async () => { + const test = harness(); + const route = createResendWebhookRoute({ + loadWebhookSecret: () => '', + verify: test.verify, + createDatabase: test.createDatabase, + processVerifiedResendWebhook: test.processVerifiedResendWebhook, + }); + const response = await route.POST(request() as never); + expect(response.status).toBe(503); + expect(test.verify).not.toHaveBeenCalled(); + expect(test.createDatabase).not.toHaveBeenCalled(); + }); + + it('rejects declared and actual bodies over the hard limit before verification', async () => { + const declared = harness(); + const declaredResponse = await declared.POST( + request('{}', { 'content-length': '65537' }) as never + ); + expect(declaredResponse.status).toBe(413); + expect(declared.verify).not.toHaveBeenCalled(); + + const actual = harness(); + const actualResponse = await actual.POST( + request('x'.repeat(65_537)) as never + ); + expect(actualResponse.status).toBe(413); + expect(actual.verify).not.toHaveBeenCalled(); + }); + + it('cancels a chunked body as soon as it crosses the hard byte limit', async () => { + const test = harness(); + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(40_000)); + controller.enqueue(new Uint8Array(30_000)); + }, + cancel, + }); + const streamedRequest = new Request( + 'https://threadplane.ai/api/webhooks/resend', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'svix-id': 'msg_123', + 'svix-timestamp': '1788264000', + 'svix-signature': 'v1,signature', + }, + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' } + ); + + const response = await test.POST(streamedRequest as never); + + expect(response.status).toBe(413); + expect(cancel).toHaveBeenCalledTimes(1); + expect(test.verify).not.toHaveBeenCalled(); + expect(test.createDatabase).not.toHaveBeenCalled(); + }); + + it('closes the database when verified payload processing fails', async () => { + const test = harness(); + test.processVerifiedResendWebhook.mockRejectedValueOnce( + new Error('bad schema') + ); + const response = await test.POST(request() as never); + expect(response.status).toBe(400); + expect(test.database.close).toHaveBeenCalledTimes(1); + }); + + it('returns 503 only for a retryable tagged provider-ID attachment race', async () => { + const test = harness(); + test.processVerifiedResendWebhook + .mockResolvedValueOnce({ + applied: false, + reason: 'retryable_unmatched_job', + }) + .mockResolvedValueOnce({ + applied: true, + activityKind: 'delivery.delivered', + deliveryStatus: 'delivered', + }) + .mockResolvedValueOnce({ applied: false, reason: 'replay' }); + + const first = await test.POST(request() as never); + const second = await test.POST(request() as never); + const third = await test.POST(request() as never); + + expect([first.status, second.status, third.status]).toEqual([503, 200, 200]); + expect(test.processVerifiedResendWebhook).toHaveBeenCalledTimes(3); + expect(test.createDatabase).toHaveBeenCalledTimes(3); + expect(test.database.close).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/website/src/app/api/webhooks/resend/route.ts b/apps/website/src/app/api/webhooks/resend/route.ts new file mode 100644 index 000000000..435d07489 --- /dev/null +++ b/apps/website/src/app/api/webhooks/resend/route.ts @@ -0,0 +1,122 @@ +import { Resend } from 'resend'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + createDatabaseExecutor, + processVerifiedResendWebhook, + type SqlExecutor, +} from '@threadplane-internal/growth'; + +import { readBoundedBody } from '../../_internal/read-bounded-body'; + +const MAX_WEBHOOK_BODY_BYTES = 65_536; +const MAX_SVIX_HEADER_LENGTH = 2_048; + +interface VerifyWebhookInput { + payload: string; + headers: { id: string; timestamp: string; signature: string }; + webhookSecret: string; +} + +interface ResendWebhookRouteDependencies { + loadWebhookSecret: () => string; + verify: (input: VerifyWebhookInput) => unknown; + createDatabase: () => SqlExecutor; + processVerifiedResendWebhook: typeof processVerifiedResendWebhook; +} + +function response(status: number): Response { + return new Response( + status === 200 ? 'Accepted' : 'Unable to process request', + { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + } + ); +} + +function requiredHeader(request: Request, name: string): string | null { + const value = request.headers.get(name)?.trim() ?? ''; + if ( + value.length === 0 || + value.length > MAX_SVIX_HEADER_LENGTH || + /[\r\n\0]/u.test(value) + ) { + return null; + } + return value; +} + +function defaultDependencies(): ResendWebhookRouteDependencies { + return { + loadWebhookSecret: () => process.env['RESEND_WEBHOOK_SECRET'] ?? '', + verify: (input) => new Resend().webhooks.verify(input), + createDatabase: () => createDatabaseExecutor(), + processVerifiedResendWebhook, + }; +} + +export function createResendWebhookRoute( + dependencies: ResendWebhookRouteDependencies = defaultDependencies() +): { POST: (request: Request) => Promise } { + return { + async POST(request: Request): Promise { + let secret: string; + try { + secret = dependencies.loadWebhookSecret().trim(); + } catch { + return response(503); + } + if (secret.length === 0 || secret.length > 2_048) return response(503); + + const id = requiredHeader(request, 'svix-id'); + const timestamp = requiredHeader(request, 'svix-timestamp'); + const signature = requiredHeader(request, 'svix-signature'); + if (!id || !timestamp || !signature) return response(400); + + const rawPayload = await readBoundedBody(request, MAX_WEBHOOK_BODY_BYTES); + if (rawPayload === null) return response(413); + + let verifiedPayload: unknown; + try { + verifiedPayload = dependencies.verify({ + payload: rawPayload, + headers: { id, timestamp, signature }, + webhookSecret: secret, + }); + } catch { + return response(400); + } + + let database: SqlExecutor; + try { + database = dependencies.createDatabase(); + } catch { + return response(503); + } + try { + const result = await dependencies.processVerifiedResendWebhook(database, { + providerEventId: id, + payload: verifiedPayload, + }); + if ( + !result.applied && + result.reason === 'retryable_unmatched_job' + ) { + return response(503); + } + return response(200); + } catch { + return response(400); + } finally { + await database.close?.(); + } + }, + }; +} + +export const { POST } = createResendWebhookRoute(); diff --git a/apps/website/src/app/api/whitepaper-signup/route.spec.ts b/apps/website/src/app/api/whitepaper-signup/route.spec.ts new file mode 100644 index 000000000..fd1bbde3a --- /dev/null +++ b/apps/website/src/app/api/whitepaper-signup/route.spec.ts @@ -0,0 +1,360 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + acceptFormSubmission, + type ApproveContactFromFormInput, + type FormApprovalControlState, + type SqlExecutor, + type SqlTransaction, +} from '@threadplane-internal/growth'; + +vi.mock('server-only', () => ({})); + +const seam = vi.hoisted(() => ({ + accept: vi.fn(), + close: vi.fn(), + createDatabase: vi.fn(), + getPolicy: vi.fn(), + loadKeyring: vi.fn(), + now: vi.fn(), + nudge: vi.fn(), +})); + +vi.mock('../../../lib/growth/form-route', async (importOriginal) => ({ + ...(await importOriginal()), + defaultGrowthFormRouteDependencies: () => ({ + accept: seam.accept, + createDatabase: seam.createDatabase, + getPolicy: seam.getPolicy, + loadKeyring: seam.loadKeyring, + now: seam.now, + nudge: seam.nudge, + }), +})); + +import type { PublicFormPolicy } from '../../../lib/growth/form-policy'; +import { POST } from './route'; + +const policy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'Contact disclosure', + newsletter: 'Newsletter disclosure', + whitepaper: 'Whitepaper disclosure', + }, +}; +const submissionId = '20000000-0000-4000-8000-000000000002'; +const acquisitionSessionId = '30000000-0000-4000-8000-000000000003'; +const occurredAt = new Date('2026-09-01T18:00:00.000Z'); +const keyring = { + active: { + version: 1, + secret: 'route-test-secret-that-is-at-least-32-bytes-long', + }, +}; + +function request(body: BodyInit | unknown, contentType = 'application/json'): Request { + return new Request('https://threadplane.ai/api/whitepaper-signup', { + method: 'POST', + headers: contentType ? { 'content-type': contentType } : undefined, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +function validBody(overrides: Record = {}) { + return { + submission_id: submissionId, + policy_version: policy.version, + acquisition_session_id: acquisitionSessionId, + email: ' Reader@Acme.COM ', + name: ' Reader ', + paper: 'chat', + ...overrides, + }; +} + +function expectCommittedBeforeNudge(): void { + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.nudge).toHaveBeenCalledOnce(); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.close.mock.invocationCallOrder[0] as number + ); + expect(seam.close.mock.invocationCallOrder[0]).toBeLessThan( + seam.nudge.mock.invocationCallOrder[0] as number + ); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.nudge.mock.invocationCallOrder[0] as number + ); +} + +function safeError(responseBody: unknown): void { + const serialized = JSON.stringify(responseBody); + expect(serialized).not.toContain('Reader@Acme.COM'); + expect(serialized).not.toContain('route-test-secret'); + expect(serialized).not.toContain('database'); +} + +beforeEach(() => { + vi.clearAllMocks(); + seam.getPolicy.mockReturnValue(policy); + seam.accept.mockResolvedValue({ + accepted: true, + approved: true, + contactId: '10000000-0000-4000-8000-000000000001', + submissionId, + }); + seam.close.mockResolvedValue(undefined); + seam.createDatabase.mockReturnValue({ close: seam.close }); + seam.loadKeyring.mockReturnValue(keyring); + seam.now.mockReturnValue(occurredAt); + seam.nudge.mockResolvedValue(undefined); +}); + +describe('/api/whitepaper-signup growth_v1', () => { + it('commits the disclosed whitepaper submission, closes Neon, then nudges', async () => { + const response = await POST(request(validBody())); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(seam.accept).toHaveBeenCalledWith( + expect.anything(), + { + submissionId, + email: 'reader@acme.com', + displayName: 'Reader', + form: { kind: 'whitepaper', paper: 'chat' }, + source: 'website', + sourceForm: 'whitepaper', + noticeText: policy.disclosures.whitepaper, + noticeVersion: `${policy.version}.whitepaper`, + policyVersion: policy.version, + acquisitionSessionId, + occurredAt, + keyring, + } + ); + expect(seam.nudge).toHaveBeenCalledWith({ submissionId }); + expect(JSON.stringify(seam.nudge.mock.calls)).not.toContain( + 'reader@acme.com' + ); + expectCommittedBeforeNudge(); + }); + + it.each([undefined, 'growth_v1.stale'])( + 'returns the current safe policy for missing or stale version %s', + async (policyVersion) => { + const body = validBody(); + if (policyVersion === undefined) delete body.policy_version; + else body.policy_version = policyVersion; + + const response = await POST(request(body)); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'This form changed. Please retry.', + policy_version: policy.version, + retryable: true, + }); + expect(response.headers.get('retry-after')).toBe('0'); + expect(seam.createDatabase).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['malformed JSON', request('{')], + ['non-object JSON', request('[]')], + ['missing content type', request(JSON.stringify(validBody()), '')], + ['invalid content type', request(JSON.stringify(validBody()), 'text/plain')], + ['oversized body', request(JSON.stringify({ padding: 'x'.repeat(20_000) }))], + ])('rejects %s before reading policy or durable state', async (_label, input) => { + const response = await POST(input); + + expect(response.status).toBe(400); + expect(seam.getPolicy).not.toHaveBeenCalled(); + expect(seam.createDatabase).not.toHaveBeenCalled(); + }); + + it.each([ + ['invalid submission UUID', { submission_id: 'not-a-uuid' }], + ['invalid acquisition UUID', { acquisition_session_id: 'not-a-uuid' }], + ['name too long', { name: 'n'.repeat(201) }], + ['name wrong type', { name: { nested: true } }], + ['paper wrong type', { paper: ['chat'] }], + ['paper unsupported', { paper: 'unknown' }], + ])('rejects %s before opening Neon', async (_label, overrides) => { + const response = await POST(request(validBody(overrides))); + + expect(response.status).toBe(400); + expect(seam.createDatabase).not.toHaveBeenCalled(); + expect(seam.accept).not.toHaveBeenCalled(); + }); + + it.each([ + 'a@b', + 'a@@example.com', + 'Reader ', + 'reader @example.com', + `${'a'.repeat(250)}@example.com`, + ])('rejects an invalid email without echoing or logging it', async (email) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const response = await POST(request(validBody({ email }))); + const responseBody = await response.json(); + + expect(response.status).toBe(400); + expect(JSON.stringify(responseBody)).not.toContain(email); + expect(consoleError).not.toHaveBeenCalled(); + expect(seam.accept).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it.each(['database construction', 'keyring setup'])( + 'fails closed when %s fails', + async (failure) => { + if (failure === 'database construction') { + seam.createDatabase.mockImplementation(() => { + throw new Error('sensitive database URL'); + }); + } else { + seam.loadKeyring.mockImplementation(() => { + throw new Error('sensitive key'); + }); + } + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).not.toHaveBeenCalled(); + expect(seam.nudge).not.toHaveBeenCalled(); + } + ); + + it('closes Neon and fails closed when the acceptance transaction fails', async () => { + seam.accept.mockRejectedValue(new Error('sensitive transaction response')); + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.accept.mock.invocationCallOrder[0]).toBeLessThan( + seam.close.mock.invocationCallOrder[0] as number + ); + expect(seam.nudge).not.toHaveBeenCalled(); + }); + + it('fails closed without nudging when Neon cannot close', async () => { + seam.close.mockRejectedValue(new Error('sensitive close failure')); + + const response = await POST(request(validBody())); + const responseBody = await response.json(); + + expect(response.status).toBe(503); + safeError(responseBody); + expect(seam.accept).toHaveBeenCalledOnce(); + expect(seam.close).toHaveBeenCalledOnce(); + expect(seam.nudge).not.toHaveBeenCalled(); + }); + + it('keeps committed acceptance successful when the lifecycle nudge fails', async () => { + seam.nudge.mockRejectedValue(new Error('sensitive lifecycle URL')); + + const response = await POST(request(validBody())); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expectCommittedBeforeNudge(); + }); + + it('replays one submission UUID without duplicate activity or logical jobs', async () => { + const acceptedEvents = new Set(); + const jobKeys = new Set(); + let activityInsertions = 0; + let jobInsertions = 0; + const transaction: SqlTransaction = { + async execute(sql, parameters = []) { + if (!sql.includes('growth:enqueue-form-jobs')) return { rows: [] }; + const replaySubmissionId = String(parameters[2]); + const kinds = parameters[3] === true + ? ['fulfill', 'enrich', 'notify'] + : ['fulfill']; + for (const kind of kinds) { + const key = `form:${replaySubmissionId}:${kind}`; + if (!jobKeys.has(key)) { + jobKeys.add(key); + jobInsertions += 1; + } + } + return { + rows: kinds.map((kind) => ({ + idempotency_key: `form:${replaySubmissionId}:${kind}`, + })), + }; + }, + }; + const database: SqlExecutor = { + execute: transaction.execute, + transaction: async (operation) => operation(transaction), + close: seam.close, + }; + const approveContact = vi.fn( + async ( + _transaction: SqlTransaction, + input: ApproveContactFromFormInput + ): Promise => { + if (!acceptedEvents.has(input.eventKey)) { + acceptedEvents.add(input.eventKey); + activityInsertions += 1; + } + return { + contactId: '10000000-0000-4000-8000-000000000001', + authorization: 'approved', + canSend: true, + formApprovalGranted: true, + outreachApprovedAt: occurredAt, + latestHardStop: null, + deletedAt: null, + updatedAt: input.occurredAt, + }; + } + ); + seam.createDatabase.mockReturnValue(database); + seam.accept.mockImplementation((executor, input) => + acceptFormSubmission(executor, input, { approveContact }) + ); + + const firstResponse = await POST(request(validBody())); + expect(firstResponse.status).toBe(200); + expectCommittedBeforeNudge(); + + vi.clearAllMocks(); + seam.getPolicy.mockReturnValue(policy); + seam.createDatabase.mockReturnValue(database); + seam.loadKeyring.mockReturnValue(keyring); + seam.now.mockReturnValue(new Date('2026-09-01T18:05:00.000Z')); + seam.nudge.mockResolvedValue(undefined); + seam.accept.mockImplementation((executor, input) => + acceptFormSubmission(executor, input, { approveContact }) + ); + + const replayResponse = await POST(request(validBody())); + expect(replayResponse.status).toBe(200); + expectCommittedBeforeNudge(); + expect(activityInsertions).toBe(1); + expect(jobInsertions).toBe(3); + expect(jobKeys).toEqual( + new Set([ + `form:${submissionId}:fulfill`, + `form:${submissionId}:enrich`, + `form:${submissionId}:notify`, + ]) + ); + }); +}); diff --git a/apps/website/src/app/api/whitepaper-signup/route.ts b/apps/website/src/app/api/whitepaper-signup/route.ts index 1df09c269..cedbad354 100644 --- a/apps/website/src/app/api/whitepaper-signup/route.ts +++ b/apps/website/src/app/api/whitepaper-signup/route.ts @@ -1,80 +1,125 @@ -import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import { sendEmail, FROM, addToAudience } from '../../../../lib/resend'; -import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops'; -import { scheduleWhitepaperDrip, type PaperId } from '../../../../lib/drip'; -import { whitepaperDownloadHtml } from '../../../../emails/whitepaper-download'; -import { angularDownloadHtml } from '../../../../emails/angular-download'; -import { renderDownloadHtml } from '../../../../emails/render-download'; -import { chatDownloadHtml } from '../../../../emails/chat-download'; -import { captureWhitepaperConversion } from '../../../lib/analytics/server'; -import { getSourcePage } from '@threadplane/telemetry/shared'; +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { normalizeRecipientEmail } from '@threadplane-internal/growth'; -const SIGNUPS_FILE = path.join(process.cwd(), 'data', 'whitepaper-signups.ndjson'); +import { matchesSubmittedFormPolicy } from '../../../lib/growth/form-policy'; +import { + defaultGrowthFormRouteDependencies, + jsonResponse, + readBoundedJsonObject, + stalePolicyResponse, + strictText, + validGrowthFormIdentities, + type GrowthFormRouteDependencies, +} from '../../../lib/growth/form-route'; -const VALID_PAPERS: PaperId[] = ['overview', 'angular', 'render', 'chat']; +const MAX_BODY_BYTES = 16_384; -const DOWNLOAD_EMAILS: Record string> = { - overview: whitepaperDownloadHtml, - angular: angularDownloadHtml, - render: renderDownloadHtml, - chat: chatDownloadHtml, -}; +type PaperId = 'overview' | 'angular' | 'render' | 'chat'; -const DOWNLOAD_SUBJECTS: Record = { - overview: 'Your Enterprise Agent UI Guide for Angular', - angular: 'Your Enterprise Guide to Agent UI in Angular', - render: 'Your Enterprise Guide to Generative UI', - chat: 'Your Enterprise Guide to Agent Chat Interfaces', -}; +const VALID_PAPERS: readonly PaperId[] = [ + 'overview', + 'angular', + 'render', + 'chat', +]; -export async function POST(req: NextRequest) { - let body: { name?: string; email?: string; paper?: string }; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } +export function createWhitepaperSignupRoute( + dependencies: GrowthFormRouteDependencies = defaultGrowthFormRouteDependencies() +): { POST: (request: Request) => Promise } { + return { + async POST(request: Request): Promise { + const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); + if (!body) return jsonResponse({ error: 'Invalid JSON' }, 400); - const name = (body.name || '').trim().slice(0, 200); - const email = (body.email || '').trim().slice(0, 320); - const paper = (VALID_PAPERS.includes(body.paper as PaperId) ? body.paper : 'overview') as PaperId; - const sourcePage = getSourcePage(req.headers.get('referer')); + let policy; + try { + policy = dependencies.getPolicy(); + } catch { + return jsonResponse({ error: 'Unable to accept request' }, 503); + } - if (!email || !email.includes('@')) { - return NextResponse.json({ error: 'Valid email required' }, { status: 400 }); - } + let submissionId; + let acquisitionSessionId; + let name; + let email; + let submittedPaper; + try { + const policyVersion = strictText(body, 'policy_version', 100); + if (!matchesSubmittedFormPolicy(policy, policyVersion || undefined)) { + return stalePolicyResponse(policy); + } + submissionId = strictText(body, 'submission_id', 36); + acquisitionSessionId = strictText(body, 'acquisition_session_id', 36); + name = strictText(body, 'name', 200); + email = strictText(body, 'email', 254); + submittedPaper = strictText(body, 'paper', 20) || 'overview'; + } catch { + return jsonResponse({ error: 'Invalid form submission' }, 400); + } + if (!validGrowthFormIdentities(submissionId, acquisitionSessionId)) { + return jsonResponse({ error: 'Invalid submission' }, 400); + } + if (!VALID_PAPERS.includes(submittedPaper as PaperId)) { + return jsonResponse({ error: 'Invalid paper' }, 400); + } - // Persist signup to NDJSON (always, even if email fails) - const entry = JSON.stringify({ name, email, paper, ts: new Date().toISOString() }) + '\n'; - try { - fs.mkdirSync(path.dirname(SIGNUPS_FILE), { recursive: true }); - fs.appendFileSync(SIGNUPS_FILE, entry, 'utf8'); - } catch (err) { - console.error('Failed to write signup:', err); - } + let normalizedEmail; + try { + normalizedEmail = normalizeRecipientEmail(email); + } catch { + return jsonResponse({ error: 'Valid email required' }, 400); + } - // Send download confirmation + schedule drip + sync contacts (best-effort) - try { - const downloadHtml = DOWNLOAD_EMAILS[paper](name || undefined); - await Promise.all([ - sendEmail({ - from: FROM, - to: email, - subject: DOWNLOAD_SUBJECTS[paper], - html: downloadHtml, - }), - scheduleWhitepaperDrip(email, paper), - addToAudience(email, name || undefined), - loopsUpsertContact({ email, firstName: name || undefined, source: `whitepaper-${paper}` }), - loopsSendEvent({ email, eventName: 'whitepaper_downloaded', properties: { paper } }), - ]); - } catch (err) { - console.error('[whitepaper-signup] email pipeline failed:', err); - } + let database; + let keyring; + try { + keyring = dependencies.loadKeyring(); + database = dependencies.createDatabase(); + } catch { + return jsonResponse({ error: 'Unable to accept request' }, 503); + } - await captureWhitepaperConversion({ email, paper, sourcePage }); + let accepted = false; + try { + await dependencies.accept(database, { + submissionId, + email: normalizedEmail, + displayName: name || undefined, + form: { kind: 'whitepaper', paper: submittedPaper as PaperId }, + source: 'website', + sourceForm: 'whitepaper', + noticeText: policy.disclosures.whitepaper, + noticeVersion: `${policy.version}.whitepaper`, + policyVersion: policy.version, + acquisitionSessionId: acquisitionSessionId || undefined, + occurredAt: dependencies.now(), + keyring, + }); + accepted = true; + } catch { + // The response below reports the failure without echoing provider detail. + } - return NextResponse.json({ ok: true }); + try { + await database.close?.(); + } catch { + return unableToAccept(); + } + if (!accepted) return unableToAccept(); + + // The durable jobs remain available to the scheduled dispatcher. + await dependencies.nudge({ submissionId }).catch(() => undefined); + return jsonResponse({ ok: true }); + }, + }; +} + +function unableToAccept(): Response { + return jsonResponse( + { error: 'Unable to accept request', retryable: true }, + 503 + ); } + +export const { POST } = createWhitepaperSignupRoute(); diff --git a/apps/website/src/app/chat/page.spec.tsx b/apps/website/src/app/chat/page.spec.tsx index 0832e7a6c..b2cbdd729 100644 --- a/apps/website/src/app/chat/page.spec.tsx +++ b/apps/website/src/app/chat/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import ChatPage from './page'; vi.mock('../../lib/analytics/client', () => ({ diff --git a/apps/website/src/app/chat/page.tsx b/apps/website/src/app/chat/page.tsx index f057c7de8..54569793f 100644 --- a/apps/website/src/app/chat/page.tsx +++ b/apps/website/src/app/chat/page.tsx @@ -11,6 +11,7 @@ import { ChatLandingCodeShowcase } from '../../components/landing/chat-landing/C import { createPageMetadata } from '../../lib/site-metadata'; import { SECTION_MEDIA } from '../../lib/section-media'; import { buildPanes } from '../../lib/build-panes'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: '@threadplane/chat — Batteries-Included Agent Chat for Angular', @@ -20,6 +21,7 @@ export const metadata = createPageMetadata({ }); export default async function ChatPage() { + const formPolicy = getFormPolicy(); const panes = await buildPanes(SECTION_MEDIA.libChat, SECTION_MEDIA.libChat.video?.url ?? ''); return ( @@ -82,7 +84,7 @@ export default async function ChatPage() { visual={} /> - + ); diff --git a/apps/website/src/app/contact/page.tsx b/apps/website/src/app/contact/page.tsx index 35dceb2df..f50fd2535 100644 --- a/apps/website/src/app/contact/page.tsx +++ b/apps/website/src/app/contact/page.tsx @@ -8,6 +8,7 @@ import { GitHubStarsPill } from '../../components/contact/GitHubStarsPill'; import { SlaCard } from '../../components/contact/SlaCard'; import { AltChannelRow } from '../../components/contact/AltChannelRow'; import { createPageMetadata } from '../../lib/site-metadata'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: 'Talk to an engineer — Threadplane', @@ -17,6 +18,7 @@ export const metadata = createPageMetadata({ }); export default function ContactPage() { + const formPolicy = getFormPolicy(); return (
@@ -32,7 +34,7 @@ export default function ContactPage() { - +
diff --git a/apps/website/src/app/langgraph/page.spec.tsx b/apps/website/src/app/langgraph/page.spec.tsx index 6c5d27e0d..ba3830dd1 100644 --- a/apps/website/src/app/langgraph/page.spec.tsx +++ b/apps/website/src/app/langgraph/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import LangGraphPage from './page'; vi.mock('../../lib/analytics/client', () => ({ diff --git a/apps/website/src/app/langgraph/page.tsx b/apps/website/src/app/langgraph/page.tsx index 199ebd3fe..2eed328db 100644 --- a/apps/website/src/app/langgraph/page.tsx +++ b/apps/website/src/app/langgraph/page.tsx @@ -13,6 +13,7 @@ import { StackDiagramSection } from '../../components/landing/StackDiagramSectio import { createPageMetadata, SHORT_POSITIONING_DESCRIPTION } from '../../lib/site-metadata'; import { SECTION_MEDIA } from '../../lib/section-media'; import { buildPanes } from '../../lib/build-panes'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: '@threadplane/langgraph — Threadplane', @@ -22,6 +23,7 @@ export const metadata = createPageMetadata({ }); export default async function LangGraphPage() { + const formPolicy = getFormPolicy(); const panes = await buildPanes(SECTION_MEDIA.libLanggraph, SECTION_MEDIA.libLanggraph.video?.url ?? ''); return ( @@ -93,7 +95,7 @@ export default async function LangGraphPage() { visual={} /> - + ); diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index 86f698e87..ee224d07b 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -16,6 +16,7 @@ import { SITE_NAME, SITE_ORIGIN, } from '../lib/site-metadata'; +import { getFormPolicy } from '../lib/growth/form-policy'; const garamond = EB_Garamond({ subsets: ['latin'], @@ -62,6 +63,7 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { + const formPolicy = getFormPolicy(); return ( {children} - +
- +
diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 4766d0f67..a2d0a76b9 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -18,6 +18,7 @@ import { RecentArticles } from '../components/landing/RecentArticles'; import { Section } from '../components/ui/Section'; import { Container } from '../components/ui/Container'; import { createPageMetadata, LONG_SUBHEAD, PRIMARY_TAGLINE } from '../lib/site-metadata'; +import { getFormPolicy } from '../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: PRIMARY_TAGLINE, @@ -27,6 +28,7 @@ export const metadata = createPageMetadata({ }); export default async function HomePage() { + const formPolicy = getFormPolicy(); const [streamPanes, renderPanes, shipPanes, approvePanes] = await Promise.all( (['stream', 'render', 'ship', 'approve'] as const).map((key) => buildPanes(SECTION_MEDIA[key], SECTION_MEDIA[key].video?.url ?? ''), @@ -134,7 +136,7 @@ export default async function HomePage() { /> - + diff --git a/apps/website/src/app/pilot-to-prod/page.spec.tsx b/apps/website/src/app/pilot-to-prod/page.spec.tsx index cd10532c2..30e835125 100644 --- a/apps/website/src/app/pilot-to-prod/page.spec.tsx +++ b/apps/website/src/app/pilot-to-prod/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import PilotToProdPage from './page'; vi.mock('../../lib/analytics/client', () => ({ diff --git a/apps/website/src/app/pilot-to-prod/page.tsx b/apps/website/src/app/pilot-to-prod/page.tsx index da1f9862e..de234bef1 100644 --- a/apps/website/src/app/pilot-to-prod/page.tsx +++ b/apps/website/src/app/pilot-to-prod/page.tsx @@ -11,6 +11,7 @@ import { FinalCTA } from '../../components/landing/FinalCTA'; import { DiagramSection } from '../../components/landing/DiagramSection'; import { PilotJourney } from '../../components/docs/diagrams'; import { createPageMetadata } from '../../lib/site-metadata'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: 'Pilot to Production — Threadplane', @@ -20,6 +21,7 @@ export const metadata = createPageMetadata({ }); export default function PilotToProdPage() { + const formPolicy = getFormPolicy(); return ( <> {/* Hero */} @@ -181,7 +183,7 @@ export default function PilotToProdPage() {
- + {/* Contact anchor */} diff --git a/apps/website/src/app/pricing/page.spec.tsx b/apps/website/src/app/pricing/page.spec.tsx index fc3df48fc..d122d11df 100644 --- a/apps/website/src/app/pricing/page.spec.tsx +++ b/apps/website/src/app/pricing/page.spec.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import PricingPage, { metadata } from './page'; vi.mock('../../components/pricing/LeadForm', () => ({ LeadForm: () => null })); diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx index e1fa3131d..109aec799 100644 --- a/apps/website/src/app/pricing/page.tsx +++ b/apps/website/src/app/pricing/page.tsx @@ -8,6 +8,7 @@ import { LeadForm } from '../../components/pricing/LeadForm'; import { FinalCTA } from '../../components/landing/FinalCTA'; import { createPageMetadata } from '../../lib/site-metadata'; import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: 'Pricing — Threadplane', @@ -18,6 +19,7 @@ export const metadata = createPageMetadata({ }); export default function PricingPage() { + const formPolicy = getFormPolicy(); return ( <>
@@ -61,7 +63,7 @@ export default function PricingPage() { - + ); diff --git a/apps/website/src/app/render/page.spec.tsx b/apps/website/src/app/render/page.spec.tsx index ee92b3141..6884735f8 100644 --- a/apps/website/src/app/render/page.spec.tsx +++ b/apps/website/src/app/render/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import RenderPage from './page'; vi.mock('../../lib/analytics/client', () => ({ diff --git a/apps/website/src/app/render/page.tsx b/apps/website/src/app/render/page.tsx index a276ce52e..89002dd0d 100644 --- a/apps/website/src/app/render/page.tsx +++ b/apps/website/src/app/render/page.tsx @@ -13,6 +13,7 @@ import { RenderCodeShowcase } from '../../components/landing/render/RenderCodeSh import { createPageMetadata } from '../../lib/site-metadata'; import { SECTION_MEDIA } from '../../lib/section-media'; import { buildPanes } from '../../lib/build-panes'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: '@threadplane/render — Generative UI for Angular', @@ -22,6 +23,7 @@ export const metadata = createPageMetadata({ }); export default async function RenderPage() { + const formPolicy = getFormPolicy(); const panes = await buildPanes(SECTION_MEDIA.libRender, SECTION_MEDIA.libRender.video?.url ?? ''); return ( @@ -91,7 +93,7 @@ export default async function RenderPage() { visual={} /> - + ); diff --git a/apps/website/src/app/solutions/[slug]/page.spec.tsx b/apps/website/src/app/solutions/[slug]/page.spec.tsx index ba9114766..c926ce8f8 100644 --- a/apps/website/src/app/solutions/[slug]/page.spec.tsx +++ b/apps/website/src/app/solutions/[slug]/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import SolutionPage from './page'; import { getAllSolutionSlugs } from '../../../lib/solutions-data'; diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx index 508f45e00..e50e0ecf1 100644 --- a/apps/website/src/app/solutions/[slug]/page.tsx +++ b/apps/website/src/app/solutions/[slug]/page.tsx @@ -17,6 +17,7 @@ import { Pill } from '../../../components/ui/Pill'; import { Card } from '../../../components/ui/Card'; import { WhitePaperBlock } from '../../../components/landing/WhitePaperBlock'; import { FinalCTA } from '../../../components/landing/FinalCTA'; +import { getFormPolicy } from '../../../lib/growth/form-policy'; interface PageProps { params: Promise<{ slug: string }>; @@ -166,6 +167,7 @@ function Capabilities({ items }: { items: ProofPoint[] }) { } export default async function SolutionPage({ params }: PageProps) { + const formPolicy = getFormPolicy(); const { slug } = await params; const solution = getSolutionBySlug(slug); if (!solution) notFound(); @@ -200,7 +202,7 @@ export default async function SolutionPage({ params }: PageProps) { {solution.demo && } - + {/* Hero */} @@ -74,7 +76,7 @@ export default function SolutionsIndexPage() {
- + ); diff --git a/apps/website/src/components/contact/ContactForm.spec.tsx b/apps/website/src/components/contact/ContactForm.spec.tsx index bcdc82ca2..663993c18 100644 --- a/apps/website/src/components/contact/ContactForm.spec.tsx +++ b/apps/website/src/components/contact/ContactForm.spec.tsx @@ -1,86 +1,109 @@ // SPDX-License-Identifier: MIT // @vitest-environment jsdom import React from 'react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; const trackMock = vi.hoisted(() => vi.fn()); -const fetchMock = vi.hoisted(() => vi.fn()); vi.mock('../../lib/analytics/client', () => ({ track: trackMock })); -vi.mock('next/navigation', () => ({ - useSearchParams: () => new URLSearchParams('?source=home_hero&track=enterprise'), -})); -vi.mock('../ui/Button', () => ({ - Button: ({ - children, - type, - disabled, - onClick, - }: { - children: React.ReactNode; - type?: 'submit' | 'button' | 'reset'; - disabled?: boolean; - onClick?: () => void; - }) => ( - - ), -})); + +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { ContactForm } from './ContactForm'; + +const formPolicy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: + 'By sending, you agree Brian may follow up by email about your request.', + newsletter: 'Newsletter disclosure', + whitepaper: 'Whitepaper disclosure', + }, +}; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function fill(fields: { + email: string; + name?: string; + company?: string; + message?: string; +}): void { + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: fields.email }, + }); + if (fields.name !== undefined) { + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: fields.name }, + }); + } + if (fields.company !== undefined) { + fireEvent.change(screen.getByLabelText(/company/i), { + target: { value: fields.company }, + }); + } + if (fields.message !== undefined) { + fireEvent.change(screen.getByLabelText(/message/i), { + target: { value: fields.message }, + }); + } +} + +function send(): void { + fireEvent.click(screen.getByRole('button', { name: /^send$/i })); +} + +function sentBody(fetchMock: ReturnType, call: number) { + return JSON.parse(fetchMock.mock.calls[call][1].body as string); +} beforeEach(() => { trackMock.mockClear(); - fetchMock.mockReset(); - vi.stubGlobal('fetch', fetchMock); - Object.defineProperty(document, 'referrer', { - value: 'https://threadplane.ai/pricing', - configurable: true, - }); + sessionStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe('ContactForm', () => { it('submits with email only and fires lead_form_submit + lead_form_success', async () => { - fetchMock.mockResolvedValue({ ok: true }); - const { ContactForm } = await import('./ContactForm'); - render(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); - fireEvent.change(screen.getByLabelText(/email/i), { - target: { value: 'jane@acme.com' }, - }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); + fill({ email: 'jane@acme.com' }); + send(); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.email).toBe('jane@acme.com'); - expect(body.source_page).toBe('home_hero'); - expect(body.track).toBe('enterprise'); - expect(body.referrer_host).toBe('threadplane.ai'); - + expect(sentBody(fetchMock, 0).email).toBe('jane@acme.com'); expect(trackMock).toHaveBeenCalledWith( 'marketing:lead_form_submit', - expect.objectContaining({ surface: 'contact' }), + expect.objectContaining({ surface: 'contact' }) ); expect(trackMock).toHaveBeenCalledWith( 'marketing:lead_form_success', - expect.objectContaining({ surface: 'contact' }), + expect.objectContaining({ surface: 'contact' }) ); }); it('submits with all optional fields populated', async () => { - fetchMock.mockResolvedValue({ ok: true }); - const { ContactForm } = await import('./ContactForm'); - render(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'jane@acme.com' } }); - fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Jane Smith' } }); - fireEvent.change(screen.getByLabelText(/company/i), { target: { value: 'Acme' } }); - fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hi' } }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); + fill({ + email: 'jane@acme.com', + name: 'Jane Smith', + company: 'Acme', + message: 'Hi', + }); + send(); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body).toMatchObject({ + expect(sentBody(fetchMock, 0)).toMatchObject({ email: 'jane@acme.com', name: 'Jane Smith', company: 'Acme', @@ -89,18 +112,136 @@ describe('ContactForm', () => { }); it('fires lead_form_fail on non-2xx', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 500 }); - const { ContactForm } = await import('./ContactForm'); - render(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 500 }) + ); + render(); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'jane@acme.com' } }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); + fill({ email: 'jane@acme.com' }); + send(); await waitFor(() => expect(trackMock).toHaveBeenCalledWith( 'marketing:lead_form_fail', - expect.objectContaining({ surface: 'contact' }), - ), + expect.objectContaining({ surface: 'contact' }) + ) + ); + }); +}); + +describe('ContactForm growth policy', () => { + it('renders the contact disclosure and describes the submit control', () => { + render(); + + const disclosure = screen.getByText(formPolicy.disclosures.contact); + const button = screen.getByRole('button', { name: /^send$/i }); + + expect(disclosure.id).toBeTruthy(); + expect(button.getAttribute('aria-describedby')).toBe(disclosure.id); + }); + + it('submits the immutable growth envelope declaring the contact form kind', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com', name: 'Reader', message: 'Hello' }); + send(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock.mock.calls[0][0]).toBe('/api/leads'); + const body = sentBody(fetchMock, 0); + expect(body.form_kind).toBe('contact'); + expect(body.email).toBe('reader@example.com'); + expect(body.policy_version).toBe(formPolicy.version); + expect(body.submission_id).toMatch(UUID_V4); + expect(body.acquisition_session_id).toMatch(UUID_V4); + }); + + it('omits blank optional fields rather than sending undefined facts', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + const keys = Object.keys(sentBody(fetchMock, 0)); + expect(keys).not.toContain('name'); + expect(keys).not.toContain('company'); + expect(keys).not.toContain('message'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('never sends legacy attribution facts the durable boundary ignores', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + const keys = Object.keys(sentBody(fetchMock, 0)); + for (const legacy of ['source_page', 'track', 'cta_id', 'referrer_host']) { + expect(keys).not.toContain(legacy); + } + }); + + it('reuses the submission UUID when an uncertain attempt is retried', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('mints a new submission UUID when the sender changes the facts', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + fill({ email: 'reader@example.com', message: 'One more thing' }); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).not.toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('requires a page refresh after a policy mismatch and reports no success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 409 }) + ); + render(); + + fill({ email: 'reader@example.com' }); + send(); + + await waitFor(() => + expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy() ); + expect(screen.queryByText(/we'll be in touch/i)).toBeNull(); }); }); diff --git a/apps/website/src/components/contact/ContactForm.tsx b/apps/website/src/components/contact/ContactForm.tsx index f512ae1bd..bbf5687c8 100644 --- a/apps/website/src/components/contact/ContactForm.tsx +++ b/apps/website/src/components/contact/ContactForm.tsx @@ -1,35 +1,31 @@ // SPDX-License-Identifier: MIT 'use client'; -import React, { useState } from 'react'; -import { useSearchParams } from 'next/navigation'; +import React, { useRef, useState } from 'react'; import { Button } from '../ui/Button'; import { track } from '../../lib/analytics/client'; import { analyticsEvents } from '../../lib/analytics/events'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { + FORM_POLICY_REFRESH_MESSAGE, + growthFormRequestSnapshot, + type GrowthFormRequestSnapshot, +} from '../../lib/growth/form-client'; -type Status = 'idle' | 'sending' | 'sent' | 'error'; +type Status = 'idle' | 'sending' | 'sent' | 'error' | 'stale'; -function sanitizeReferrerHost(): string | undefined { - if (typeof document === 'undefined' || !document.referrer) return undefined; - try { - return new URL(document.referrer).hostname; - } catch { - return undefined; - } -} - -export function ContactForm() { - const params = useSearchParams(); +export function ContactForm({ + formPolicy, +}: { + formPolicy: PublicFormPolicy; +}) { const [status, setStatus] = useState('idle'); const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [company, setCompany] = useState(''); const [message, setMessage] = useState(''); - - const sourcePage = params.get('source') ?? 'contact_direct'; - const trackParam = (params.get('track') ?? 'enterprise') as string; - const ctaId = params.get('cta_id') ?? undefined; - const paper = params.get('paper') ?? undefined; + const submissionSnapshot = useRef(null); + const disclosureId = 'contact-form-growth-disclosure'; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -40,22 +36,34 @@ export function ContactForm() { source_section: 'contact-form', }); try { + const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { + form_kind: 'contact', + email, + ...(name ? { name } : {}), + ...(company ? { company } : {}), + ...(message ? { message } : {}), + }); + submissionSnapshot.current = snapshot; const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - email, - name: name || undefined, - company: company || undefined, - message: message || undefined, - source_page: sourcePage, - track: trackParam, - cta_id: ctaId, - paper, - referrer_host: sanitizeReferrerHost(), + ...snapshot.facts, + acquisition_session_id: snapshot.acquisition_session_id, + submission_id: snapshot.submission_id, + policy_version: formPolicy.version, }), }); + if (res.status === 409) { + submissionSnapshot.current = null; + setStatus('stale'); + return; + } + if (res.status >= 400 && res.status < 500) { + submissionSnapshot.current = null; + } if (res.ok) { + submissionSnapshot.current = null; track(analyticsEvents.marketingLeadFormSuccess, { surface: 'contact', source_section: 'contact-form', @@ -79,6 +87,22 @@ export function ContactForm() { } } + if (status === 'stale') { + return ( +
+

{FORM_POLICY_REFRESH_MESSAGE}

+ +
+ ); + } + if (status === 'sent') { return (
@@ -130,7 +154,16 @@ export function ContactForm() { className="contact-form-input contact-form-textarea" /> - {status === 'error' && ( diff --git a/apps/website/src/components/landing/WhitePaperBlock.spec.tsx b/apps/website/src/components/landing/WhitePaperBlock.spec.tsx index 2f3832f22..9379cd674 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.spec.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.spec.tsx @@ -1,32 +1,146 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +// @vitest-environment jsdom +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const trackMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../lib/analytics/client', () => ({ + track: trackMock, + trackWhitepaperDownloadClick: vi.fn(), +})); + +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; import { WhitePaperBlock } from './WhitePaperBlock'; -const trackMock = vi.fn(); -vi.mock('../../lib/analytics/client', async (importOriginal) => { - const mod = await importOriginal>(); - return { - ...mod, - track: (...args: unknown[]) => trackMock(...args), - trackWhitepaperDownloadClick: vi.fn(), - }; +const formPolicy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'Contact disclosure', + newsletter: 'Newsletter disclosure', + whitepaper: + 'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.', + }, +}; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function submit(email: string): void { + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: email }, + }); + fireEvent.click(screen.getByRole('button', { name: /download \(free\)/i })); +} + +function sentBody(fetchMock: ReturnType, call: number) { + return JSON.parse(fetchMock.mock.calls[call][1].body as string); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe('WhitePaperBlock', () => { beforeEach(() => { trackMock.mockClear(); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); }); it('submits the email, fires signup analytics, and shows the done state', async () => { - render(); - fireEvent.change(screen.getByLabelText(/email/i), { - target: { value: 'dev@example.com' }, - }); - fireEvent.submit(screen.getByRole('button', { name: /download/i }).closest('form')!); - await waitFor(() => expect(screen.getByText(/Check your inbox/i)).toBeTruthy()); - const events = trackMock.mock.calls.map((c) => c[0]); + render(); + submit('dev@example.com'); + + await waitFor(() => + expect(screen.getByText(/Check your inbox/i)).toBeTruthy() + ); + const events = trackMock.mock.calls.map((call) => call[0]); expect(events).toContain('marketing:whitepaper_signup_submit'); expect(events).toContain('marketing:whitepaper_signup_success'); }); }); + +describe('WhitePaperBlock growth policy', () => { + it('renders the whitepaper disclosure and describes the submit control', () => { + render(); + + const disclosure = screen.getByText(formPolicy.disclosures.whitepaper); + const button = screen.getByRole('button', { name: /download \(free\)/i }); + + expect(disclosure.id).toBeTruthy(); + expect(button.getAttribute('aria-describedby')).toBe(disclosure.id); + }); + + it('submits the immutable growth envelope with the declared paper', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + submit('reader@example.com'); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock.mock.calls[0][0]).toBe('/api/whitepaper-signup'); + const body = sentBody(fetchMock, 0); + expect(body.email).toBe('reader@example.com'); + expect(body.paper).toBe('chat'); + expect(body.policy_version).toBe(formPolicy.version); + expect(body.submission_id).toMatch(UUID_V4); + expect(body.acquisition_session_id).toMatch(UUID_V4); + }); + + it('reuses the submission UUID when an uncertain attempt is retried', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + submit('reader@example.com'); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: /download \(free\)/i })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('mints a new submission UUID when the reader changes the facts', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + submit('reader@example.com'); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + submit('someone-else@example.com'); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).not.toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('requires a page refresh after a policy mismatch and reports no success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 409 }) + ); + render(); + + submit('reader@example.com'); + + await waitFor(() => + expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy() + ); + expect(screen.queryByText(/check your inbox/i)).toBeNull(); + }); +}); diff --git a/apps/website/src/components/landing/WhitePaperBlock.tsx b/apps/website/src/components/landing/WhitePaperBlock.tsx index 4bba10ea6..d8756446e 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.tsx @@ -1,5 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { + FORM_POLICY_REFRESH_MESSAGE, + growthFormRequestSnapshot, + type GrowthFormRequestSnapshot, +} from '../../lib/growth/form-client'; import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; @@ -18,6 +24,7 @@ type WhitepaperId = 'overview' | 'angular' | 'render' | 'chat'; interface WhitePaperBlockProps { /** Whitepaper variant. Determines PDF path + analytics tag. */ paper?: WhitepaperId; + formPolicy: PublicFormPolicy; } const PDF_PATHS: Record = { @@ -27,10 +34,20 @@ const PDF_PATHS: Record = { chat: { href: '/whitepapers/chat.pdf', download: 'angular-chat-guide.pdf' }, }; -export function WhitePaperBlock({ paper = 'overview' }: WhitePaperBlockProps = {}) { +export function WhitePaperBlock({ + formPolicy, + paper = 'overview', +}: WhitePaperBlockProps) { const pdf = PDF_PATHS[paper]; const [email, setEmail] = useState(''); - const [state, setState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle'); + const [state, setState] = useState< + 'idle' | 'submitting' | 'done' | 'error' | 'stale' + >('idle'); + const submissionSnapshot = useRef | null>(null); + const disclosureId = `wp-${paper}-growth-disclosure`; const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -42,12 +59,31 @@ export function WhitePaperBlock({ paper = 'overview' }: WhitePaperBlockProps = { paper, }); try { + const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { + email, + paper, + }); + submissionSnapshot.current = snapshot; const res = await fetch('/api/whitepaper-signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, paper }), + body: JSON.stringify({ + ...snapshot.facts, + acquisition_session_id: snapshot.acquisition_session_id, + submission_id: snapshot.submission_id, + policy_version: formPolicy.version, + }), }); + if (res.status === 409) { + submissionSnapshot.current = null; + setState('stale'); + return; + } + if (res.status >= 400 && res.status < 500) { + submissionSnapshot.current = null; + } if (!res.ok) throw new Error('whitepaper_signup_failed'); + submissionSnapshot.current = null; track(analyticsEvents.marketingWhitepaperSignupSuccess, { surface: 'home_whitepaper', source_section: 'whitepaper-block', @@ -104,6 +140,18 @@ export function WhitePaperBlock({ paper = 'overview' }: WhitePaperBlockProps = { Or download directly.
+ ) : state === 'stale' ? ( +
+

{FORM_POLICY_REFRESH_MESSAGE}

+ +
) : (
@@ -118,11 +166,15 @@ export function WhitePaperBlock({ paper = 'overview' }: WhitePaperBlockProps = { disabled={state === 'submitting'} className="wp-email-input" /> +

+ {formPolicy.disclosures.whitepaper} +

diff --git a/apps/website/src/components/pricing/LeadForm.spec.tsx b/apps/website/src/components/pricing/LeadForm.spec.tsx index 55a6168c1..9c666bef1 100644 --- a/apps/website/src/components/pricing/LeadForm.spec.tsx +++ b/apps/website/src/components/pricing/LeadForm.spec.tsx @@ -1,28 +1,73 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +// @vitest-environment jsdom +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const trackMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../lib/analytics/client', () => ({ track: trackMock })); + +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; import { LeadForm } from './LeadForm'; -const trackMock = vi.fn(); -vi.mock('../../lib/analytics/client', async (importOriginal) => { - const mod = await importOriginal>(); - return { ...mod, track: (...args: unknown[]) => trackMock(...args) }; +const formPolicy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'By sending, you agree Brian may follow up by email about your request.', + newsletter: 'Newsletter disclosure', + whitepaper: 'Whitepaper disclosure', + }, +}; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function fill(email: string, company = 'Acme'): void { + fireEvent.change(screen.getByLabelText(/^name$/i), { + target: { value: 'Buyer' }, + }); + fireEvent.change(screen.getByLabelText(/work email/i), { + target: { value: email }, + }); + fireEvent.change(screen.getByLabelText(/^company$/i), { + target: { value: company }, + }); +} + +function request(): void { + fireEvent.click( + screen.getByRole('button', { name: /request enterprise quote/i }) + ); +} + +function sentBody(fetchMock: ReturnType, call: number) { + return JSON.parse(fetchMock.mock.calls[call][1].body as string); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe('LeadForm', () => { beforeEach(() => { trackMock.mockClear(); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); }); it('submits and fires the lead analytics with surface/source_section, then shows the sent state', async () => { - render(); - fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Dev' } }); - fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } }); - fireEvent.change(screen.getByLabelText(/^company$/i), { target: { value: 'Acme' } }); - fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!); + render(); + fill('dev@example.com'); + request(); await waitFor(() => { - expect(screen.getByText(/we'll be in touch within one business day/i)).toBeTruthy(); + expect( + screen.getByText(/we'll be in touch within one business day/i) + ).toBeTruthy(); }); expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_submit', { @@ -33,16 +78,17 @@ describe('LeadForm', () => { surface: 'pricing', source_section: 'lead-form', }); - expect(trackMock).not.toHaveBeenCalledWith('marketing:lead_form_fail', expect.anything()); + expect(trackMock).not.toHaveBeenCalledWith( + 'marketing:lead_form_fail', + expect.anything() + ); }); it('fires the fail event with error_reason api_error when the request fails', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); - render(); - fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Dev' } }); - fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } }); - fireEvent.change(screen.getByLabelText(/^company$/i), { target: { value: 'Acme' } }); - fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); + render(); + fill('dev@example.com'); + request(); await waitFor(() => { expect(screen.getByText(/something went wrong/i)).toBeTruthy(); @@ -56,8 +102,105 @@ describe('LeadForm', () => { }); it('renders the See how Pilot-to-Prod works link pointing at /pilot-to-prod', () => { - render(); - const link = screen.getByRole('link', { name: /see how pilot-to-prod works/i }); + render(); + const link = screen.getByRole('link', { + name: /see how pilot-to-prod works/i, + }); expect(link.getAttribute('href')).toBe('/pilot-to-prod'); }); }); + +describe('LeadForm growth policy', () => { + it('renders the contact disclosure and describes the submit control', () => { + render(); + + const disclosure = screen.getByText(formPolicy.disclosures.contact); + const button = screen.getByRole('button', { + name: /request enterprise quote/i, + }); + + expect(disclosure.id).toBeTruthy(); + expect(button.getAttribute('aria-describedby')).toBe(disclosure.id); + }); + + it('submits the immutable growth envelope declaring the pricing form kind', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill('buyer@example.com'); + fireEvent.change(screen.getByLabelText(/team size/i), { + target: { value: '6-25' }, + }); + request(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock.mock.calls[0][0]).toBe('/api/leads'); + const body = sentBody(fetchMock, 0); + expect(body.form_kind).toBe('pricing'); + expect(body.email).toBe('buyer@example.com'); + expect(body.name).toBe('Buyer'); + expect(body.company).toBe('Acme'); + expect(body.team_size).toBe('6-25'); + expect(body.pilot_interest).toBe('maybe'); + expect(body.policy_version).toBe(formPolicy.version); + expect(body.submission_id).toMatch(UUID_V4); + expect(body.acquisition_session_id).toMatch(UUID_V4); + }); + + it('reuses the submission UUID when an uncertain attempt is retried', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill('buyer@example.com'); + request(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + request(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('mints a new submission UUID when the buyer changes the facts', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill('buyer@example.com'); + request(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + fill('buyer@example.com', 'Globex'); + request(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).not.toBe( + sentBody(fetchMock, 0).submission_id + ); + expect(sentBody(fetchMock, 1).company).toBe('Globex'); + }); + + it('requires a page refresh after a policy mismatch and reports no success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 409 }) + ); + render(); + + fill('buyer@example.com'); + request(); + + await waitFor(() => + expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy() + ); + expect(screen.queryByText(/we'll be in touch/i)).toBeNull(); + }); +}); diff --git a/apps/website/src/components/pricing/LeadForm.tsx b/apps/website/src/components/pricing/LeadForm.tsx index 95de540a9..73d6b3856 100644 --- a/apps/website/src/components/pricing/LeadForm.tsx +++ b/apps/website/src/components/pricing/LeadForm.tsx @@ -1,5 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { + FORM_POLICY_REFRESH_MESSAGE, + growthFormRequestSnapshot, + type GrowthFormRequestSnapshot, +} from '../../lib/growth/form-client'; import { analyticsEvents } from '../../lib/analytics/events'; import { track } from '../../lib/analytics/client'; import { Container } from '../ui/Container'; @@ -8,9 +14,13 @@ import { Eyebrow } from '../ui/Eyebrow'; import { Button } from '../ui/Button'; import { Card } from '../ui/Card'; -export function LeadForm() { - const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); +export function LeadForm({ formPolicy }: { formPolicy: PublicFormPolicy }) { + const [status, setStatus] = useState< + 'idle' | 'sending' | 'sent' | 'error' | 'stale' + >('idle'); const [pilotInterest, setPilotInterest] = useState<'yes' | 'maybe' | 'no'>('maybe'); + const submissionSnapshot = useRef(null); + const disclosureId = 'lead-form-growth-disclosure'; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -22,12 +32,37 @@ export function LeadForm() { source_section: 'lead-form', }); try { + const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { + form_kind: 'pricing', + email: String(data['email'] ?? ''), + name: String(data['name'] ?? ''), + company: String(data['company'] ?? ''), + message: String(data['message'] ?? ''), + team_size: String(data['team_size'] ?? ''), + timeline: String(data['timeline'] ?? ''), + pilot_interest: pilotInterest, + }); + submissionSnapshot.current = snapshot; const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...data, pilot_interest: pilotInterest }), + body: JSON.stringify({ + ...snapshot.facts, + acquisition_session_id: snapshot.acquisition_session_id, + submission_id: snapshot.submission_id, + policy_version: formPolicy.version, + }), }); + if (res.status === 409) { + submissionSnapshot.current = null; + setStatus('stale'); + return; + } + if (res.status >= 400 && res.status < 500) { + submissionSnapshot.current = null; + } if (res.ok) { + submissionSnapshot.current = null; track(analyticsEvents.marketingLeadFormSuccess, { surface: 'pricing', source_section: 'lead-form', @@ -72,7 +107,23 @@ export function LeadForm() {
- {status === 'sent' ? ( + {status === 'stale' ? ( + +
+

+ {FORM_POLICY_REFRESH_MESSAGE} +

+ +
+
+ ) : status === 'sent' ? (

Thanks — we'll be in touch within one business day. @@ -182,12 +233,16 @@ export function LeadForm() { className="lead-form-input lead-form-textarea" /> +

+ {formPolicy.disclosures.contact} +

diff --git a/apps/website/src/components/shared/AnnouncementToast.spec.tsx b/apps/website/src/components/shared/AnnouncementToast.spec.tsx index 40f0b963f..b13a02c65 100644 --- a/apps/website/src/components/shared/AnnouncementToast.spec.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.spec.tsx @@ -1,5 +1,6 @@ -import { render, act, fireEvent } from '@testing-library/react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; import { AnnouncementToast } from './AnnouncementToast'; vi.mock('../../lib/analytics/client', () => ({ @@ -7,6 +8,20 @@ vi.mock('../../lib/analytics/client', () => ({ trackWhitepaperDownloadClick: vi.fn(), })); +const formPolicy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'Contact disclosure', + newsletter: 'Newsletter disclosure', + whitepaper: + 'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.', + }, +}; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + function setScroll(fraction: number) { Object.defineProperty(document.documentElement, 'scrollHeight', { value: 5000, @@ -23,6 +38,7 @@ describe('AnnouncementToast', () => { beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); + sessionStorage.clear(); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0; @@ -34,14 +50,14 @@ describe('AnnouncementToast', () => { }); it('stays hidden after the timer if the reader has not scrolled 40%', () => { - render(); + render(); act(() => vi.advanceTimersByTime(31_000)); act(() => setScroll(0.1)); expect(document.querySelector('.toast-root')).toBeNull(); }); it('appears once BOTH the timer and the 40% scroll threshold are met', () => { - render(); + render(); act(() => vi.advanceTimersByTime(31_000)); act(() => setScroll(0.45)); expect(document.querySelector('.toast-root')).toBeTruthy(); @@ -52,9 +68,93 @@ describe('AnnouncementToast', () => { // literal construction: `dismissed-announcement-${ANNOUNCEMENT_DATE}` // with ANNOUNCEMENT_DATE = '2026-04-07'. localStorage.setItem('dismissed-announcement-2026-04-07', 'true'); - render(); + render(); act(() => vi.advanceTimersByTime(31_000)); act(() => setScroll(0.45)); expect(document.querySelector('.toast-root')).toBeNull(); }); }); + +function openForm(): void { + render(); + act(() => vi.advanceTimersByTime(31_000)); + act(() => setScroll(0.45)); + fireEvent.click(screen.getByRole('button', { name: /get the guide/i })); +} + +function fillAndSubmit(email: string): void { + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: email }, + }); + fireEvent.click(screen.getByRole('button', { name: /send me the guide/i })); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function sentBody(fetchMock: ReturnType, call: number) { + return JSON.parse(fetchMock.mock.calls[call][1].body as string); +} + +describe('AnnouncementToast growth policy', () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + sessionStorage.clear(); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('renders the whitepaper disclosure and describes the submit control', () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); + openForm(); + + const disclosure = screen.getByText(formPolicy.disclosures.whitepaper); + const submit = screen.getByRole('button', { name: /send me the guide/i }); + + expect(disclosure.id).toBeTruthy(); + expect(submit.getAttribute('aria-describedby')).toBe(disclosure.id); + }); + + it('submits the immutable growth envelope with the declared facts', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + openForm(); + + fillAndSubmit('reader@example.com'); + + await flush(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe('/api/whitepaper-signup'); + const body = sentBody(fetchMock, 0); + expect(body.email).toBe('reader@example.com'); + expect(body.paper).toBe('overview'); + expect(body.policy_version).toBe(formPolicy.version); + expect(body.submission_id).toMatch(UUID_V4); + expect(body.acquisition_session_id).toMatch(UUID_V4); + }); + + it('shows the refresh instruction and no success after a policy mismatch', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 409 }) + ); + openForm(); + + fillAndSubmit('reader@example.com'); + + await flush(); + expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy(); + expect(screen.queryByText(/check your inbox/i)).toBeNull(); + }); +}); diff --git a/apps/website/src/components/shared/AnnouncementToast.tsx b/apps/website/src/components/shared/AnnouncementToast.tsx index d194514bc..ce020d324 100644 --- a/apps/website/src/components/shared/AnnouncementToast.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.tsx @@ -1,5 +1,11 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { + FORM_POLICY_REFRESH_MESSAGE, + growthFormRequestSnapshot, + type GrowthFormRequestSnapshot, +} from '../../lib/growth/form-client'; import { analyticsEvents } from '../../lib/analytics/events'; import { track, @@ -15,14 +21,23 @@ const ANNOUNCEMENT_DATE = '2026-04-07'; const STORAGE_KEY = `dismissed-announcement-${ANNOUNCEMENT_DATE}`; const DELAY_MS = 30_000; -type Step = 'cta' | 'form' | 'sent'; +type Step = 'cta' | 'form' | 'sent' | 'stale'; -export function AnnouncementToast() { +export function AnnouncementToast({ + formPolicy, +}: { + formPolicy: PublicFormPolicy; +}) { const [visible, setVisible] = useState(false); const [mounted, setMounted] = useState(false); const [step, setStep] = useState('cta'); const [email, setEmail] = useState(''); const [submitting, setSubmitting] = useState(false); + const submissionSnapshot = useRef | null>(null); + const disclosureId = 'toast-whitepaper-growth-disclosure'; const [timerDone, setTimerDone] = useState(false); const [scrolledEnough, setScrolledEnough] = useState(false); @@ -94,11 +109,31 @@ export function AnnouncementToast() { paper: 'overview', }); try { - await fetch('/api/whitepaper-signup', { + const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { + email, + paper: 'overview' as const, + }); + submissionSnapshot.current = snapshot; + const response = await fetch('/api/whitepaper-signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify({ + ...snapshot.facts, + acquisition_session_id: snapshot.acquisition_session_id, + submission_id: snapshot.submission_id, + policy_version: formPolicy.version, + }), }); + if (response.status === 409) { + submissionSnapshot.current = null; + setStep('stale'); + setSubmitting(false); + return; + } + if (response.status >= 400 && response.status < 500) { + submissionSnapshot.current = null; + } + submissionSnapshot.current = null; track(analyticsEvents.marketingWhitepaperSignupSuccess, { surface: 'toast', source_section: 'announcement-toast', @@ -192,12 +227,16 @@ export function AnnouncementToast() { autoFocus className="toast-input" /> +

+ {formPolicy.disclosures.whitepaper} +

@@ -220,6 +259,22 @@ export function AnnouncementToast() { )} + {step === 'stale' && ( +
+

{FORM_POLICY_REFRESH_MESSAGE}

+
+ +
+
+ )} + {step === 'sent' && (
{/* role=status: the step swap is announced without stealing focus. */} diff --git a/apps/website/src/components/shared/Footer.spec.tsx b/apps/website/src/components/shared/Footer.spec.tsx new file mode 100644 index 000000000..ce0e0a105 --- /dev/null +++ b/apps/website/src/components/shared/Footer.spec.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/analytics/client', () => ({ + track: vi.fn(), + trackCtaClick: vi.fn(), + trackExternalLinkClick: vi.fn(), +})); + +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { Footer } from './Footer'; + +const formPolicy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: 'Contact disclosure', + newsletter: + 'Subscribe to Threadplane updates and a short, three-email welcome from Brian. Unsubscribe anytime.', + whitepaper: 'Whitepaper disclosure', + }, +}; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function subscribe(email: string): void { + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: email }, + }); + fireEvent.click(screen.getByRole('button', { name: /subscribe/i })); +} + +function sentBody(fetchMock: ReturnType, call: number) { + return JSON.parse(fetchMock.mock.calls[call][1].body as string); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Footer newsletter growth policy', () => { + it('renders the exact newsletter disclosure and describes the submit control', () => { + render(