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.
-
- `,
- });
-}
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.
-
- `,
- });
-}
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 `
-
-
-
-
- ${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 ? `` : ''}
-
- `,
- });
-}
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.
-
- `,
- });
-}
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.
-
- `,
- });
-}
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}
-
-
-
- ${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;
- }) => (
-
- {children}
-
- ),
-}));
+
+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}
+
window.location.reload()}
+ >
+ Refresh page
+
+
+ );
+ }
+
if (status === 'sent') {
return (
@@ -130,7 +154,16 @@ export function ContactForm() {
className="contact-form-input contact-form-textarea"
/>
-
+
+ {formPolicy.disclosures.contact}
+
+
{status === 'sending' ? 'Sending…' : 'Send'}
{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}
+
window.location.reload()}
+ >
+ Refresh page
+
+
) : (