From ca31bf58862a5dce19d8d54974a3b4c635490b6c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:48:49 -0700 Subject: [PATCH 01/15] docs: specify growth lifecycle hard cutover --- .../2026-09-02-growth-hard-cutover-design.md | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md diff --git a/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md b/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md new file mode 100644 index 000000000..d36074af6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md @@ -0,0 +1,390 @@ +# Threadplane Growth Lifecycle Immediate Hard Cutover + +Status: Approved design and independent repository review; pending user review and commit authorization + +Date: 2026-09-02 + +Scope: Website acquisition forms, Neon growth CRM, Dawn lifecycle execution, Resend delivery and webhook handling, Google Workspace reply detection, and removal of the legacy website lifecycle paths + +## Context + +PR #952 established the reusable growth and lifecycle foundation: Neon-backed contacts, activity, jobs, artifacts, reporting views, stop handling, delivery policy, Resend integration, Google reply processing, Dawn workflows, deployment adapters, and operational tooling. A follow-on website integration exists as an uncommitted draft in the source worktree, mixed with unrelated work. This design defines how to extract only that integration into a clean branch and perform an immediate production cutover. + +This document narrows and supersedes the rollout details in `2026-08-31-threadplane-growth-lifecycle-v1-design.md` wherever the earlier design proposed draining already-scheduled legacy messages. The approved policy is now an immediate hard cutover: stop the legacy system, import its state, cancel every outstanding legacy Resend schedule, and rely on Git and Vercel deployment rollback rather than preserving a live legacy fallback. + +The system remains intentionally lean: + +- Neon is the v1 CRM and operational source of truth. +- `outreach_approved_at` is the single current outreach authorization value. +- Whitepaper, newsletter, contact, and pricing forms grant that approval through the disclosed submission action. +- One hardcoded founder-style campaign is supported. +- Resend remains the only delivery provider. +- Dawn runs deterministic fulfillment and orchestration plus bounded AI enrichment. +- Google Workspace mailbox polling detects natural replies. +- PostHog remains an analytics destination, not the lifecycle CRM. +- Authentication, a campaign builder, an in-house reporting UI, external CRM sync, calendar tooling, inferred-contact prospecting, and broader attribution work are not part of this cutover. + +## Goals + +1. Make every supported acquisition form durable in Neon before returning success. +2. Remove legacy NDJSON, Loops, Resend Audience, direct-send, and provider-scheduled sequence behavior from the active website paths. +3. Fulfill requested content and queue enrichment/notification work without requiring campaign delivery to be enabled. +4. Make reply, unsubscribe, bounce, complaint, and founder stop durable and authoritative. +5. Cut over without duplicate sends, accidental legacy enrollment, or a hidden runtime fallback. +6. Leave a reporting-friendly database trail for a future agentic operating UI. + +## Non-goals + +- Changing the public runtime telemetry design or event taxonomy. +- Adding telemetry promises or telemetry marketing copy to the website. +- Reworking PostHog acquisition attribution during this cutover. +- Adding AnyMailFinder or inferring contacts from anonymous telemetry. +- Adding authentication, account administration, a general CRM, a campaign editor, or meeting tooling. +- Building multiple campaigns or configurable sequences. +- Mutating production providers as part of the code pull request. +- Pulling unrelated cockpit, documentation-generation, website-redesign, privacy-copy, or operational-control-plane changes out of the dirty source worktree. + +## Locked decisions + +### Cutover policy + +- The production website switches directly to `growth_v1`; there is no dual write. +- Missing or invalid growth configuration fails closed. It must not silently execute legacy behavior. +- Legacy application code is deleted in the cutover pull request. +- Rollback means reverting the Git change or selecting the prior Vercel deployment. It does not mean selecting a legacy code path at runtime. +- The lifecycle service is deployed before the website boundary, with recipient delivery, campaign enrollment, and campaign delivery disabled. +- Existing Resend contacts and scheduled messages are snapshotted and imported before cancellation. The importer creates a durable legacy marker for every imported contact, including contacts with no scheduled message. +- Every outstanding legacy scheduled message is individually cancelled and the result reconciled against the snapshot. +- The final snapshot must leave a conservative, tool-enforced processing window before the earliest scheduled delivery. If it does not, the cutover aborts before database or provider mutation and is retried in a safe window. +- Imported legacy contacts remain excluded from the new campaign. A later deliberate reauthorization operation may remove that exclusion; ordinary form replay must not do so accidentally. + +### Form and outreach policy + +- Whitepaper, newsletter, contact, and pricing submissions use a server-controlled `growth_v1` policy and an idempotency UUID generated by the client. +- The currently rendered policy version must accompany the request. A stale or missing version returns `409 Conflict` with the current policy metadata so the client can refresh. +- Whitepaper fulfillment is independent of enrichment and campaign state. +- A successful form response means the Neon transaction committed. It does not claim that email delivery or enrichment already completed. +- `outreach_approved_at` is the only current approval flag. Stops clear it. +- A hard stop cannot be undone by a duplicate submission or ordinary later form submission. +- Existing raw-email unsubscribe URLs may be accepted only as inbound compatibility for messages already sent. All new unsubscribe links use opaque signed tokens. + +### Delivery and reply policy + +- Campaign and fulfillment email is plain text and should read as a direct message from Brian. +- Resend is used for transport, provider message identifiers, and verified delivery webhooks. +- New campaign jobs are held in Neon until due; Resend must not schedule the future campaign steps. +- Google Workspace is the reply mailbox. A Google Apps Script poller sends only bounded message metadata and reply headers to the signed reply endpoint; it does not persist message bodies in Threadplane. +- Reply, unsubscribe, complaint, hard bounce, founder stop, and invalid address stop future campaign work immediately. +- Open and click tracking stay disabled. + +## Target architecture + +```mermaid +flowchart TD + FORM[Website form] --> CHECK[Validate bounded body, policy version, and submission UUID] + CHECK --> TX[One Neon transaction] + TX --> CONTACT[Contact and approval timestamp] + TX --> ACTIVITY[Form activity and provenance] + TX --> JOBS[Fulfillment, enrichment, and founder-notification jobs] + TX --> RESPONSE[Success response] + RESPONSE --> NUDGE[Best-effort PII-free Dawn nudge] + + CRON[Vercel Cron] --> DAWN[Dawn lifecycle dispatcher] + NUDGE --> DAWN + JOBS --> DAWN + DAWN --> ENRICH[Bounded AI enrichment] + DAWN --> SEND[Resend delivery] + ENRICH --> ARTIFACT[Persisted research artifact] + + SEND --> WEBHOOK[Verified Resend webhook] + SEND --> GMAIL[Brian's Google mailbox] + GMAIL --> POLLER[Google Apps Script metadata poller] + POLLER --> REPLY[Signed reply endpoint] + WEBHOOK --> STOP[Canonical stop transaction] + REPLY --> STOP + UNSUB[Signed unsubscribe / one-click] --> STOP + FOUNDER[Founder stop action] --> STOP + STOP --> CONTACT + STOP --> JOBS + + CONTACT --> REPORTING[Neon reporting views] + ACTIVITY --> REPORTING + JOBS --> REPORTING + ARTIFACT --> REPORTING +``` + +## Components and boundaries + +### Website form boundary + +The website owns request parsing, size limits, policy negotiation, user-facing validation responses, and the call into the growth library. It must not own provider delivery or append customer data to local files. + +The extraction includes: + +- `apps/website/src/lib/growth/form-policy.ts` +- `apps/website/src/lib/growth/form-route.ts` +- `apps/website/src/lib/growth/form-client.ts` +- `apps/website/src/lib/growth/lifecycle-client.ts` +- `apps/website/src/lib/growth/email-keyring.ts` +- `apps/website/src/app/api/_internal/read-bounded-body.ts` +- `apps/website/src/app/api/whitepaper-signup/route.ts` +- `apps/website/src/app/api/newsletter/route.ts` +- `apps/website/src/app/api/leads/route.ts` +- Their focused unit tests + +The affected form components are `WhitePaperBlock`, `AnnouncementToast`, `Footer`, `ContactForm`, and `LeadForm`. Only the growth policy, disclosure, UUID, and request-shape hunks are extracted from mixed website pages. Visual redesign and unrelated copy changes remain in the source worktree. + +### Durable form acceptance + +All form types converge on `acceptFormSubmission()` in `libs/growth/src/lib/forms.ts`. One database transaction: + +1. Normalizes and upserts the contact. +2. Records the server-verified form activity and policy provenance. +3. Sets `outreach_approved_at` only when the contact is eligible for approval under the existing stop rules. +4. Creates idempotent fulfillment, enrichment, and founder-notification jobs appropriate to the form. +5. Commits before the route reports success. + +The submission UUID is the idempotency boundary. Replaying the same UUID returns the original accepted outcome and does not duplicate contacts, approval events, or jobs. A Dawn nudge occurs only after commit and contains no contact PII. Nudge failure does not fail an already committed form because the periodic dispatcher can recover the durable jobs. + +### Lifecycle execution + +The website exposes only narrow authenticated adapters: + +- `apps/website/src/app/api/cron/lifecycle/route.ts` +- `apps/website/src/app/api/growth/stop/route.ts` +- `apps/website/src/app/api/growth/replies/google/route.ts` +- `apps/website/src/app/api/webhooks/resend/route.ts` +- `apps/website/src/app/api/unsubscribe/route.ts` + +Cron and nudges invoke the deployed Dawn lifecycle service. Dawn leases Neon jobs so multiple instances or concurrent nudges cannot send the same job twice. Fulfillment, enrichment, and founder notification can run while campaign enrollment and campaign delivery remain disabled. + +### Campaign eligibility + +`materializeCampaignEnrollment()` in `libs/growth/src/lib/jobs.ts` is the sole creator of campaign enrollment and sequence jobs. Eligibility requires the current approval event, no hard stop, an approval at or after the immutable cohort timestamp, and all existing campaign invariants. + +The importer must create a durable `growth_jobs.kind = 'legacy'` contact marker for every imported Resend contact, not only for contacts with scheduled messages. A contact marker is a terminal, non-dispatchable job with a deterministic idempotency key and a payload subtype that distinguishes it from an imported scheduled message. Imported scheduled messages remain separate legacy jobs with their provider message IDs. Re-running the importer must reproduce the same markers without duplicates or changing approval. + +The cutover adds an additional conservative condition: a contact with any imported `growth_jobs.kind = 'legacy'` marker is ineligible for campaign v1. The intended query shape is: + +```sql +and not exists ( + select 1 + from growth_jobs legacy + where legacy.contact_id = c.id + and legacy.kind = 'legacy' +) +``` + +This is a correctness blocker for the cutover. Cancellation removes provider-side delivery; the marker exclusion prevents any imported Resend contact from entering the new Neon campaign after a later form submission. A future explicit reauthorization operation may archive or replace the legacy marker after a human decision. Normal form handling must not implicitly do that. + +### Stops and provider events + +All stop sources converge on the existing canonical stop transaction in `libs/growth/src/lib/stops.ts`. That transaction records provenance, clears `outreach_approved_at`, cancels pending campaign jobs, and reconciles provider state where required. It is idempotent. + +New unsubscribe links contain signed opaque tokens. One-click unsubscribe uses the appropriate `List-Unsubscribe` and `List-Unsubscribe-Post` headers. The legacy raw-email endpoint remains input compatibility only and must invoke the same canonical stop operation. When the database is healthy, known and unknown syntactically valid addresses return the same response shape and status. A matched contact receives a success response only after the canonical stop transaction commits. Database or stop failures return one uniform retryable failure and must never be presented as a successful unsubscribe. The endpoint must not reveal whether an arbitrary address exists. + +Resend webhook requests require signature verification against the exact raw body. Google reply requests require timestamped HMAC verification, replay protection, bounded metadata, and no message body. Test and production credentials and database environments must remain distinct. + +### Reporting trail + +Neon remains the operational CRM and future reporting substrate. The cutover records contact state, form provenance, approval, stops, job transitions, provider IDs, delivery events, reply metadata, and enrichment artifacts in the existing growth schema. PostHog may continue to receive bounded acquisition analytics, but PostHog state cannot authorize delivery or become the source of contact state. + +Restoring detailed PostHog form-conversion continuity is a separate bounded attribution change after the cutover. It must not delay replacement of the legacy messaging path. + +## Legacy removal + +The pull request removes active references from the affected website routes to: + +- Local NDJSON lead, whitepaper, newsletter, or unsubscribe storage. +- Loops contact creation or workflow events. +- Resend Audience contact synchronization. +- Direct synchronous recipient delivery in a form request. +- Code that schedules future lifecycle messages at Resend. +- Raw-email generation for new unsubscribe links. +- Runtime feature flags that select the legacy form implementation. + +Inbound compatibility is narrower than a fallback. The application may continue to accept already-issued legacy unsubscribe links and provider identifiers long enough to stop delivery safely, but it must never resume the legacy write or send paths. + +### Legacy cancellation operator + +The code pull request includes a narrow operator tool and matching runbook update because the existing importer can snapshot and import provider state but cannot complete the approved cancellation policy. The tool may extend `scripts/import-resend-lifecycle.mts` or be a focused sibling such as `scripts/cancel-resend-lifecycle.mts`, but it must have one responsibility: reconcile imported scheduled legacy jobs with Resend. + +The operator workflow must: + +1. Preflight the authoritative snapshot before any database or provider mutation. When scheduled messages exist, set the cancellation deadline to five minutes before the earliest `scheduled_at` value and require at least 30 minutes between snapshot time and that deadline. The tool may use larger configured margins but must reject smaller ones. +2. Abort cleanly and remove the temporary form block if the preflight window is insufficient. The operator then chooses a later cutover window; no import or cancellation from the rejected snapshot is allowed. +3. Enumerate every imported legacy job with a scheduled provider message ID without printing those IDs in ordinary output. +4. Require an expected aggregate count, an explicit apply mode, the correct database environment, and a separate production acknowledgement. +5. Cancel each provider message through Resend's supported single-message operation; no bulk or recipient-derived cancellation is allowed. +6. Record each confirmed cancellation against the exact legacy job with terminal job state and a durable activity event. +7. Leave failed or ambiguous cancellations durable and visibly unresolved rather than marking them complete. +8. Re-list all Resend scheduled messages after the pass and reconcile the provider result against the imported set. +9. Fail the cutover if full cancellation and reconciliation have not completed before the calculated cancellation deadline, even if no provider error was returned. +10. Report aggregate counts, remaining safe-window duration, and stable error categories only. Exact provider IDs stay in the restricted database/provider session. + +Campaign enrollment and campaign delivery remain disabled until the tool reports zero unresolved imported schedules and zero unexpected scheduled messages. The production execution of this tool is an authorized runbook action after the code pull request is green and merged. + +## Data flow + +### Form submission + +1. The browser generates a submission UUID and submits bounded fields plus the rendered policy version. +2. The route checks content type, body size, field lengths, form kind, and policy version. +3. The route calls `acceptFormSubmission()`. +4. Neon atomically records the contact, activity, approval state, and jobs. +5. The route returns the accepted response. +6. The route makes a best-effort PII-free lifecycle nudge after commit. +7. Dawn leases due jobs and persists each state transition. +8. Fulfillment sends the requested asset exactly once, independently of campaign state. +9. Enrichment persists a structured research artifact and founder notification summarizes the result. +10. Campaign enrollment remains disabled until the operational cutover gates pass. + +### Stop signal + +1. A signed unsubscribe, verified webhook, verified Google reply, or authenticated founder action reaches a narrow endpoint. +2. The endpoint validates authenticity, replay rules, and bounded input before mutation. +3. The canonical stop transaction records the event and clears approval. +4. Pending campaign work is cancelled. Ambiguous in-flight provider work is reconciled by provider ID rather than blindly resent. +5. Repeating the same stop event returns an idempotent outcome. + +## Failure semantics + +| Failure | Required behavior | +| --- | --- | +| Neon unavailable during form submission | Return `503 Service Unavailable`; do not send, enrich, notify, or claim acceptance. | +| Missing or invalid `GROWTH_FORM_POLICY` | Fail closed; do not select legacy behavior. | +| Missing or stale browser policy version | Return `409 Conflict` with current policy metadata; do not mutate Neon. | +| Duplicate submission UUID | Return the accepted idempotent result; create no duplicate jobs. | +| Dawn nudge unavailable after commit | Return form success; cron later processes the durable jobs. | +| Dawn instance fails while processing | Lease expires or retry policy recovers the job without duplicate provider acceptance. | +| Resend accepts but response is ambiguous | Persist/reconcile idempotency and provider identifiers; do not blindly retry a recipient send. | +| Resend webhook signature invalid | Reject with no mutation. | +| Google reply signature, timestamp, or replay check invalid | Reject with no mutation. | +| Legacy schedule cancellation incomplete | Keep affected forms blocked during the cutover critical section and keep campaign enrollment and delivery disabled. Preserve exact unresolved provider IDs in restricted durable state; emit only aggregate counts to ordinary logs. | +| Stop races with provider submission | Persist the stop and reconcile bounded in-flight state; no later campaign step may send. | +| A post-cutover defect requires rollback | Revert Git or select the prior Vercel deployment. Treat Neon-accepted jobs as durable state requiring explicit operational control; do not activate a hidden dual-write fallback. | + +## Production cutover sequence + +1. Extract only the growth website integration and focused tests from the dirty source worktree into this clean branch based on `origin/main`. +2. Delete the legacy execution paths and add the imported-legacy campaign exclusion plus its tests. +3. Run focused unit, integration, build, and static-boundary verification against a disposable Neon database. +4. Provision distinct production Growth Neon and Dawn Neon databases, then apply and verify migrations. +5. Deploy the lifecycle project to production with fulfillment delivery, campaign enrollment, and campaign delivery disabled. +6. Configure the production website for `growth_v1`, but do not expose the new boundary yet. +7. Begin a short quiescent critical section by installing a temporary Vercel Firewall rule that returns a retryable maintenance response for POST requests to `/api/whitepaper-signup`, `/api/newsletter`, and `/api/leads`. Verify all three routes are blocked and that no route can create a new provider schedule. Keep the rule active until step 11 completes. +8. Take the authoritative live Resend contact and scheduled-message snapshot. Before any mutation, require the cancellation tool to prove at least its 30-minute minimum processing window before the calculated deadline ahead of the earliest scheduled delivery. If the preflight fails, remove the form block and retry in a later safe window. If it passes, import every contact with a durable legacy marker and every scheduled message as a provider-bound legacy job. +9. Run the cancellation operator against the imported set. Cancel each outstanding scheduled message individually, persist each result, and re-list Resend. Complete before the calculated cancellation deadline and do not proceed until reconciliation proves zero unresolved imported schedules and zero unexpected scheduled messages. +10. Deploy the production website hard boundary while the form-route firewall remains active. Verify the deployed artifact has `growth_v1`, contains no active legacy form branch, and can reach the intended production Growth database and lifecycle service without enabling recipient delivery. +11. Remove the temporary firewall rule and run one no-delivery form persistence smoke test. New submissions now commit only to Neon and queue jobs; no legacy branch remains. +12. Register and verify the Resend webhook. Deploy and verify the Google mailbox poller against the production reply endpoint. +13. Enable lifecycle cron and recipient delivery for fulfillment, enrichment, and founder notification. Drain and inspect those job classes while campaign enrollment and delivery stay off. +14. Set `CAMPAIGN_ENROLLMENT_START_AT` once to the instant the forms reopened in step 11. Enable enrollment, inspect eligible contacts and created jobs, and only then enable campaign delivery. + +The provider inventory must be freshly read immediately before mutation. Previously observed counts are planning context, not an execution precondition. + +## Rollback and recovery + +There is no application-level legacy fallback. Code rollback uses Git and Vercel deployment history, but an older website deployment must never receive affected form POST traffic because it contains the removed side effects. + +- Before selecting a prior website deployment, install the same Vercel Firewall block for all affected form POST routes. Keep the routes blocked while the deployment changes and while database/provider state is reconciled. +- A prior deployment may temporarily serve unaffected pages behind that route block, but the form routes reopen only on a deployment that preserves the Neon hard boundary. A prepared Git rollback artifact may revert unrelated application changes while retaining those route implementations. +- Before enabling delivery, rollback preserves the committed Neon queue, blocks form ingress, and restores a deployment that retains the hard boundary. +- After fulfillment delivery is enabled, accepted jobs remain authoritative and visible in Neon. Operators pause delivery and block form ingress before changing deployments. +- After campaign delivery is enabled, operators first disable enrollment and campaign delivery, inspect in-flight provider state, and then revert. +- Imported/cancelled legacy Resend schedules are not recreated automatically during rollback. +- Before reopening forms, reconcile every accepted Neon submission and every provider acceptance during the rollback window so a roll-forward cannot duplicate customer effects. +- A rollback must never replay NDJSON, re-add contacts to Resend Audience, or allow provider-scheduled drip behavior to receive traffic without a new explicit decision. + +## Acceptance gates + +### Repository scope + +- The clean pull request contains only the website growth integration, required configuration/dependency changes, focused tests, legacy deletion, the legacy-contact exclusion, importer/cancellation operator changes, and the matching cutover runbook update. +- It contains no unrelated cockpit, generated-documentation, website-redesign, privacy-copy, telemetry, or operational-control-plane changes from the source worktree. +- A static boundary test fails if the affected production routes import filesystem PII storage, Loops, Resend Audience helpers, legacy scheduling, or direct recipient sends. + +### Form correctness + +- Missing/invalid server policy fails closed. +- Missing/stale submitted policy returns `409` without mutation. +- Oversized or malformed request bodies are rejected before database work. +- Duplicate submission UUIDs produce one acceptance event and one logical set of jobs. +- Whitepaper, newsletter, contact, and pricing forms all persist through the same durable boundary. +- A successful response is impossible without a committed Neon transaction. +- A failed post-commit nudge does not lose work. + +### Approval and stop correctness + +- Ordinary form submission cannot undo unsubscribe, complaint, hard bounce, reply, founder stop, deletion, or another protected hard stop. +- Every marketing send performs the final approval and stop check immediately before provider submission. +- Signed unsubscribe and one-click unsubscribe converge on the same canonical stop transaction. +- Legacy raw-email unsubscribe input returns the same healthy result for known and unknown valid addresses, but a database/stop failure returns a uniform retryable failure rather than false success. +- Duplicate webhook, reply, unsubscribe, and founder-stop events are idempotent. + +### Legacy cutover correctness + +- The Resend snapshot records every relevant contact and scheduled message before mutation. +- Import produces one deterministic legacy contact marker for every imported Resend contact, plus deterministic provider-bound records for scheduled messages, without granting approval. +- Importing a contact with no scheduled message still creates the exclusion marker. +- `materializeCampaignEnrollment()` excludes every contact with a legacy marker, including after that contact later submits an approving form. +- Every outstanding legacy scheduled message is individually cancelled and verified. +- The cancellation operator persists confirmed and unresolved per-message outcomes, emits aggregate-only ordinary output, and re-lists the provider after cancellation. +- Cancellation preflight rejects a snapshot before mutation when it lacks the minimum 30-minute processing window, and the successful path proves final reconciliation completed before the calculated deadline. +- Campaign enrollment cannot be enabled while cancellation reconciliation reports an unresolved or unexpected legacy schedule. +- During the quiescent critical section, all three affected form POST routes are blocked before the authoritative snapshot and stay blocked until the hard boundary is verified. +- Production code performs no NDJSON, Loops, Resend Audience, direct-form-send, or provider-scheduled drip side effect. + +### Lifecycle and delivery correctness + +- Disposable-Neon integration tests cover migrations, forms, jobs, stops, replies, and the new legacy exclusion. +- Concurrent nudges and at least two Dawn instances cannot duplicate a provider send. +- Preview form submissions create expected rows and jobs with all provider delivery switches off. +- Enabling fulfillment sends one whitepaper request exactly once and persists the provider message ID and delivery status. +- A verified Resend webhook advances delivery state; a forged webhook does nothing. +- A detected Google reply clears approval and cancels pending campaign work. +- Failure after ambiguous provider acceptance does not cause a blind duplicate retry. +- Test fixtures clean up only their exact rows and leave the disposable database in the expected state. + +### Production readiness + +- Production Growth and Dawn databases are distinct from preview and test. +- Required secrets are present in the correct Vercel projects and environments; values are not logged. +- The configured sender uses an authenticated Threadplane domain with verified SPF, DKIM, DMARC, Return-Path behavior, Reply-To, `List-Unsubscribe`, and `List-Unsubscribe-Post` where applicable. +- Resend open and click tracking remain disabled. +- Campaign enrollment and delivery remain off until fulfillment and reply-handling canaries pass. +- One production whitepaper canary is fulfilled exactly once, visible in Neon, Resend, and the mailbox. +- The first campaign cohort is inspected before campaign delivery is enabled. + +## Verification plan + +The implementation plan must select the smallest repository-native commands that prove each layer. At minimum it should include: + +- Project-scoped unit tests for `growth`, `lifecycle`, and `website` route/components affected by the extraction. +- Growth integration tests against the disposable Neon database. +- Import/cancellation operator tests covering contact markers, per-message settlement, partial provider failure, idempotent rerun, and final inventory reconciliation. +- Lifecycle Vercel adapter verification and production-bundle inspection. +- Website and lifecycle builds through Nx. +- Static searches/tests proving removal of legacy imports and side effects. +- Preview form and lifecycle runtime canaries with delivery disabled. +- Production inventory reconciliation and canaries performed from the cutover runbook, not from automated test fixtures. + +No completion claim may depend only on mocked provider behavior. Production provider mutations occur during the reviewed runbook after the code pull request is green and merged. + +## Pull request boundary + +The implementation is one focused hard-cutover code pull request followed by an operational cutover: + +- Extract the approved website integration from the source worktree. +- Add the legacy-contact exclusion and tests. +- Extend the legacy importer to mark every imported contact and add the narrow cancellation operator plus runbook coverage. +- Remove active legacy implementation and dependencies from the touched paths. +- Add the minimum Vercel/environment wiring required by those paths. +- Preserve the already-merged growth/lifecycle foundation. +- Keep telemetry attribution continuity, website privacy copy, cockpit work, and broader UI changes outside this pull request. + +Mixed files must be reconstructed or staged hunk-by-hunk. The source worktree is not a patch source to apply wholesale. + +## Next arc after cutover + +Once the cutover is stable, the next design cycle can connect bounded runtime signals to account-level scoring, deterministic research, AI enrichment, and founder approval-driven outreach. That work should preserve the same boundary: anonymous or client-reported signals may prioritize research, but person-specific sending requires a real contact and an active `outreach_approved_at` timestamp. A future agentic reporting UI can read the same Neon event and job history without changing the v1 operational model. From c46b4df85848cf308e7f82a2aaee9a1ab4415620 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 10:25:48 -0700 Subject: [PATCH 02/15] docs: plan growth lifecycle hard cutover --- .../plans/2026-09-02-growth-hard-cutover.md | 1103 +++++++++++++++++ 1 file changed, 1103 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-growth-hard-cutover.md diff --git a/docs/superpowers/plans/2026-09-02-growth-hard-cutover.md b/docs/superpowers/plans/2026-09-02-growth-hard-cutover.md new file mode 100644 index 000000000..07d11416f --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-growth-hard-cutover.md @@ -0,0 +1,1103 @@ +# Threadplane Growth Lifecycle Hard Cutover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Threadplane's legacy website lead and whitepaper delivery paths with the already-merged Neon/Dawn/Resend lifecycle, cancel every outstanding provider-scheduled legacy message, and enable the new system through a controlled production hard cutover. + +**Architecture:** Website forms validate one server-owned `growth_v1` policy and commit contacts, approval, activity, and jobs in one Neon transaction before returning success. Dawn leases durable fulfillment, enrichment, notification, and campaign jobs; Resend transports messages and reports delivery; signed stop surfaces and Google mailbox metadata polling terminate outreach. The repository lands as one focused pull request, then production uses a short Vercel Firewall quiescent window to snapshot, import, cancel, deploy, and reopen without dual-writing or reviving legacy behavior. + +**Tech Stack:** Nx, npm, TypeScript, Next.js 16, React 19, Vitest, Playwright, Neon PostgreSQL, Dawn 0.8.21, Vercel Pro/Cron/Firewall, Resend 6.10, Google Apps Script. + +--- + +## Working agreements + +- Work only in the clean `growth-hard-cutover` worktree on `blove/growth-hard-cutover`. +- Treat the original `angular-agent-framework` checkout as a read-only source draft. It contains unrelated user work. Never reset, stage, commit, or edit it. +- The source draft is not the specification. Port the useful `growth_v1` implementation, then apply the hard-cutover changes in the approved design. +- Use `@superpowers:test-driven-development` for every behavior change and `@superpowers:verification-before-completion` before every completion claim. +- Make the task commits below only after their focused tests pass. Do not publish or merge until the full verification task passes. +- Never print `.env` values, provider IDs, email addresses, database URLs, or raw provider responses in logs or review evidence. +- Production mutations are explicitly confined to Tasks 10–12, after the code pull request is green and merged. + +## File map + +### Growth control plane + +- Modify `libs/growth/src/lib/jobs.ts`: exclude imported legacy contacts from campaign enrollment. +- Modify `libs/growth/src/lib/jobs.spec.ts`: assert the SQL eligibility backstop. +- Modify `libs/growth/test/jobs.integration.spec.ts`: prove an imported contact remains excluded after a later approving form event. +- Modify `scripts/import-resend-lifecycle.mts`: create a deterministic terminal marker for every imported contact and enforce the cutover timing preflight. +- Modify `scripts/import-resend-lifecycle.spec.ts`: cover contact markers, idempotency, approval preservation, and timing rejection. +- Create `scripts/cancel-resend-lifecycle.mts`: cancel and reconcile imported scheduled messages one provider record at a time. +- Create `scripts/cancel-resend-lifecycle.spec.ts`: cover guards, partial failure, settlement, deadline expiry, output redaction, and idempotent rerun. +- Modify `libs/growth/project.json`: include the new operator in task inputs and operator tests. +- Modify `libs/growth/vite.operator-cli.config.mts`: include the cancellation operator spec. +- Modify `package.json` and `package-lock.json`: add the operator script and website server boundary dependency without accepting unrelated lockfile changes. +- Modify `docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md`: replace drain instructions with the approved block/snapshot/import/cancel/reconcile sequence. + +### Website server boundary + +- Create `apps/website/src/app/api/_internal/read-bounded-body.ts` and its spec. +- Create `apps/website/src/lib/growth/email-keyring.ts`. +- Create `apps/website/src/lib/growth/form-client.ts` and its spec. +- Create `apps/website/src/lib/growth/form-policy.ts` and its spec. +- Create `apps/website/src/lib/growth/form-route.ts` and its spec. +- Create `apps/website/src/lib/growth/lifecycle-client.ts` and its spec. +- Create `apps/website/src/lib/growth/hard-cutover-boundary.spec.ts`. +- Modify `apps/website/tsconfig.json`: add only the internal growth path alias. + +### Forms and pages + +- Replace `apps/website/src/app/api/whitepaper-signup/route.ts` and its spec. +- Replace `apps/website/src/app/api/newsletter/route.ts` and add its spec. +- Replace `apps/website/src/app/api/leads/route.ts` and replace its spec. +- Modify `apps/website/src/components/landing/WhitePaperBlock.tsx` and add/restore its focused spec. +- Modify `apps/website/src/components/shared/AnnouncementToast.tsx` and add its focused spec. +- Modify `apps/website/src/components/shared/Footer.tsx` and add its focused spec. +- Modify `apps/website/src/components/shared/SiteFooter.tsx` and its focused spec so the root layout can pass policy through the route gate. +- Modify `apps/website/src/components/contact/ContactForm.tsx` and its focused spec. +- Modify `apps/website/src/components/pricing/LeadForm.tsx` and add/restore its focused spec. +- Modify only policy-prop hunks in `apps/website/src/app/layout.tsx`, `page.tsx`, `ag-ui/page.tsx`, `chat/page.tsx`, `contact/page.tsx`, `langgraph/page.tsx`, `pilot-to-prod/page.tsx`, `pricing/page.tsx`, `render/page.tsx`, `solutions/page.tsx`, and `solutions/[slug]/page.tsx`. +- Modify only the four form-flow cases in `apps/website/e2e/website.spec.ts` and the local environment construction in `apps/website/playwright.config.ts`. + +### Stop and orchestration adapters + +- Replace `apps/website/src/app/api/unsubscribe/route.ts` and add its spec. +- Create `apps/website/src/app/api/growth/stop/route.ts` and its spec. +- Create `apps/website/src/app/api/growth/replies/google/route.ts` and its spec. +- Create `apps/website/src/app/api/webhooks/resend/route.ts` and its spec. +- Create `apps/website/src/app/api/cron/lifecycle/route.ts` and its spec. +- Modify `vercel.json`: register the single lifecycle cron. + +### Legacy deletion + +- Delete `apps/website/lib/drip.ts`, `apps/website/lib/loops.ts`, and `apps/website/lib/resend.ts`. +- Delete `apps/website/src/app/api/email-preview/route.ts`. +- Delete the legacy-only `apps/website/emails/*.ts` templates after proving no remaining import. +- Do not modify `apps/website/src/lib/analytics/server.ts` in this pull request. It becomes inactive when the form imports disappear; attribution cleanup is a separate change. + +## Task 0: Synchronize the clean worktree and prove the baseline + +**Files:** + +- No planned file changes. + +- [ ] **Step 1: Fetch and inspect divergence** + +```bash +git fetch origin +git status --short --branch +git log --oneline --left-right HEAD...origin/main +``` + +Expected: only the committed design and this uncommitted plan are local. Stop if any unrelated working-tree change appears. + +- [ ] **Step 2: Rebase the design commit onto current main** + +Temporarily leave the uncommitted plan untouched only if Git can preserve it safely; otherwise stage it nowhere and use a non-destructive temporary patch file outside the repository. Run: + +```bash +git rebase origin/main +``` + +Expected: clean rebase. If a conflict touches the design or any growth/lifecycle file, stop and resolve from current repository truth rather than accepting either side wholesale. + +- [ ] **Step 3: Re-run the merged-foundation baseline** + +```bash +NX_DAEMON=false npx nx run-many -t test --projects=growth,lifecycle --outputStyle=static +``` + +Expected: PASS before implementation begins. + +## Task 1: Block imported contacts from campaign enrollment + +**Files:** + +- Modify: `libs/growth/src/lib/jobs.spec.ts` +- Modify: `libs/growth/src/lib/jobs.ts` +- Modify: `libs/growth/test/jobs.integration.spec.ts` + +- [ ] **Step 1: Add a failing SQL-shape assertion** + +In the existing `materializes only post-launch approvals` unit test, assert the enrollment query contains this contact-level backstop: + +```ts +expect(sql).toMatch( + /not exists \([\s\S]*from growth_jobs legacy[\s\S]*legacy\.contact_id = c\.id[\s\S]*legacy\.kind = 'legacy'/u +); +``` + +- [ ] **Step 2: Run the focused unit test and prove it fails** + +Run: + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config libs/growth/vite.config.mts libs/growth/src/lib/jobs.spec.ts +``` + +Expected: FAIL because `materializeCampaignEnrollment()` does not query `growth_jobs legacy`. + +- [ ] **Step 3: Add the minimal eligibility predicate** + +In the `eligible` CTE in `materializeCampaignEnrollment()`, place this before the existing `campaign.enrolled:v1` check: + +```sql +and not exists ( + select 1 + from growth_jobs legacy + where legacy.contact_id = c.id + and legacy.kind = 'legacy' +) +``` + +- [ ] **Step 4: Add a real-database integration case** + +In `libs/growth/test/jobs.integration.spec.ts`, create two approved post-launch contacts. Give one a terminal legacy marker: + +```sql +insert into growth_jobs ( + kind, contact_id, status, available_at, idempotency_key, payload +) values ( + 'legacy', $1, 'cancelled', $2, + 'legacy:resend:contact:test-imported', + '{"legacy_type":"contact_marker","provider":"resend"}'::jsonb +) +``` + +Call `materializeCampaignEnrollment()` and assert only the control contact receives `campaign.enrolled:v1` plus three `send_step` jobs. Then add a later `form.outreach_approved` activity and update `outreach_approved_at` for the imported contact; call again and assert it is still excluded. + +- [ ] **Step 5: Run unit tests, then the disposable-Neon integration test** + +Run the unit command from Step 2. Expected: PASS. + +Run with the already-provisioned disposable test connection loaded without printing it: + +```bash +NX_DAEMON=false npx nx test-integration growth --outputStyle=static +``` + +Expected: PASS, including the new imported-contact case. + +- [ ] **Step 6: Commit** + +```bash +git add libs/growth/src/lib/jobs.ts libs/growth/src/lib/jobs.spec.ts libs/growth/test/jobs.integration.spec.ts +git commit -S -m "fix: exclude imported contacts from lifecycle campaign" +``` + +## Task 2: Mark every imported Resend contact and enforce the timing preflight + +**Files:** + +- Modify: `scripts/import-resend-lifecycle.spec.ts` +- Modify: `scripts/import-resend-lifecycle.mts` + +- [ ] **Step 1: Add failing importer tests** + +Add cases proving: + +1. One contact and zero scheduled messages creates one terminal legacy contact marker. +2. One contact and one scheduled message creates one marker plus one provider-bound legacy schedule job. +3. Reapplying the same snapshot creates neither duplicate. +4. Import never sets `outreach_approved_at`. +5. The apply path rejects before loading keys or creating a database executor when the earliest scheduled message does not allow a 30-minute processing window plus a five-minute delivery margin. +6. A snapshot at the exact 35-minute boundary is accepted. +7. Every scheduled-message payload includes `legacy_type: 'scheduled_message'`, and replay validation rejects an old or conflicting payload shape. +8. A successful import persists one immutable cutover configuration activity containing snapshot time, cancellation deadline, aggregate counts, and a SHA-256 identity of the sorted opaque provider contact/message IDs. Reapply validates rather than replaces it. + +Use deterministic keys: + +```ts +const contactMarkerKey = `legacy:resend:contact:${providerContactId}`; +const scheduledKey = `legacy:resend:scheduled:${providerEmailId}`; +``` + +The marker row must be: + +```ts +{ + kind: 'legacy', + status: 'cancelled', + provider_email_id: null, + delivery_status: 'not_submitted', + payload: { + imported: true, + legacy_type: 'contact_marker', + provider: 'resend', + provider_contact_id: providerContactId, + }, +} +``` + +Provider IDs remain in Neon but must not appear in CLI output or thrown error text. + +- [ ] **Step 2: Run the operator test and prove it fails** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config libs/growth/vite.operator-cli.config.mts scripts/import-resend-lifecycle.spec.ts +``` + +Expected: FAIL because contacts without schedules have no marker and no timing guard exists. + +- [ ] **Step 3: Implement marker creation inside the existing import transaction** + +After `importContact()` returns each contact, insert the deterministic marker with `ON CONFLICT (idempotency_key) DO NOTHING`, validate an existing row exactly on replay, and add separate aggregate result fields: + +```ts +legacy_contact_markers_created: number; +legacy_contact_markers_existing: number; +legacy_scheduled_jobs_created: number; +legacy_scheduled_jobs_existing: number; +``` + +Do not overload the marker with schedule state. Keep the existing provider-bound job per scheduled message. + +Change the scheduled-message payload to: + +```ts +const payload = { + imported: true, + legacy_type: 'scheduled_message', + provider: 'resend', + provider_state: 'scheduled', +}; +``` + +Validate this exact payload on replay so the later cancellation query cannot silently miss an older row. + +- [ ] **Step 4: Implement a pure timing preflight** + +Add constants and a pure exported helper: + +```ts +const MIN_CANCELLATION_WORK_MS = 30 * 60_000; +const DELIVERY_SAFETY_MARGIN_MS = 5 * 60_000; + +export function cancellationDeadline( + snapshot: ResendLifecycleSnapshot, + snapshotAt: Date +): Date | null { + if (snapshot.scheduledEmails.length === 0) return null; + const earliest = Math.min( + ...snapshot.scheduledEmails.map(({ scheduled_at }) => + new Date(scheduled_at).getTime() + ) + ); + const deadline = new Date(earliest - DELIVERY_SAFETY_MARGIN_MS); + if (deadline.getTime() - snapshotAt.getTime() < MIN_CANCELLATION_WORK_MS) { + fail('snapshot_cancellation_window_insufficient'); + } + return deadline; +} +``` + +Call it after exact count validation but before key loading, database creation, or provider mutation. Use one captured `now()` value for preflight and import. + +Inside the successful import transaction, persist the authority used by the separate cancellation process: + +```ts +{ + event_key: 'legacy:resend:cutover:v1:configuration', + kind: 'legacy.resend_cutover_configured', + occurred_at: snapshotAt, + data: { + snapshot_at: snapshotAt.toISOString(), + cancellation_deadline: deadline?.toISOString() ?? null, + expected_contacts: prepared.contacts.length, + expected_scheduled: prepared.scheduled.length, + snapshot_identity: sha256OfSortedOpaqueProviderIds, + }, +} +``` + +`snapshot_identity` hashes only opaque provider IDs and structural separators, never email or other PII. `ON CONFLICT` must read and validate the exact stored counts, deadline, and identity; it must not move the deadline. Add only the deadline timestamp and remaining seconds to aggregate CLI output. + +- [ ] **Step 5: Run focused tests and type/build checks** + +Run the command from Step 2, then: + +```bash +NX_DAEMON=false npx nx test-operator-cli growth --outputStyle=static +NX_DAEMON=false npx nx build growth --outputStyle=static +``` + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/import-resend-lifecycle.mts scripts/import-resend-lifecycle.spec.ts +git commit -S -m "feat: mark imported lifecycle contacts" +``` + +## Task 3: Add the one-record-at-a-time legacy cancellation operator + +**Files:** + +- Create: `scripts/cancel-resend-lifecycle.mts` +- Create: `scripts/cancel-resend-lifecycle.spec.ts` +- Modify: `libs/growth/project.json` +- Modify: `libs/growth/vite.operator-cli.config.mts` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md` + +- [ ] **Step 1: Write the failing operator contract tests** + +Define a dependency-injected `mainCancelResendLifecycle()` and test: + +- `--dry-run` reads Neon and Resend, emits aggregate counts only, and never calls cancel. +- `--apply` requires `--expected-scheduled N` and the same `TEST_DATABASE_URL` versus acknowledged `DATABASE_URL` guard as the importer. +- Any currently scheduled Resend ID outside the complete immutable imported schedule set halts before mutation. +- Missing unresolved IDs are checked with `emails.get()` before comparing the remaining verified-scheduled subset with the provider scheduled set. +- When configured `expected_scheduled > 0`, the stored cancellation deadline must be non-null and still in the future. +- When configured `expected_scheduled === 0`, the deadline must be null, both imported/provider schedule inventories must be empty, and the operator succeeds with zero `get` or `cancel` calls. +- Each call is `client.emails.cancel(exactProviderId)`; there is no bulk or recipient-derived call. +- A confirmed cancellation sets only its exact job to terminal state and records one stable activity event. +- Provider error or ambiguous result keeps the exact job unresolved with a closed `last_error_code`. +- If provider cancellation succeeds but Neon settlement fails, a rerun uses `emails.get(exactProviderId)` and settles only when the exact record reports `last_event === 'canceled'`; it does not issue a second cancel. +- Missing, malformed, delivered, or otherwise ambiguous exact-record lookup remains unresolved and halts cutover. +- A provider-unsubscribed imported contact whose stop transaction already changed the job's `status` to `cancelled` is still selected while `payload.provider_state === 'scheduled'`. +- A final paginated re-list must contain zero scheduled messages. +- A rerun after full settlement makes zero cancellation calls and returns success. +- Output and errors contain no `@`, provider ID, subject, recipient, or raw provider error. + +Use this public surface: + +```ts +export interface LegacyCancellationClient { + emails: { + list(options: { limit: number; after?: string }): Promise>; + cancel(id: string): Promise<{ data: unknown | null; error: unknown | null }>; + get(id: string): Promise<{ data: unknown | null; error: unknown | null }>; + }; +} + +export async function mainCancelResendLifecycle( + argv: readonly string[], + dependencies: LegacyCancellationDependencies +): Promise; +``` + +- [ ] **Step 2: Run the new spec and prove it fails** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config libs/growth/vite.operator-cli.config.mts scripts/cancel-resend-lifecycle.spec.ts +``` + +Expected: FAIL because the file is absent. + +- [ ] **Step 3: Implement bounded inventory loading and guards** + +Read and validate `legacy:resend:cutover:v1:configuration`, including deadline, counts, and snapshot identity. Reconstruct the complete immutable inventory with two queries that do not filter on job status or provider state: + +```sql +select payload->>'provider_contact_id' as provider_contact_id +from growth_jobs +where kind = 'legacy' + and provider_email_id is null + and payload->>'legacy_type' = 'contact_marker' +order by payload->>'provider_contact_id' +``` + +```sql +select id, contact_id, available_at, provider_email_id, status, payload +from growth_jobs +where kind = 'legacy' + and provider_email_id is not null + and payload->>'legacy_type' = 'scheduled_message' +order by provider_email_id +``` + +Recompute the configured snapshot identity from all contact-marker IDs and all scheduled-message IDs, including already reconciled rows. Validate exact contact and scheduled counts plus the stored hash before selecting work. Require a future stored deadline only when the configured scheduled count is positive. For zero schedules, require a null deadline and empty immutable, unresolved, and provider scheduled inventories. + +Provider reconciliation is then keyed by `payload.provider_state`, not job `status`, because `stopContact()` may already cancel the local job for an unsubscribed contact while the Resend schedule remains live. Derive the unresolved subset with: + +```sql +select id, contact_id, available_at, provider_email_id, status, payload +from growth_jobs +where kind = 'legacy' + and provider_email_id is not null + and payload->>'legacy_type' = 'scheduled_message' + and payload->>'provider_state' = 'scheduled' +order by provider_email_id +``` + +Include both `pending` and locally `cancelled` jobs while `payload->>'provider_state' = 'scheduled'`. Reject null/duplicate/unbounded IDs, unknown job state, count/snapshot-identity drift, database-environment mismatch, a currently scheduled provider ID outside the full immutable imported schedule set, or an expired stored deadline before the first cancel call. Do not require equality with the unresolved subset yet; missing unresolved IDs must pass exact-record recovery first. + +- [ ] **Step 4: Implement exact settlement** + +After each successful provider cancellation, use one transaction to update the exact job and insert a stable activity: + +```sql +update growth_jobs +set status = 'cancelled', + lease_token = null, + lease_until = null, + last_error_code = null, + payload = payload || jsonb_build_object( + 'provider_state', 'cancelled', + 'cancelled_at', $2::timestamptz + ) +where id = $1 + and kind = 'legacy' + and provider_email_id = $3 + and payload->>'legacy_type' = 'scheduled_message' + and payload->>'provider_state' = 'scheduled' +returning id +``` + +Insert `legacy.resend_schedule_cancelled` with `event_key = 'legacy:resend:scheduled:' || job_id || ':cancelled'`. On provider failure, preserve the local job status, leave `payload.provider_state='scheduled'`, write one closed error category, and continue only long enough to produce a complete unresolved aggregate. Never mark an absent-but-unconfirmed provider record as cancelled. + +- [ ] **Step 5: Re-list and require zero** + +Before issuing a cancel, classify each unresolved imported ID from the current scheduled list. If it is absent, call `emails.get(id)`: + +- `last_event === 'canceled'`: settle Neon without another cancellation call. +- Still scheduled: retain it in the verified-scheduled subset. +- Delivered, sent, failed, unknown, missing, or malformed: persist a closed unresolved category and halt the cutover. + +This exact-record reconciliation is mandatory after a prior provider success followed by a Neon failure. After settling every verified canceled record, re-query the unresolved subset and require its ID set to equal the verified-scheduled provider set. Only then call `emails.cancel(id)` once for each remaining exact ID. + +After the per-record cancellation pass, use the same bounded pagination as the importer. Success requires: + +```ts +{ + unresolved_imported: 0, + unexpected_provider_scheduled: 0, + provider_scheduled_remaining: 0, +} +``` + +If the deadline passes at any checkpoint, stop further mutation, persist the unresolved state, and fail. + +- [ ] **Step 6: Wire repository commands** + +Add: + +```json +"growth:cancel-resend": "tsx scripts/cancel-resend-lifecycle.mts" +``` + +Add `scripts/cancel-resend-lifecycle*` to `growthLifecycleControlPlane` and its spec to `vite.operator-cli.config.mts`. Update the lockfile only through `npm install --package-lock-only --ignore-scripts` if package metadata actually changes; reject unrelated removals. + +- [ ] **Step 7: Replace the legacy drain runbook section** + +Document the exact sequence: Vercel Firewall block, in-flight request drain, stable provider inventories, final importer dry run, timing preflight, apply, cancellation dry run, cancellation apply, exact-record recovery, final provider re-list, hard-boundary deploy, and firewall removal. Replace rollback instructions so no prior website deployment may receive the three form POSTs: block them first, keep them blocked until a Neon-only boundary is restored, and reconcile accepted Neon/provider effects before reopening. Use placeholders for counts and never show provider IDs. + +- [ ] **Step 8: Run operator, growth, and lint checks** + +```bash +NX_DAEMON=false npx nx test-operator-cli growth --outputStyle=static +NX_DAEMON=false npx nx test growth --outputStyle=static +NX_DAEMON=false npx nx lint growth --outputStyle=static +``` + +Expected: all PASS. + +- [ ] **Step 9: Commit** + +```bash +git add scripts/cancel-resend-lifecycle.mts scripts/cancel-resend-lifecycle.spec.ts libs/growth/project.json libs/growth/vite.operator-cli.config.mts package.json package-lock.json docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md +git commit -S -m "feat: reconcile scheduled lifecycle mail" +``` + +## Task 4: Establish the fail-closed website growth boundary + +**Files:** + +- Create: `apps/website/src/app/api/_internal/read-bounded-body.ts` +- Create: `apps/website/src/app/api/_internal/read-bounded-body.spec.ts` +- Create: `apps/website/src/lib/growth/email-keyring.ts` +- Create: `apps/website/src/lib/growth/form-client.ts` +- Create: `apps/website/src/lib/growth/form-client.spec.ts` +- Create: `apps/website/src/lib/growth/form-policy.ts` +- Create: `apps/website/src/lib/growth/form-policy.spec.ts` +- Create: `apps/website/src/lib/growth/form-route.ts` +- Create: `apps/website/src/lib/growth/form-route.spec.ts` +- Create: `apps/website/src/lib/growth/lifecycle-client.ts` +- Create: `apps/website/src/lib/growth/lifecycle-client.spec.ts` +- Modify: `apps/website/tsconfig.json` +- Modify: `package.json` +- Modify: `package-lock.json` + +- [ ] **Step 1: Port the focused helper tests, then harden the policy tests** + +Use the source draft versions as the starting point, but change the policy expectations to: + +```ts +expect(() => getFormPolicy({})).toThrow(/GROWTH_FORM_POLICY/u); +expect(() => getFormPolicy({ GROWTH_FORM_POLICY: 'legacy' })).toThrow(); +expect(() => getFormPolicy({ GROWTH_FORM_POLICY: 'unknown' })).toThrow(); +expect(getFormPolicy({ GROWTH_FORM_POLICY: 'growth_v1' })).toEqual({ + mode: 'growth_v1', + version: GROWTH_FORM_POLICY_VERSION, + disclosures: expect.objectContaining({ + whitepaper: expect.any(String), + newsletter: expect.any(String), + contact: expect.any(String), + }), +}); +``` + +Retain tests for bounded body streaming, immutable retry snapshots, acquisition-session UUIDs, PII-free nudges, timeout, and secret-safe errors. + +- [ ] **Step 2: Run the focused website tests and prove they fail** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config apps/website/vite.config.mts apps/website/src/app/api/_internal/read-bounded-body.spec.ts apps/website/src/lib/growth/form-client.spec.ts apps/website/src/lib/growth/form-policy.spec.ts apps/website/src/lib/growth/form-route.spec.ts apps/website/src/lib/growth/lifecycle-client.spec.ts +``` + +Expected: FAIL because the files are absent. + +- [ ] **Step 3: Port helpers from the source draft using `apply_patch`** + +Port the six focused modules without copying any other source-worktree file. Preserve dependency injection and closed errors. Add only this path to `apps/website/tsconfig.json`: + +```json +"@threadplane-internal/growth": ["../../libs/growth/src/index.ts"] +``` + +Add `server-only@^0.0.1` to the root package metadata and lockfile without accepting the draft's unrelated dependency changes. + +- [ ] **Step 4: Remove legacy from the policy type and runtime** + +The final policy module must have no `LEGACY_POLICY` and no default: + +```ts +export interface PublicFormPolicy { + mode: 'growth_v1'; + version: typeof GROWTH_FORM_POLICY_VERSION; + disclosures: { + contact: string; + newsletter: string; + whitepaper: string; + }; +} + +export function getFormPolicy( + environment: Readonly> = process.env +): PublicFormPolicy { + if (environment['GROWTH_FORM_POLICY']?.trim() !== 'growth_v1') { + throw new Error('GROWTH_FORM_POLICY must be growth_v1'); + } + return GROWTH_V1_POLICY; +} +``` + +- [ ] **Step 5: Run focused tests and the website type/build surface** + +Run the command from Step 2. Expected: PASS. + +```bash +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx test website --outputStyle=static +``` + +Expected: existing website tests plus new helpers PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/app/api/_internal apps/website/src/lib/growth apps/website/tsconfig.json package.json package-lock.json +git commit -S -m "feat: add durable website growth boundary" +``` + +## Task 5: Cut all acquisition forms over to Neon-only acceptance + +**Files:** + +- Modify: `apps/website/src/app/api/whitepaper-signup/route.ts` +- Create: `apps/website/src/app/api/whitepaper-signup/route.spec.ts` +- Modify: `apps/website/src/app/api/newsletter/route.ts` +- Create: `apps/website/src/app/api/newsletter/route.spec.ts` +- Modify: `apps/website/src/app/api/leads/route.ts` +- Modify: `apps/website/src/app/api/leads/route.spec.ts` + +- [ ] **Step 1: Port and rewrite route tests as growth-only contracts** + +Start from the source draft's `growth_v1` describes. Delete every expectation for NDJSON, Loops, Resend Audience, synchronous email, legacy analytics, or `legacyPost`. Add a common assertion per route: + +```ts +expect(accept).toHaveBeenCalledOnce(); +expect(accept.mock.invocationCallOrder[0]).toBeLessThan( + nudge.mock.invocationCallOrder[0] +); +``` + +Required cases per route: committed success, stale/missing policy `409`, malformed/oversized body `400`, invalid email `400`, invalid UUID `400`, database/keyring setup `503`, Neon transaction `503`, post-commit nudge failure still `200`, and same-UUID replay without duplicate logical jobs. + +- [ ] **Step 2: Run the three route specs and prove the growth-only expectations fail** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config apps/website/vite.config.mts apps/website/src/app/api/whitepaper-signup/route.spec.ts apps/website/src/app/api/newsletter/route.spec.ts apps/website/src/app/api/leads/route.spec.ts +``` + +Expected: FAIL while the current routes still import and execute legacy helpers. + +- [ ] **Step 3: Replace the routes with their source-draft durable branches only** + +Remove `legacyPost()` and all imports of `fs`, `path`, `getSourcePage`, website email templates, `lib/drip`, `lib/loops`, `lib/resend`, and `lib/analytics/server`. Each route must follow only: + +```ts +const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); +const policy = dependencies.getPolicy(); +if (!matchesSubmittedFormPolicy(policy, submittedVersion)) { + return stalePolicyResponse(policy); +} +// Strict field validation and normalizeRecipientEmail. +// Create DB + load keyring. +await dependencies.accept(database, input); +// Close DB. +await dependencies.nudge({ submissionId }).catch(() => undefined); +return jsonResponse({ ok: true }); +``` + +No code path may send or schedule email from the request. + +- [ ] **Step 4: Add cross-route static boundary assertions** + +Create `apps/website/src/lib/growth/hard-cutover-boundary.spec.ts`. Read only the three production route source files and reject these patterns: + +```ts +const forbidden = [ + /from ['"](?:node:)?fs['"]/u, + /lib\/loops/u, + /lib\/resend/u, + /lib\/drip/u, + /scheduleWhitepaperDrip/u, + /addToAudience/u, + /sendEmail\(/u, + /\.ndjson/u, + /legacyPost/u, +]; +``` + +- [ ] **Step 5: Run focused and full website unit tests** + +Run the command from Step 2, then: + +```bash +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx test website --outputStyle=static +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/app/api/whitepaper-signup apps/website/src/app/api/newsletter apps/website/src/app/api/leads apps/website/src/lib/growth/hard-cutover-boundary.spec.ts +git commit -S -m "feat: persist acquisition forms in neon" +``` + +## Task 6: Make every rendered form submit the immutable growth envelope + +**Files:** + +- Modify the five form components and their focused specs listed in the file map. +- Modify `apps/website/src/components/shared/SiteFooter.tsx` and `SiteFooter.spec.tsx`. +- Modify only policy-prop hunks in the eleven page/layout files listed in the file map, including `ag-ui/page.tsx`. +- Modify: `apps/website/playwright.config.ts` +- Modify: `apps/website/e2e/website.spec.ts` + +- [ ] **Step 1: Port focused component tests and add hard-cutover assertions** + +Each form spec must prove: + +- The disclosure text is visible and referenced with `aria-describedby`. +- The request contains `submission_id`, `policy_version`, and optional `acquisition_session_id` plus the declared form facts. +- An uncertain retry reuses the same UUID and immutable facts. +- A changed form creates a new UUID. +- `409` displays the refresh/retry message and does not report success. +- No component contains a `formPolicy.mode === 'legacy'` branch. + +Add a `SiteFooter` test proving it passes the exact policy object to `Footer` on marketing routes and still renders nothing on `/docs` routes. + +- [ ] **Step 2: Run focused component tests and prove they fail** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config apps/website/vite.config.mts apps/website/src/components/landing/WhitePaperBlock.spec.tsx apps/website/src/components/shared/AnnouncementToast.spec.tsx apps/website/src/components/shared/Footer.spec.tsx apps/website/src/components/shared/SiteFooter.spec.tsx apps/website/src/components/contact/ContactForm.spec.tsx apps/website/src/components/pricing/LeadForm.spec.tsx +``` + +Expected: FAIL because current components submit the legacy payload. + +- [ ] **Step 3: Port only the growth form behavior** + +Use `growthFormRequestSnapshot()` unconditionally. Accept `PublicFormPolicy` as a server-provided prop, render its matching disclosure, send the immutable snapshot, retain it after an uncertain failure, and clear it after success or a user fact change. Do not port visual redesign hunks. + +- [ ] **Step 4: Thread policy through pages without unrelated changes** + +In every listed server page/layout, add only `getFormPolicy()`, create `const formPolicy = getFormPolicy()`, and pass it to the relevant form. The root layout passes it through `SiteFooter` and directly to `AnnouncementToast`; `SiteFooter` passes it to `Footer`. `ag-ui/page.tsx` passes it to its existing `WhitePaperBlock`. Compare each reconstructed file against `origin/main` and ensure the diff contains no unrelated text, structure, style, telemetry, privacy, or cockpit changes. + +- [ ] **Step 5: Update local Playwright environment and four form assertions** + +Set `GROWTH_FORM_POLICY: 'growth_v1'` in `createLocalWebServerEnvironment()`. Update only contact, pricing, newsletter, and whitepaper E2E payload expectations to require UUID-shaped `submission_id` and the exact policy version. Keep network interception so E2E does not require Neon or send email. + +- [ ] **Step 6: Run component tests and browser form flows** + +Run Step 2. Expected: PASS. + +```bash +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx e2e website --outputStyle=static --grep "contact page submits|pricing lead form posts|footer newsletter form posts|whitepaper signup form posts" +``` + +Expected: four selected tests PASS with no external provider call. + +- [ ] **Step 7: Commit** + +Stage only the named files, inspect `git diff --cached`, then: + +```bash +git commit -S -m "feat: submit growth approval envelopes" +``` + +## Task 7: Land stop, webhook, reply, and cron adapters + +**Files:** + +- Modify: `apps/website/src/app/api/unsubscribe/route.ts` +- Create the four route directories/specs listed under “Stop and orchestration adapters”. +- Modify: `vercel.json` + +- [ ] **Step 1: Port all five route specs and correct legacy unsubscribe expectations** + +For the raw-email compatibility case, replace the source-draft test that treats internal failure as success. The required matrix is: + +```ts +// Healthy known and healthy unknown valid addresses: same 200 shape. +// Database/stop failure for any valid address: same retryable 503 shape. +// Malformed address: 400 without database access. +``` + +Add the same retry distinction for signed human confirmation and RFC one-click POST: malformed content or invalid/expired token remains the closed `400` response, while database creation, canonical stop failure, or database close failure returns the uniform retryable `503` response so clients can retry. + +Retain exact raw-body webhook verification, size limits, HMAC timestamp/replay checks, no-cookie confirmation pages, one-click POST parsing, closed errors, database cleanup, and cron-disabled behavior. + +- [ ] **Step 2: Run route tests and prove they fail** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config apps/website/vite.config.mts apps/website/src/app/api/unsubscribe/route.spec.ts apps/website/src/app/api/growth/stop/route.spec.ts apps/website/src/app/api/growth/replies/google/route.spec.ts apps/website/src/app/api/webhooks/resend/route.spec.ts apps/website/src/app/api/cron/lifecycle/route.spec.ts +``` + +Expected: FAIL because four routes are absent and legacy unsubscribe is not durable. + +- [ ] **Step 3: Port the adapters and fix false-success unsubscribe** + +In the raw-email GET branch, keep known/unknown non-enumeration but move `successResponse()` inside the successful database operation: + +```ts +try { + await withDatabase(dependencies, async (executor) => { + await dependencies.stopLegacyEmailUnsubscribe(executor, input); + }); + return successResponse(); +} catch { + return retryableFailureResponse(); +} +``` + +Do not log the email or internal error. New links and one-click POSTs use only signed opaque tokens. + +Apply the same server-failure response in the signed POST branch: + +```ts +try { + await withDatabase(dependencies, (executor) => + dependencies.stopContact(executor, signedStopInput(payload, receivedAt)) + ); + return successResponse(); +} catch { + return retryableFailureResponse(); +} +``` + +- [ ] **Step 4: Register exactly one Vercel cron** + +Add only: + +```json +"crons": [ + { "path": "/api/cron/lifecycle", "schedule": "* * * * *" } +] +``` + +The route must still return disabled without invoking Dawn unless `LIFECYCLE_CRON_ENABLED` is exactly `true`. + +- [ ] **Step 5: Run focused tests, website tests, and config checks** + +Run Step 2. Expected: PASS. + +```bash +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx test website --outputStyle=static +``` + +Expected: PASS, including one cron registration and failure-path tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/app/api/unsubscribe apps/website/src/app/api/growth apps/website/src/app/api/webhooks apps/website/src/app/api/cron vercel.json +git commit -S -m "feat: expose lifecycle stop and dispatch routes" +``` + +## Task 8: Delete the legacy delivery implementation and prove the production boundary + +**Files:** + +- Delete the legacy files listed under “Legacy deletion”. +- Modify: `apps/website/package.json` if `resend` becomes unused there. +- Modify: `package-lock.json` only as required. +- Modify: `apps/website/src/lib/growth/hard-cutover-boundary.spec.ts` + +- [ ] **Step 1: Expand the boundary test before deletion** + +Assert the legacy modules and preview route do not exist and production source contains no imports or calls to them. Also assert no affected route imports `apps/website/src/lib/analytics/server.ts`. + +- [ ] **Step 2: Run the boundary test and prove it fails** + +```bash +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config apps/website/vite.config.mts apps/website/src/lib/growth/hard-cutover-boundary.spec.ts +``` + +Expected: FAIL because the legacy modules still exist. + +- [ ] **Step 3: Delete only the now-unreferenced legacy implementation** + +Delete `apps/website/lib/{drip,loops,resend}.ts`, `/api/email-preview`, and all `apps/website/emails/*.ts`. Confirm first that `rg` finds no remaining import outside those files. Remove `resend` from `apps/website/package.json` only if no website source imports it; keep the root/lifecycle dependency used by the operator and Dawn delivery. + +- [ ] **Step 4: Regenerate only required package metadata** + +```bash +npm install --package-lock-only --ignore-scripts +``` + +Inspect the lockfile and revert any unrelated mechanical drift with a targeted patch; do not copy the dirty source lockfile. + +- [ ] **Step 5: Prove the legacy surface is gone** + +```bash +rg -n "scheduleWhitepaperDrip|loopsUpsertContact|loopsSendEvent|addToAudience|whitepaper-signups\.ndjson|leads\.ndjson|unsubscribed\.ndjson|legacyPost" apps/website +``` + +Expected: no production matches; only explicit forbidden-pattern strings inside the boundary spec are allowed. + +Run the boundary test. Expected: PASS. + +- [ ] **Step 6: Run website lint and production build** + +```bash +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx lint website --outputStyle=static +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx build website --outputStyle=static +``` + +Expected: PASS. Inspect `dist/apps/website/.next` and verify the affected functions contain no legacy module or NDJSON path. + +- [ ] **Step 7: Commit** + +```bash +git add -A apps/website package-lock.json +git commit -S -m "refactor: remove legacy website email pipeline" +``` + +## Task 9: Full verification, review, pull request, and merge + +**Files:** + +- Review all changed files; create no planned new production code. + +- [ ] **Step 1: Verify scope before broad tests** + +```bash +git status --short +git diff --stat origin/main...HEAD +git diff --name-status origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Expected: only files named in this plan and the committed design spec. No generated docs, `data/`, `tsconfig.tsbuildinfo`, telemetry, privacy, cockpit, or visual-redesign files. + +- [ ] **Step 2: Run all repository-native project gates** + +```bash +GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx run-many -t test lint build --projects=growth,lifecycle,website --outputStyle=static +NX_DAEMON=false npx nx run lifecycle:check --outputStyle=static +NX_DAEMON=false npx nx run growth:test-operator-cli --outputStyle=static +NX_DAEMON=false npx nx run growth:test-integration --outputStyle=static +``` + +Expected: every target PASS using the disposable Neon integration database for the final command. + +- [ ] **Step 3: Run production-mode website E2E** + +Build first, then: + +```bash +GROWTH_FORM_POLICY=growth_v1 WEBSITE_E2E_MODE=production NX_DAEMON=false npx nx e2e website --outputStyle=static +``` + +Expected: PASS; mocked form requests contain policy and UUID fields, and no provider effect occurs. + +- [ ] **Step 4: Run preview no-delivery canaries** + +With preview `DELIVERY_ENABLED=false`, `CAMPAIGN_ENROLLMENT_ENABLED=false`, `CAMPAIGN_ENABLED=false`, and cron disabled, deploy the branch preview. Submit one deterministic form fixture and verify one contact, approval activity, and expected fulfillment/enrichment/notification jobs in preview Neon, with zero Resend effects. Verify authenticated lifecycle health and two-instance Dawn persistence using the existing runbook. + +- [ ] **Step 5: Request independent code review** + +Invoke `@superpowers:requesting-code-review`. Resolve only evidence-backed findings, rerun affected tests after each change, and make one signed fix commit if needed. + +- [ ] **Step 6: Create the pull request** + +The PR title and body must describe the hard cutover, testing, operational gates, and explicit exclusions without referencing internal agent tooling. Do not include secrets, provider identifiers, contacts, or raw environment output. + +- [ ] **Step 7: Wait for green and merge** + +Require all branch protections and review gates to pass. Re-read the live check state immediately before merge. Merge on green as previously authorized; do not start production mutation on a merely pending or stale check result. + +## Task 10: Provision and deploy production with every effect switch off + +**Files:** + +- No repository edits. Follow `docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md`. + +- [ ] **Step 1: Re-read provider state before mutation** + +Verify the current Vercel team/project linkage, the live website project, the lifecycle project, available Neon Marketplace installation, and production environment metadata. Never assume preview resource IDs or historical Resend counts apply to production. + +- [ ] **Step 2: Provision two distinct production Neon resources** + +Create one Growth production resource connected to both website and lifecycle projects as `DATABASE_URL`, and a separate Dawn production resource connected only to lifecycle as `DAWN_DATABASE_URL`. Require different resource IDs and production-only environment scope. + +- [ ] **Step 3: Apply and verify migrations** + +Run database preflight, apply the committed migrations to Growth production, and verify reporting views. Initialize Dawn storage only through the lifecycle service's supported deployment path. Do not alias Dawn storage to Growth. + +- [ ] **Step 4: Install production secrets and fail-closed flags** + +Website: growth database environment, form policy, token/email keyrings, webhook secret, Google reply secret, cron secret, lifecycle URL and service secret. + +Lifecycle: Growth and Dawn connections, service secret, model/provider keys, sender configuration, action-token keyring, founder address, and exact environment policy. + +Set: + +```text +LIFECYCLE_CRON_ENABLED=false +DELIVERY_ENABLED=false +CAMPAIGN_ENROLLMENT_ENABLED=false +CAMPAIGN_ENABLED=false +GROWTH_FORM_POLICY=growth_v1 +``` + +- [ ] **Step 5: Deploy lifecycle first** + +Deploy the merged commit, verify unauthenticated rejection and authenticated real `/healthz`, then prove no job leasing or provider delivery can occur with switches off. + +- [ ] **Step 6: Prepare—but do not promote—the website deployment** + +Build/deploy the merged website artifact with `growth_v1`. Verify configuration, database reachability, lifecycle reachability, and bundle absence of legacy code on its deployment URL. Do not route production form traffic to it yet. + +- [ ] **Step 7: Prepare and verify the rollback control before cutover** + +Create the exact Vercel Firewall rule definition used to block the three form POST paths and verify it can be installed without affecting ordinary GET traffic. Record the current and prepared deployment identifiers privately. The rollback order is fixed: disable campaign/enrollment and recipient delivery as appropriate; install and verify the three-route block; wait for in-flight requests; select a prior deployment only behind that block; reconcile Neon jobs and all provider effects; restore a deployment that retains the Neon-only form boundary; then remove the block. Forms must never reopen on the old NDJSON/Loops/Resend scheduling implementation. + +## Task 11: Execute the quiescent hard cutover + +**Files:** + +- No repository edits. Record aggregate-only evidence in the private operator worksheet described by the runbook. + +- [ ] **Step 1: Install and verify the temporary form-route block** + +Create a Vercel Firewall rule that returns a retryable maintenance response only for POST requests to: + +```text +/api/whitepaper-signup +/api/newsletter +/api/leads +``` + +Verify all three production POSTs are blocked and ordinary site GETs remain available. + +- [ ] **Step 2: Drain pre-block legacy invocations and prove inventory stability** + +Resolve the maximum execution duration of the currently deployed legacy form functions from the production deployment configuration. Wait that entire duration plus a 30-second margin after the firewall block becomes effective. Use Vercel runtime observability to require zero active invocations for the three paths, then take two complete aggregate Resend contact/scheduled inventories 60 seconds apart. Both inventories must have identical counts and scheduled-ID set hashes. If they drift, keep the firewall active and restart the drain interval. + +- [ ] **Step 3: Take the authoritative snapshot and timing preflight** + +Run the importer dry run, approve exact aggregate counts privately, and confirm the earliest scheduled delivery leaves the required 30-minute work window plus five-minute margin. If it does not, make no DB/provider mutation, remove the firewall rule, and select a later safe window. + +- [ ] **Step 4: Import the final snapshot** + +Run the production-acknowledged importer with exact expected counts. Verify: + +- every provider contact has one terminal marker; +- every scheduled message has one provider-bound legacy job; +- zero approval timestamps were granted; +- re-running is idempotent. + +- [ ] **Step 5: Cancel and reconcile every scheduled message** + +Run cancellation dry-run, approve its aggregate inventory, then apply. Require one supported Resend cancel operation per exact imported job, durable settlement, zero unresolved jobs, zero unexpected provider schedules, zero final scheduled messages, and completion before the deadline. + +If any condition fails, keep the firewall and all campaign switches off. Follow the prepared rollback order if deployment rollback is required. Do not claim cutover completion or route form POSTs to a prior deployment. + +- [ ] **Step 6: Promote the hard-boundary website while forms remain blocked** + +Promote the prepared website deployment. Verify production is on the merged commit, `growth_v1` is active, and static/runtime probes find no NDJSON, Loops, Audience, direct send, or provider scheduling path. + +- [ ] **Step 7: Reopen forms and capture the immutable cohort instant** + +Remove the firewall rule. Record that exact instant for `CAMPAIGN_ENROLLMENT_START_AT`. Submit one allowlisted no-delivery form smoke test and verify its Neon transaction and jobs. Suppress or delete the synthetic contact through the approved exact-key cleanup so it cannot enter the real campaign. + +## Task 12: Verify callbacks, enable fulfillment, then enable the campaign + +**Files:** + +- No repository edits unless runtime testing reveals a defect; defects return to a new tested pull request rather than being patched directly in production. + +- [ ] **Step 1: Register and verify the Resend webhook** + +Register the exact production webhook URL and allowed delivery events. Verify a signed fixture changes the exact job delivery state and a forged/stale signature produces no mutation. Leave open/click tracking disabled. + +- [ ] **Step 2: Install and initialize Google mailbox polling** + +Under the intended Google Business Starter mailbox owner, install the committed Apps Script, set only Script Properties, initialize the history watermark once, and create exactly one minute trigger. Verify metadata/header-only callback behavior and no message-body persistence. + +- [ ] **Step 3: Enable cron with recipient delivery still off** + +Set `LIFECYCLE_CRON_ENABLED=true`. Verify durable jobs are discoverable without provider submission, no duplicate leases occur across two lifecycle instances, and no mailbox recovery condition is open. + +- [ ] **Step 4: Enable fulfillment delivery only** + +Set `DELIVERY_ENABLED=true` while campaign enrollment and campaign delivery stay false. Submit one allowlisted production whitepaper request. Require exactly one plain-text fulfillment, persisted provider ID, webhook delivery state, correct Reply-To, signed unsubscribe headers, authenticated domain results, and no tracking. + +- [ ] **Step 5: Verify natural reply stop** + +Reply from the canary recipient. Require the Google poller to record the reply stop, clear `outreach_approved_at`, and cancel pending campaign work without persisting body, subject, or snippet. + +- [ ] **Step 6: Set the immutable cohort timestamp and inspect enrollment** + +Set `CAMPAIGN_ENROLLMENT_START_AT` to the recorded form-reopen instant. Set `CAMPAIGN_ENROLLMENT_ENABLED=true` while `CAMPAIGN_ENABLED=false`. Inspect aggregate cohort counts and prove every imported legacy marker is excluded. + +- [ ] **Step 7: Enable campaign delivery last** + +Start with an internal/allowlisted new contact, set `CAMPAIGN_ENABLED=true`, and verify step 1 exactly once. Then allow the small new post-cutover cohort. Stop immediately on duplicate acceptance, unknown provider outcome, reply/suppression bypass, unexpected recipient, or legacy-contact enrollment. + +- [ ] **Step 8: Verify rollback invariants remain available** + +Before declaring success, confirm the documented firewall rule still targets exactly the three POST routes, the prior deployment cannot receive those requests without the rule, and the operator worksheet can reconcile every Neon acceptance/provider effect since forms reopened. If rollback is needed, execute the fixed order from Task 10 Step 7 and keep forms blocked until a Neon-only deployment is restored. + +- [ ] **Step 9: Close the cutover only with evidence** + +Record aggregate verification results, merged commit, deployment identifiers, timestamps, and switch state without secrets or PII. Confirm the firewall is removed, legacy schedules are zero, the synthetic fixture is cleaned, callbacks are healthy, and the campaign cohort contains only eligible post-cutover contacts. + +## Completion criteria + +- One reviewed and green pull request contains only the approved hard-cutover scope. +- All imported Resend contacts have durable legacy exclusion markers. +- All outstanding legacy scheduled messages are cancelled and reconciled before their safety deadline. +- Production forms write only to Neon and never execute the removed legacy side effects. +- Whitepaper fulfillment works exactly once through Dawn and Resend. +- Webhook, signed unsubscribe, founder stop, and Google reply paths durably suppress later campaign steps. +- Campaign enrollment begins at the immutable reopen timestamp and excludes all imported contacts. +- Neon contains the operational and reporting trail needed for the next enrichment/reporting arc. From 392bcf6ebe5d9462457c1673a9bdfe8a76bb5e2b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 10:29:19 -0700 Subject: [PATCH 03/15] fix: exclude imported contacts from lifecycle campaign --- libs/growth/src/lib/jobs.spec.ts | 3 + libs/growth/src/lib/jobs.ts | 6 ++ libs/growth/test/jobs.integration.spec.ts | 98 +++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/libs/growth/src/lib/jobs.spec.ts b/libs/growth/src/lib/jobs.spec.ts index 0b1804c36..47f4d3061 100644 --- a/libs/growth/src/lib/jobs.spec.ts +++ b/libs/growth/src/lib/jobs.spec.ts @@ -151,6 +151,9 @@ describe('campaign enrollment', () => { expect(sql).toMatch( /stop\.occurred_at\s*>=\s*c\.outreach_approved_at/u ); + expect(sql).toMatch( + /not exists \([\s\S]*from growth_jobs legacy[\s\S]*legacy\.contact_id = c\.id[\s\S]*legacy\.kind = 'legacy'/u + ); expect(sql).toMatch(/campaign\.enrolled:v1/u); expect(sql).toMatch(/'approval_event_key',\s*e\.approval_event_key/u); expect(sql).toMatch(/'approval_kind',\s*e\.approval_kind/u); diff --git a/libs/growth/src/lib/jobs.ts b/libs/growth/src/lib/jobs.ts index a5e4f802c..c8ed371c8 100644 --- a/libs/growth/src/lib/jobs.ts +++ b/libs/growth/src/lib/jobs.ts @@ -318,6 +318,12 @@ export async function materializeCampaignEnrollment( and stop.kind = any($4::text[]) and stop.occurred_at >= c.outreach_approved_at ) + and not exists ( + select 1 + from growth_jobs legacy + where legacy.contact_id = c.id + and legacy.kind = 'legacy' + ) and not exists ( select 1 from growth_activity a diff --git a/libs/growth/test/jobs.integration.spec.ts b/libs/growth/test/jobs.integration.spec.ts index fa4eb175f..3fbbfd6be 100644 --- a/libs/growth/test/jobs.integration.spec.ts +++ b/libs/growth/test/jobs.integration.spec.ts @@ -217,6 +217,104 @@ describeDatabase( ).toBe(true); }); + it('permanently excludes contacts with a terminal legacy marker', async () => { + const launchAt = new Date('2097-10-01T00:00:00.000Z'); + const approvedAt = new Date('2097-10-01T00:00:00.000Z'); + const enrollmentAt = new Date('2097-10-01T12:00:00.000Z'); + const imported = await createContact(approvedAt); + const control = await createContact(approvedAt); + await executor.execute( + `insert into growth_jobs ( + kind, contact_id, status, available_at, idempotency_key, payload + ) values ( + 'legacy', $1, 'cancelled', $2, + $3, + '{"legacy_type":"contact_marker","provider":"resend"}'::jsonb + )`, + [imported, approvedAt, `legacy:resend:contact:${imported}`] + ); + + const first = await materializeCampaignEnrollment(executor, { + enrollmentEnabled: true, + enrollmentStartAt: launchAt, + now: enrollmentAt, + batchSize: 10, + }); + + expect(first).toEqual({ enrolledContactIds: [control], createdJobs: 3 }); + const firstActivities = await executor.execute<{ + contact_id: string; + count: string; + }>( + `select contact_id, count(*)::text as count + from growth_activity + where kind = 'campaign.enrolled:v1' + and contact_id = any($1::uuid[]) + group by contact_id`, + [[imported, control]] + ); + expect(firstActivities.rows).toEqual([ + { contact_id: control, count: '1' }, + ]); + const firstJobs = await executor.execute<{ + contact_id: string; + count: string; + }>( + `select contact_id, count(*)::text as count + from growth_jobs + where kind = 'send_step' + and contact_id = any($1::uuid[]) + group by contact_id`, + [[imported, control]] + ); + expect(firstJobs.rows).toEqual([{ contact_id: control, count: '3' }]); + + const laterApprovalAt = new Date('2097-10-01T13:00:00.000Z'); + await executor.execute( + `insert into growth_activity ( + event_key, contact_id, kind, occurred_at, data + ) values ($1, $2, 'form.outreach_approved', $3, $4::jsonb)`, + [ + `jobs-integration:approval:later:${imported}`, + imported, + laterApprovalAt, + JSON.stringify({ + source_form: 'pricing', + verification: 'server_verified', + }), + ] + ); + await executor.execute( + `update growth_contacts + set outreach_approved_at = $2 + where id = $1`, + [imported, laterApprovalAt] + ); + + const replay = await materializeCampaignEnrollment(executor, { + enrollmentEnabled: true, + enrollmentStartAt: launchAt, + now: new Date('2097-10-01T13:01:00.000Z'), + batchSize: 10, + }); + + expect(replay).toEqual({ enrolledContactIds: [], createdJobs: 0 }); + const importedActivity = await executor.execute<{ count: string }>( + `select count(*)::text as count + from growth_activity + where contact_id = $1 and kind = 'campaign.enrolled:v1'`, + [imported] + ); + expect(importedActivity.rows).toEqual([{ count: '0' }]); + const importedJobs = await executor.execute<{ count: string }>( + `select count(*)::text as count + from growth_jobs + where contact_id = $1 and kind = 'send_step'`, + [imported] + ); + expect(importedJobs.rows).toEqual([{ count: '0' }]); + }); + it('anchors fixed elapsed-hour cadence across DST and never compresses after pause', async () => { const enrollmentAt = new Date('2026-03-07T19:00:00.000Z'); const contactId = await createContact(enrollmentAt); From c1b5a00aa030ad753b4b13aff5853c64d5c8e951 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 10:58:26 -0700 Subject: [PATCH 04/15] feat: mark imported lifecycle contacts --- scripts/import-resend-lifecycle.mts | 264 ++++++++- scripts/import-resend-lifecycle.spec.ts | 681 +++++++++++++++++++++++- 2 files changed, 922 insertions(+), 23 deletions(-) diff --git a/scripts/import-resend-lifecycle.mts b/scripts/import-resend-lifecycle.mts index fb83953a5..abd0db2ef 100644 --- a/scripts/import-resend-lifecycle.mts +++ b/scripts/import-resend-lifecycle.mts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -19,6 +20,11 @@ const PAGE_SIZE = 100; const MAX_PAGES = 100; const MAX_TOTAL_RECORDS = PAGE_SIZE * MAX_PAGES; const SOURCE = 'resend_legacy_import'; +const MIN_CANCELLATION_WORK_MS = 30 * 60_000; +const DELIVERY_SAFETY_MARGIN_MS = 5 * 60_000; +const CUTOVER_CONFIGURATION_EVENT_KEY = + 'legacy:resend:cutover:v1:configuration'; +const CUTOVER_CONFIGURATION_KIND = 'legacy.resend_cutover_configured'; const USAGE = 'Usage: npm run growth:import-resend -- --dry-run | --apply --expected-contacts N --expected-scheduled N [--allow-database-url-apply]'; @@ -80,8 +86,10 @@ export interface ResendLifecycleImportResult { contacts_created: number; contacts_existing: number; contacts_rekeyed: number; - legacy_jobs_created: number; - legacy_jobs_existing: number; + legacy_contact_markers_created: number; + legacy_contact_markers_existing: number; + legacy_scheduled_jobs_created: number; + legacy_scheduled_jobs_existing: number; legacy_provider_cancellations_required: number; } @@ -96,6 +104,7 @@ type FailureCode = | 'provider_emails_list_failed' | 'provider_emails_pagination_invalid' | 'provider_emails_payload_invalid' + | 'snapshot_cancellation_window_insufficient' | 'snapshot_count_drift' | 'snapshot_identity_conflict' | 'snapshot_scheduled_recipient_invalid' @@ -112,6 +121,23 @@ function fail(code: FailureCode): never { throw new ImportFailure(code); } +export function cancellationDeadline( + snapshot: ResendLifecycleSnapshot, + snapshotAt: Date +): Date | null { + if (snapshot.scheduledEmails.length === 0) return null; + const earliest = Math.min( + ...snapshot.scheduledEmails.map(({ scheduled_at }) => + new Date(scheduled_at).getTime() + ) + ); + const deadline = new Date(earliest - DELIVERY_SAFETY_MARGIN_MS); + if (deadline.getTime() - snapshotAt.getTime() < MIN_CANCELLATION_WORK_MS) { + fail('snapshot_cancellation_window_insufficient'); + } + return deadline; +} + function boundedProviderId(value: unknown): string { if ( typeof value !== 'string' || @@ -128,12 +154,43 @@ function validIsoDate( value: unknown, code: 'provider_contacts_payload_invalid' | 'provider_emails_payload_invalid' ): string { - if ( - typeof value !== 'string' || - value.length > 100 || - !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/u.test( + if (typeof value !== 'string' || value.length > 100) { + fail(code); + } + const match = + /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}(?::?\d{2})?)$/u.exec( value - ) + ); + if (!match) fail(code); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if ( + month < 1 || + month > 12 || + day < 1 || + day > (daysInMonth[month - 1] ?? 0) || + hour > 23 || + minute > 59 || + second > 59 ) { fail(code); } @@ -448,6 +505,95 @@ function canonicalJson(value: unknown): string { return JSON.stringify(normalize(value)); } +function preparedCancellationDeadline( + prepared: ReturnType +): Date | null { + if (prepared.scheduled.length === 0) return null; + return new Date( + Math.min( + ...prepared.scheduled.map(({ scheduledAt }) => scheduledAt.getTime()) + ) - DELIVERY_SAFETY_MARGIN_MS + ); +} + +function snapshotIdentity( + prepared: ReturnType +): string { + const contactIds = prepared.contacts.map(({ contact }) => contact.id).sort(); + const scheduledMessageIds = prepared.scheduled + .map(({ email }) => email.id) + .sort(); + const identity = [ + 'contacts', + String(contactIds.length), + ...contactIds, + 'scheduled_messages', + String(scheduledMessageIds.length), + ...scheduledMessageIds, + ].join('\0'); + return createHash('sha256').update(identity).digest('hex'); +} + +async function persistCutoverConfiguration( + transaction: SqlTransaction, + prepared: ReturnType, + snapshotAt: Date +): Promise { + const deadline = preparedCancellationDeadline(prepared); + const data = { + snapshot_at: snapshotAt.toISOString(), + cancellation_deadline: deadline?.toISOString() ?? null, + expected_contacts: prepared.contacts.length, + expected_scheduled: prepared.scheduled.length, + snapshot_identity: snapshotIdentity(prepared), + }; + const inserted = await transaction.execute( + `/* growth:import-insert-cutover-configuration */ + insert into growth_activity ( + event_key, occurred_at, kind, data + ) values ($1, $2, $3, $4::jsonb) + on conflict (event_key) do nothing + returning event_key, contact_id, project_id, kind, occurred_at, data`, + [ + CUTOVER_CONFIGURATION_EVENT_KEY, + snapshotAt, + CUTOVER_CONFIGURATION_KIND, + JSON.stringify(data), + ] + ); + if (inserted.rows.length > 0) return snapshotAt; + + const replay = await transaction.execute( + `/* growth:import-read-cutover-configuration */ + select event_key, contact_id, project_id, kind, occurred_at, data + from growth_activity + where event_key = $1`, + [CUTOVER_CONFIGURATION_EVENT_KEY] + ); + const row = replay.rows[0]; + const storedSnapshotAt = row + ? new Date(row.occurred_at) + : new Date(Number.NaN); + if ( + !row || + Number.isNaN(storedSnapshotAt.getTime()) || + row.event_key !== CUTOVER_CONFIGURATION_EVENT_KEY || + row.contact_id !== null || + row.project_id !== null || + row.kind !== CUTOVER_CONFIGURATION_KIND + ) { + fail('snapshot_identity_conflict'); + } + const replayData = { + ...data, + snapshot_at: storedSnapshotAt.toISOString(), + }; + if (canonicalJson(row.data) !== canonicalJson(replayData)) { + fail('snapshot_identity_conflict'); + } + return storedSnapshotAt; +} + async function importContact( transaction: SqlTransaction, prepared: PreparedContact, @@ -637,6 +783,65 @@ function validateLegacyReplay( } } +async function importContactMarker( + transaction: SqlTransaction, + providerContactId: string, + contact: ImportContactRow, + availableAt: Date, + result: ResendLifecycleImportResult +): Promise { + const idempotencyKey = `legacy:resend:contact:${providerContactId}`; + const payload = { + imported: true, + legacy_type: 'contact_marker', + provider: 'resend', + provider_contact_id: providerContactId, + }; + const inserted = await transaction.execute( + `/* growth:import-insert-contact-marker */ + insert into growth_jobs ( + kind, contact_id, status, available_at, idempotency_key, + payload, provider_email_id, delivery_status + ) values ( + 'legacy', $1, 'cancelled', $2, $3, $4::jsonb, null, 'not_submitted' + ) + on conflict (idempotency_key) do nothing + returning id, contact_id, kind, status, available_at, + idempotency_key, payload, provider_email_id, + delivery_status`, + [contact.id, availableAt, idempotencyKey, JSON.stringify(payload)] + ); + if (inserted.rows.length > 0) { + result.legacy_contact_markers_created += 1; + return; + } + + const replay = await transaction.execute( + `/* growth:import-read-contact-marker */ + select id, contact_id, kind, status, available_at, + idempotency_key, payload, provider_email_id, + delivery_status + from growth_jobs + where idempotency_key = $1`, + [idempotencyKey] + ); + const row = replay.rows[0]; + if ( + !row || + row.kind !== 'legacy' || + row.contact_id !== contact.id || + row.status !== 'cancelled' || + new Date(row.available_at).getTime() !== availableAt.getTime() || + row.idempotency_key !== idempotencyKey || + row.provider_email_id !== null || + row.delivery_status !== 'not_submitted' || + canonicalJson(row.payload) !== canonicalJson(payload) + ) { + fail('snapshot_identity_conflict'); + } + result.legacy_contact_markers_existing += 1; +} + export async function importResendLifecycleSnapshot( executor: SqlExecutor, snapshot: ResendLifecycleSnapshot, @@ -677,24 +882,44 @@ export async function importResendLifecycleSnapshot( throw new Error('rotation_coverage_failed'); } + const cutoverSnapshotAt = await persistCutoverConfiguration( + transaction, + prepared, + occurredAt + ); + const result: ResendLifecycleImportResult = { contacts_created: 0, contacts_existing: 0, contacts_rekeyed: 0, - legacy_jobs_created: 0, - legacy_jobs_existing: 0, + legacy_contact_markers_created: 0, + legacy_contact_markers_existing: 0, + legacy_scheduled_jobs_created: 0, + legacy_scheduled_jobs_existing: 0, legacy_provider_cancellations_required: 0, }; const contactsByEmail = new Map(); - for (const contact of prepared.contacts) { - contactsByEmail.set( - contact.normalizedEmail, - await importContact(transaction, contact, keyring, occurredAt, result) + for (const preparedContact of prepared.contacts) { + const contact = await importContact( + transaction, + preparedContact, + keyring, + occurredAt, + result + ); + await importContactMarker( + transaction, + preparedContact.contact.id, + contact, + cutoverSnapshotAt, + result ); + contactsByEmail.set(preparedContact.normalizedEmail, contact); } const payload = { imported: true, + legacy_type: 'scheduled_message', provider: 'resend', provider_state: 'scheduled', }; @@ -725,7 +950,7 @@ export async function importResendLifecycleSnapshot( ] ); if (inserted.rows.length > 0) { - result.legacy_jobs_created += 1; + result.legacy_scheduled_jobs_created += 1; continue; } const replay = await transaction.execute( @@ -744,7 +969,7 @@ export async function importResendLifecycleSnapshot( providerEmailId: scheduled.email.id, payload, }); - result.legacy_jobs_existing += 1; + result.legacy_scheduled_jobs_existing += 1; } const transactionExecutor: SqlExecutor = { @@ -917,6 +1142,9 @@ export async function mainImportResendLifecycle( ) { fail('snapshot_count_drift'); } + const snapshotAt = dependencies.now?.() ?? new Date(); + if (Number.isNaN(snapshotAt.getTime())) fail('database_import_failed'); + const deadline = cancellationDeadline(snapshot, snapshotAt); let keyring: EmailHmacKeyring; try { keyring = dependencies.loadKeyring(dependencies.environment); @@ -934,7 +1162,7 @@ export async function mainImportResendLifecycle( executor, snapshot, keyring, - dependencies.now?.() ?? new Date() + snapshotAt ); } catch (error) { if (error instanceof ImportFailure) throw error; @@ -945,6 +1173,10 @@ export async function mainImportResendLifecycle( command: 'import-resend-lifecycle', mode: 'apply', ...result, + cancellation_deadline: deadline?.toISOString() ?? null, + cancellation_remaining_seconds: deadline + ? Math.floor((deadline.getTime() - snapshotAt.getTime()) / 1000) + : null, }) ); return 0; diff --git a/scripts/import-resend-lifecycle.spec.ts b/scripts/import-resend-lifecycle.spec.ts index 09ce92be1..7d7a8c262 100644 --- a/scripts/import-resend-lifecycle.spec.ts +++ b/scripts/import-resend-lifecycle.spec.ts @@ -12,6 +12,7 @@ import { stopContact, } from '../libs/growth/src/index.ts'; import { + cancellationDeadline, importResendLifecycleSnapshot, mainImportResendLifecycle, snapshotResendLifecycle, @@ -133,6 +134,7 @@ function mainHarness(overrides?: { client?: ResendLifecycleClient; environment?: Record; executor?: SqlExecutor; + now?: () => Date; }) { const output: string[] = []; const errors: string[] = []; @@ -158,6 +160,7 @@ function mainHarness(overrides?: { loadKeyring, writeOutput: (line: string) => output.push(line), writeError: (line: string) => errors.push(line), + now: overrides?.now, }, }; } @@ -274,6 +277,31 @@ describe('snapshotResendLifecycle', () => { }); }); + it('rejects an impossible scheduled calendar date with the safe payload error', async () => { + const provider = paginatedClient({ + emailPages: [ + [ + { + id: 'invalid_scheduled_date', + to: ['one@example.com'], + created_at: '2026-02-01T00:00:00.000Z', + scheduled_at: '2026-02-31T12:00:00.000Z', + last_event: 'scheduled', + }, + ], + ], + }); + let outcome = 'resolved'; + + try { + await snapshotResendLifecycle(provider.client); + } catch (error) { + outcome = error instanceof Error ? error.message : 'unknown_error'; + } + + expect(outcome).toBe('provider_emails_payload_invalid'); + }); + it('accepts the PostgreSQL-style timestamp shape returned by live Resend contacts', async () => { const provider = paginatedClient({ contactPages: [ @@ -390,6 +418,17 @@ describe('snapshotResendLifecycle', () => { }); }); +describe('cancellationDeadline', () => { + it('returns null without attempting a timing comparison when no messages are scheduled', () => { + expect( + cancellationDeadline( + { contacts: fixtureSnapshot().contacts, scheduledEmails: [] }, + new Date(Number.NaN) + ) + ).toBeNull(); + }); +}); + describe('redacted dry run and guards', () => { it('prints aggregate categories only and never provider PII or payloads', async () => { const provider = paginatedClient(); @@ -552,6 +591,129 @@ describe('redacted dry run and guards', () => { expect(harness.createExecutor).not.toHaveBeenCalled(); }); + it('rejects an unsafe cancellation window before loading keys or opening Neon', async () => { + const provider = paginatedClient({ + emailPages: [ + [ + { + id: 'email_scheduled_1', + to: ['first.person@example.com'], + created_at: '2026-09-01T00:00:00.000Z', + scheduled_at: '2026-09-01T12:34:59.999Z', + last_event: 'scheduled', + }, + ], + ], + }); + const nowFn = vi.fn(() => now); + const harness = mainHarness({ + client: provider.client, + environment: { + RESEND_API_KEY: 're_secret_value', + TEST_DATABASE_URL: 'postgres://test-safe', + }, + now: nowFn, + }); + + const exitCode = await mainImportResendLifecycle( + ['--apply', '--expected-contacts', '1', '--expected-scheduled', '1'], + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle import failed: snapshot_cancellation_window_insufficient', + ]); + expect(nowFn).toHaveBeenCalledTimes(1); + expect(harness.loadKeyring).not.toHaveBeenCalled(); + expect(harness.createExecutor).not.toHaveBeenCalled(); + expect(provider.cancel).not.toHaveBeenCalled(); + }); + + it('accepts the exact 35-minute cancellation boundary', async () => { + const provider = paginatedClient({ + emailPages: [ + [ + { + id: 'email_scheduled_1', + to: ['first.person@example.com'], + created_at: '2026-09-01T00:00:00.000Z', + scheduled_at: '2026-09-01T12:35:00.000Z', + last_event: 'scheduled', + }, + ], + ], + }); + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + }; + const nowFn = vi.fn(() => now); + const harness = mainHarness({ + client: provider.client, + executor: importExecutor(state), + environment: { + RESEND_API_KEY: 're_secret_value', + TEST_DATABASE_URL: 'postgres://test-safe', + }, + now: nowFn, + }); + + const exitCode = await mainImportResendLifecycle( + ['--apply', '--expected-contacts', '1', '--expected-scheduled', '1'], + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(nowFn).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(harness.output[0]))).toMatchObject({ + cancellation_deadline: '2026-09-01T12:30:00.000Z', + cancellation_remaining_seconds: 1800, + }); + }); + + it('applies a zero-schedule snapshot with null deadline output', async () => { + const provider = paginatedClient({ emailPages: [[]] }); + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + }; + const harness = mainHarness({ + client: provider.client, + executor: importExecutor(state), + environment: { + RESEND_API_KEY: 're_secret_value', + TEST_DATABASE_URL: 'postgres://test-safe', + }, + now: () => now, + }); + + const exitCode = await mainImportResendLifecycle( + ['--apply', '--expected-contacts', '1', '--expected-scheduled', '0'], + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(JSON.parse(String(harness.output[0]))).toEqual({ + command: 'import-resend-lifecycle', + mode: 'apply', + contacts_created: 1, + contacts_existing: 0, + contacts_rekeyed: 0, + legacy_contact_markers_created: 1, + legacy_contact_markers_existing: 0, + legacy_scheduled_jobs_created: 0, + legacy_scheduled_jobs_existing: 0, + legacy_provider_cancellations_required: 0, + cancellation_deadline: null, + cancellation_remaining_seconds: null, + }); + }); + it('applies only after exact counts and passes TEST_DATABASE_URL explicitly', async () => { const state: ImportState = { contacts: new Map(), @@ -566,6 +728,7 @@ describe('redacted dry run and guards', () => { RESEND_API_KEY: 're_secret_value', TEST_DATABASE_URL: 'postgres://test-safe', }, + now: () => now, }); const exitCode = await mainImportResendLifecycle( @@ -576,12 +739,22 @@ describe('redacted dry run and guards', () => { expect(exitCode).toBe(0); expect(harness.createExecutor).toHaveBeenCalledWith('postgres://test-safe'); expect(harness.loadKeyring).toHaveBeenCalledTimes(1); - expect(JSON.parse(String(harness.output[0]))).toMatchObject({ + const line = String(harness.output[0]); + expect(JSON.parse(line)).toEqual({ command: 'import-resend-lifecycle', mode: 'apply', contacts_created: 1, - legacy_jobs_created: 1, + contacts_existing: 0, + contacts_rekeyed: 0, + legacy_contact_markers_created: 1, + legacy_contact_markers_existing: 0, + legacy_scheduled_jobs_created: 1, + legacy_scheduled_jobs_existing: 0, + legacy_provider_cancellations_required: 0, + cancellation_deadline: '2026-09-03T11:55:00.000Z', + cancellation_remaining_seconds: 172500, }); + expect(line).not.toMatch(/@|provider_contact_|provider_email_/u); }); it('uses only DATABASE_URL with the explicit environment-bound acknowledgement', async () => { @@ -598,6 +771,7 @@ describe('redacted dry run and guards', () => { RESEND_API_KEY: 're_secret_value', DATABASE_URL: 'postgres://environment-bound', }, + now: () => now, }); const exitCode = await mainImportResendLifecycle( @@ -636,6 +810,10 @@ interface ImportState { activities: Map>; nextContact: number; failAtMarker?: string; + queries?: Array<{ + sql: string; + parameters: readonly unknown[]; + }>; } function importExecutor(state: ImportState): SqlExecutor { @@ -644,6 +822,7 @@ function importExecutor(state: ImportState): SqlExecutor { sql: string, parameters: readonly unknown[] = [] ): Promise> { + state.queries?.push({ sql, parameters: [...parameters] }); const marker = /\/\* growth:([a-z0-9-]+) \*\//u.exec(sql)?.[1]; if (marker === state.failAtMarker) throw new Error('injected failure'); if (marker === 'lock-resend-lifecycle-import') return { rows: [] }; @@ -712,6 +891,52 @@ function importExecutor(state: ImportState): SqlExecutor { contact.email_lookup_hmac = String(parameters[2]); return { rows: [contact] } as SqlQueryResult; } + if (marker === 'import-insert-cutover-configuration') { + const key = String(parameters[0]); + if (state.activities.has(key)) { + return { rows: [] } as SqlQueryResult; + } + const activity = { + event_key: key, + contact_id: null, + project_id: null, + occurred_at: parameters[1], + kind: parameters[2], + data: JSON.parse(String(parameters[3])), + }; + state.activities.set(key, activity); + return { rows: [activity] } as SqlQueryResult; + } + if (marker === 'import-read-cutover-configuration') { + const activity = state.activities.get(String(parameters[0])); + return { rows: activity ? [activity] : [] } as SqlQueryResult; + } + if (marker === 'import-insert-contact-marker') { + const idempotencyKey = String(parameters[2]); + if (state.jobs.has(idempotencyKey)) { + return { rows: [] } as SqlQueryResult; + } + const job = { + id: `00000000-0000-4000-8000-${String(state.jobs.size + 100).padStart( + 12, + '0' + )}`, + kind: 'legacy', + contact_id: parameters[0], + status: 'cancelled', + available_at: parameters[1], + provider_email_id: null, + idempotency_key: idempotencyKey, + delivery_status: 'not_submitted', + payload: JSON.parse(String(parameters[3])), + }; + state.jobs.set(idempotencyKey, job); + return { rows: [job] } as SqlQueryResult; + } + if (marker === 'import-read-contact-marker') { + const job = state.jobs.get(String(parameters[0])); + return { rows: job ? [job] : [] } as SqlQueryResult; + } if (marker === 'import-insert-legacy-job') { const idempotencyKey = String(parameters[3]); if (state.jobs.has(idempotencyKey)) @@ -840,6 +1065,18 @@ function importExecutor(state: ImportState): SqlExecutor { }; } +function recordedQuery(state: ImportState, marker: string) { + const query = state.queries?.find(({ sql }) => + sql.includes(`/* growth:${marker} */`) + ); + if (!query) throw new Error(`Expected recorded query for ${marker}`); + return query; +} + +function compactSql(sql: string): string { + return sql.replace(/\s+/gu, ' ').trim(); +} + function fixtureSnapshot(): ResendLifecycleSnapshot { return { contacts: [ @@ -879,7 +1116,432 @@ function fixtureSnapshot(): ResendLifecycleSnapshot { }; } +function oneContactSnapshot(scheduledAt?: string): ResendLifecycleSnapshot { + const fixture = fixtureSnapshot(); + return { + contacts: fixture.contacts.slice(0, 1), + scheduledEmails: + scheduledAt === undefined + ? [] + : [ + { + ...(fixture + .scheduledEmails[0] as ResendLifecycleSnapshot['scheduledEmails'][number]), + scheduled_at: scheduledAt, + }, + ], + }; +} + +const expectedFixtureSnapshotIdentity = + '60242884d71c5e0f7b23323b4af191a1a59aca157e150d2ab1315362dfcf5e3d'; + describe('importResendLifecycleSnapshot', () => { + it('creates one terminal legacy marker for a contact with no scheduled messages', async () => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + queries: [], + }; + + const result = await importResendLifecycleSnapshot( + importExecutor(state), + oneContactSnapshot(), + keyring, + now + ); + + expect(result).toEqual({ + contacts_created: 1, + contacts_existing: 0, + contacts_rekeyed: 0, + legacy_contact_markers_created: 1, + legacy_contact_markers_existing: 0, + legacy_scheduled_jobs_created: 0, + legacy_scheduled_jobs_existing: 0, + legacy_provider_cancellations_required: 0, + }); + expect(state.jobs).toHaveLength(1); + const markerKey = 'legacy:resend:contact:provider_contact_1'; + const markerPayload = { + imported: true, + legacy_type: 'contact_marker', + provider: 'resend', + provider_contact_id: 'provider_contact_1', + }; + const marker = state.jobs.get(markerKey); + const importedContact = state.contacts.get('first.person@example.com'); + expect({ + contact_id: marker?.['contact_id'], + kind: marker?.['kind'], + status: marker?.['status'], + available_at: marker?.['available_at'], + idempotency_key: marker?.['idempotency_key'], + payload: marker?.['payload'], + provider_email_id: marker?.['provider_email_id'], + delivery_status: marker?.['delivery_status'], + }).toEqual({ + contact_id: importedContact?.id, + kind: 'legacy', + status: 'cancelled', + available_at: now, + idempotency_key: markerKey, + payload: markerPayload, + provider_email_id: null, + delivery_status: 'not_submitted', + }); + const markerInsert = recordedQuery(state, 'import-insert-contact-marker'); + expect(compactSql(markerInsert.sql)).toContain( + 'insert into growth_jobs ( kind, contact_id, status, available_at, idempotency_key, payload, provider_email_id, delivery_status )' + ); + expect(compactSql(markerInsert.sql)).toContain( + "values ( 'legacy', $1, 'cancelled', $2, $3, $4::jsonb, null, 'not_submitted' )" + ); + expect(compactSql(markerInsert.sql)).toContain( + 'on conflict (idempotency_key) do nothing' + ); + expect(markerInsert.parameters).toEqual([ + importedContact?.id, + now, + markerKey, + JSON.stringify(markerPayload), + ]); + expect( + state.contacts.get('first.person@example.com')?.outreach_approved_at + ).toBeNull(); + expect( + state.activities.get('legacy:resend:cutover:v1:configuration')?.['data'] + ).toMatchObject({ + cancellation_deadline: null, + expected_contacts: 1, + expected_scheduled: 0, + }); + }); + + it('creates a contact marker and a separate provider-bound scheduled legacy job', async () => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + queries: [], + }; + + const result = await importResendLifecycleSnapshot( + importExecutor(state), + oneContactSnapshot('2026-09-03T12:00:00.000Z'), + keyring, + now + ); + + expect(result).toMatchObject({ + legacy_contact_markers_created: 1, + legacy_contact_markers_existing: 0, + legacy_scheduled_jobs_created: 1, + legacy_scheduled_jobs_existing: 0, + }); + expect(state.jobs).toHaveLength(2); + const scheduledKey = 'legacy:resend:scheduled:provider_email_1'; + const scheduledPayload = { + imported: true, + legacy_type: 'scheduled_message', + provider: 'resend', + provider_state: 'scheduled', + }; + const scheduled = state.jobs.get(scheduledKey); + const importedContact = state.contacts.get('first.person@example.com'); + const scheduledAt = new Date('2026-09-03T12:00:00.000Z'); + expect({ + contact_id: scheduled?.['contact_id'], + kind: scheduled?.['kind'], + status: scheduled?.['status'], + available_at: scheduled?.['available_at'], + idempotency_key: scheduled?.['idempotency_key'], + payload: scheduled?.['payload'], + provider_email_id: scheduled?.['provider_email_id'], + delivery_status: scheduled?.['delivery_status'], + }).toEqual({ + contact_id: importedContact?.id, + kind: 'legacy', + status: 'pending', + available_at: scheduledAt, + idempotency_key: scheduledKey, + payload: scheduledPayload, + provider_email_id: 'provider_email_1', + delivery_status: 'not_submitted', + }); + const scheduledInsert = recordedQuery(state, 'import-insert-legacy-job'); + expect(compactSql(scheduledInsert.sql)).toContain( + 'insert into growth_jobs ( kind, contact_id, status, available_at, idempotency_key, payload, provider_email_id, delivery_status )' + ); + expect(compactSql(scheduledInsert.sql)).toContain( + "values ( 'legacy', $1, 'pending', $2, $4, $5::jsonb, $3, 'not_submitted' )" + ); + expect(compactSql(scheduledInsert.sql)).toContain( + 'on conflict (idempotency_key) do nothing' + ); + expect(scheduledInsert.parameters).toEqual([ + importedContact?.id, + scheduledAt, + 'provider_email_1', + scheduledKey, + JSON.stringify(scheduledPayload), + ]); + }); + + it('reapplies the same snapshot without duplicating either legacy row type', async () => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + }; + const executor = importExecutor(state); + const snapshot = oneContactSnapshot('2026-09-03T12:00:00.000Z'); + await importResendLifecycleSnapshot(executor, snapshot, keyring, now); + + const result = await importResendLifecycleSnapshot( + executor, + snapshot, + keyring, + now + ); + + expect(result).toMatchObject({ + legacy_contact_markers_created: 0, + legacy_contact_markers_existing: 1, + legacy_scheduled_jobs_created: 0, + legacy_scheduled_jobs_existing: 1, + }); + expect(state.jobs).toHaveLength(2); + }); + + it.each([ + ['status', (row: Record) => (row['status'] = 'pending')], + [ + 'provider binding', + (row: Record) => + (row['provider_email_id'] = 'conflicting_message'), + ], + [ + 'delivery status', + (row: Record) => (row['delivery_status'] = 'submitted'), + ], + [ + 'payload', + (row: Record) => + (row['payload'] = { + imported: true, + legacy_type: 'contact_marker', + provider: 'resend', + }), + ], + ] as const)( + 'rejects a contact marker with conflicting %s', + async (_name, tamper) => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + }; + const executor = importExecutor(state); + const snapshot = oneContactSnapshot(); + await importResendLifecycleSnapshot(executor, snapshot, keyring, now); + const marker = state.jobs.get('legacy:resend:contact:provider_contact_1'); + if (!marker) throw new Error('Fixture contact marker missing'); + tamper(marker); + + await expect( + importResendLifecycleSnapshot(executor, snapshot, keyring, now) + ).rejects.toThrow(/^snapshot_identity_conflict$/u); + } + ); + + it('never grants outreach approval while importing an existing contact', async () => { + const lookup = createEmailLookupHmac( + 'first.person@example.com', + keyring.active + ); + const approvedAt = new Date('2026-08-31T12:00:00.000Z'); + const existing = { + id: contactId, + email_normalized: 'first.person@example.com', + email_lookup_hmac: lookup.digest, + email_hmac_key_version: lookup.keyVersion, + outreach_approved_at: approvedAt, + deleted_at: null, + updated_at: approvedAt, + }; + const state: ImportState = { + contacts: new Map([['first.person@example.com', existing]]), + jobs: new Map(), + activities: new Map(), + nextContact: 2, + }; + + await importResendLifecycleSnapshot( + importExecutor(state), + oneContactSnapshot(), + keyring, + now + ); + + expect(existing.outreach_approved_at).toEqual(approvedAt); + }); + + it.each([ + [ + 'missing legacy type', + { + imported: true, + provider: 'resend', + provider_state: 'scheduled', + }, + ], + [ + 'extra conflicting property', + { + imported: true, + legacy_type: 'scheduled_message', + provider: 'resend', + provider_state: 'scheduled', + conflicting: true, + }, + ], + ] as const)( + 'rejects a scheduled-message payload with %s on replay', + async (_name, conflictingPayload) => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + }; + const executor = importExecutor(state); + const snapshot = oneContactSnapshot('2026-09-03T12:00:00.000Z'); + await importResendLifecycleSnapshot(executor, snapshot, keyring, now); + const scheduled = state.jobs.get( + 'legacy:resend:scheduled:provider_email_1' + ); + if (!scheduled) throw new Error('Fixture scheduled job missing'); + scheduled['payload'] = conflictingPayload; + + await expect( + importResendLifecycleSnapshot(executor, snapshot, keyring, now) + ).rejects.toThrow(/^snapshot_identity_conflict$/u); + } + ); + + it('persists one immutable cutover configuration and validates it on replay', async () => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + queries: [], + }; + const executor = importExecutor(state); + const snapshot = fixtureSnapshot(); + await importResendLifecycleSnapshot(executor, snapshot, keyring, now); + const eventKey = 'legacy:resend:cutover:v1:configuration'; + const configuration = state.activities.get(eventKey); + const expectedData = { + snapshot_at: now.toISOString(), + cancellation_deadline: '2026-09-03T11:55:00.000Z', + expected_contacts: 2, + expected_scheduled: 2, + snapshot_identity: expectedFixtureSnapshotIdentity, + }; + + expect(configuration).toEqual({ + event_key: eventKey, + contact_id: null, + project_id: null, + kind: 'legacy.resend_cutover_configured', + occurred_at: now, + data: expectedData, + }); + const configurationInsert = recordedQuery( + state, + 'import-insert-cutover-configuration' + ); + const configurationSql = compactSql(configurationInsert.sql); + expect(configurationSql).toContain( + 'insert into growth_activity ( event_key, occurred_at, kind, data ) values ($1, $2, $3, $4::jsonb)' + ); + expect(configurationSql).toContain('on conflict (event_key) do nothing'); + expect(configurationSql).not.toContain('do update'); + expect(configurationSql).not.toMatch(/\bupdate growth_activity\b/u); + expect(configurationInsert.parameters).toEqual([ + eventKey, + now, + 'legacy.resend_cutover_configured', + JSON.stringify(expectedData), + ]); + expect(JSON.stringify(configuration)).not.toMatch( + /@|provider_contact_|provider_email_/u + ); + const immutableConfiguration = structuredClone(configuration); + const reorderedSnapshot: ResendLifecycleSnapshot = { + contacts: [...snapshot.contacts].reverse(), + scheduledEmails: [...snapshot.scheduledEmails].reverse(), + }; + + await importResendLifecycleSnapshot( + executor, + reorderedSnapshot, + keyring, + new Date('2026-09-02T12:00:00.000Z') + ); + + expect(state.activities.get(eventKey)).toEqual(immutableConfiguration); + }); + + it.each([ + ['cancellation_deadline', '2026-09-03T11:54:59.999Z'], + ['expected_contacts', 999], + ['expected_scheduled', 999], + ['snapshot_identity', '0'.repeat(64)], + ] as const)( + 'rejects conflicting cutover configuration %s without replacing it', + async (field, conflictingValue) => { + const state: ImportState = { + contacts: new Map(), + jobs: new Map(), + activities: new Map(), + nextContact: 1, + }; + const executor = importExecutor(state); + const snapshot = fixtureSnapshot(); + const eventKey = 'legacy:resend:cutover:v1:configuration'; + await importResendLifecycleSnapshot(executor, snapshot, keyring, now); + const configuration = state.activities.get(eventKey); + const data = configuration?.['data'] as + | Record + | undefined; + if (!data) throw new Error('Fixture cutover configuration missing'); + data[field] = conflictingValue; + + await expect( + importResendLifecycleSnapshot( + executor, + snapshot, + keyring, + new Date('2026-09-02T12:00:00.000Z') + ) + ).rejects.toThrow(/^snapshot_identity_conflict$/u); + + const persistedData = state.activities.get(eventKey)?.['data'] as + | Record + | undefined; + expect(persistedData?.[field]).toEqual(conflictingValue); + expect(state.activities.get(eventKey)?.['occurred_at']).toEqual(now); + } + ); + it('imports contacts, then applies provider unsubscribe through the canonical stop without provider mutation', async () => { const state: ImportState = { contacts: new Map(), @@ -900,8 +1562,10 @@ describe('importResendLifecycleSnapshot', () => { contacts_created: 2, contacts_existing: 0, contacts_rekeyed: 0, - legacy_jobs_created: 2, - legacy_jobs_existing: 0, + legacy_contact_markers_created: 2, + legacy_contact_markers_existing: 0, + legacy_scheduled_jobs_created: 2, + legacy_scheduled_jobs_existing: 0, legacy_provider_cancellations_required: 1, }); expect([...state.contacts.values()]).toEqual( @@ -923,6 +1587,7 @@ describe('importResendLifecycleSnapshot', () => { available_at: new Date('2026-09-03T12:00:00.000Z'), payload: { imported: true, + legacy_type: 'scheduled_message', provider: 'resend', provider_state: 'scheduled', }, @@ -971,8 +1636,10 @@ describe('importResendLifecycleSnapshot', () => { contacts_created: 0, contacts_existing: 2, contacts_rekeyed: 0, - legacy_jobs_created: 0, - legacy_jobs_existing: 2, + legacy_contact_markers_created: 0, + legacy_contact_markers_existing: 2, + legacy_scheduled_jobs_created: 0, + legacy_scheduled_jobs_existing: 2, legacy_provider_cancellations_required: 1, }); expect(approved.outreach_approved_at).toEqual( @@ -1008,7 +1675,7 @@ describe('importResendLifecycleSnapshot', () => { now ); - expect(result.legacy_jobs_existing).toBe(2); + expect(result.legacy_scheduled_jobs_existing).toBe(2); expect(first['status']).toBe('cancelled'); expect(second['delivery_status']).toBe('delivered'); }); From ba8271a644eb60bc6a7396ecb52233debd36c375 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:50:11 -0700 Subject: [PATCH 05/15] feat: reconcile scheduled lifecycle mail --- .../2026-08-31-growth-lifecycle-cutover.md | 60 +- libs/growth/project.json | 3 +- libs/growth/vite.operator-cli.config.mts | 1 + package.json | 1 + scripts/cancel-resend-lifecycle.mts | 1719 ++++++++++ scripts/cancel-resend-lifecycle.spec.ts | 3001 +++++++++++++++++ 6 files changed, 4771 insertions(+), 14 deletions(-) create mode 100644 scripts/cancel-resend-lifecycle.mts create mode 100644 scripts/cancel-resend-lifecycle.spec.ts diff --git a/docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md b/docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md index 05a691277..a6ac5b0e1 100644 --- a/docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md +++ b/docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md @@ -217,11 +217,11 @@ Create a separate protected Vercel project with root `apps/lifecycle`, monorepo Environment ownership is strict: -| Owner | Values | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Website preview project | preview growth `DATABASE_URL`; `GROWTH_DATABASE_ENVIRONMENT=preview`; growth token/email HMAC keyrings; `RESEND_WEBHOOK_SECRET`; `GOOGLE_REPLY_HMAC_SECRET`; `CRON_SECRET`; lifecycle origin and shared service secret; `LIFECYCLE_CRON_ENABLED=false` | +| Owner | Values | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Website preview project | preview growth `DATABASE_URL`; `GROWTH_DATABASE_ENVIRONMENT=preview`; growth token/email HMAC keyrings; `RESEND_WEBHOOK_SECRET`; `GOOGLE_REPLY_HMAC_SECRET`; `CRON_SECRET`; lifecycle origin and shared service secret; `LIFECYCLE_CRON_ENABLED=false` | | Lifecycle preview project | preview growth `DATABASE_URL`; app-dedicated preview `DAWN_DATABASE_URL`; shared lifecycle service secret; Anthropic/Resend keys; growth action-token keyring; public custom-domain alias for the exact Website preview deployment as `GROWTH_PUBLIC_ACTION_ORIGIN`; founder address; delivery environment/allowlist/redirect; immutable cohort timestamp; sender flags; all delivery/enrollment/leasing switches false | -| Vercel project settings | root directory, parent-file access, Node 24, protected preview access policy | +| Vercel project settings | root directory, parent-file access, Node 24, protected preview access policy | Preview and production must use separate growth databases and separate Dawn stores. `DAWN_DATABASE_URL` must never alias or fall back to growth `DATABASE_URL`. No value may use a `NEXT_PUBLIC_` name. @@ -283,39 +283,71 @@ From one received allowlisted message, verify and record pass/fail without copyi Do not enable production recipient delivery if any item is absent or if provider tracking is active. -## 6. Legacy Resend reconciliation +## 6. Legacy Resend hard-boundary reconciliation ### LOCAL -The importer unit gate is provider-free: +The importer and cancellation operator gates are provider-free: ```bash -npx -y node@22 ./node_modules/vitest/vitest.mjs run scripts/import-resend-lifecycle.spec.ts +npx -y node@22 ./node_modules/vitest/vitest.mjs run --config libs/growth/vite.operator-cli.config.mts scripts/import-resend-lifecycle.spec.ts scripts/cancel-resend-lifecycle.spec.ts ``` ### PREVIEW LIVE — explicit authorization required -Run a fresh aggregate-only provider snapshot, privately record the current counts, then import into an authorized preview/disposable target. The dry run reads the live Resend provider even though it does not write. Never reuse the historical 14-contact/17-scheduled observation. Before apply, require `TEST_DATABASE_URL` to be present and `DATABASE_URL` to be absent; the importer rejects both variables together and rejects the production acknowledgement in this mode: +Exercise the same ordered procedure below against the authorized preview/disposable target before production. Use `TEST_DATABASE_URL`, omit `DATABASE_URL`, and omit `--allow-database-url-apply` from both apply commands. Provider and database reads are live even during dry runs; applies mutate the authorized target. Require aggregate JSON only, zero newly granted approvals, a stable importer rerun, exact-record cancellation, and a successful zero-work cancellation rerun. + +Never reuse historical observations. Record only approved placeholder counts in durable evidence: ```bash +EXPECTED_CONTACTS= +EXPECTED_SCHEDULED= npm run growth:import-resend -- --dry-run test -n "${TEST_DATABASE_URL:-}" && test -z "${DATABASE_URL:-}" env -u DATABASE_URL npm run growth:import-resend -- --apply --expected-contacts "$EXPECTED_CONTACTS" --expected-scheduled "$EXPECTED_SCHEDULED" +npm run growth:cancel-resend -- --dry-run +env -u DATABASE_URL npm run growth:cancel-resend -- --apply --expected-scheduled "$EXPECTED_SCHEDULED" +env -u DATABASE_URL npm run growth:cancel-resend -- --apply --expected-scheduled "$EXPECTED_SCHEDULED" +npm run growth:cancel-resend -- --dry-run ``` -Require aggregate JSON only, zero newly granted approvals, stable idempotent rerun, and contact-scoped legacy cancellation counts. This apply mutates a database and is not a local-only check. - ### PRODUCTION LIVE — explicit authorization required -After preview reconciliation and deployed stop surfaces, repeat the immediately-current dry run and apply with the production acknowledgement. The dry run is a live provider read. Before apply, require the environment-bound `DATABASE_URL` to be present and `TEST_DATABASE_URL` to be absent; the acknowledgement never permits fallback to a test target and the importer rejects both variables together: +Perform this as one attended sequence. Do not print, paste, export, or record addresses, subjects, provider identifiers, raw provider failures, credentials, or database URLs. Stop on any count drift, unknown state, deadline failure, unverified provider record, or nonzero final inventory. + +1. **Block legacy ingress with Vercel Firewall.** Add an exact temporary block for `POST /api/leads`, `POST /api/newsletter`, and `POST /api/whitepaper-signup`. Verify all three POST paths are blocked while unrelated reads remain available. Keep this block in place through the hard-boundary deployment. +2. **Drain in-flight requests.** Wait for every request admitted before the firewall rule to finish, then require zero in-flight legacy form requests and stable legacy side-effect counters. +3. **Require stable provider inventories.** Take two attended, aggregate-only Resend inventories separated by the approved observation interval. Continue only when contact and scheduled-message counts match exactly. Set `EXPECTED_CONTACTS` and `EXPECTED_SCHEDULED` from that immediately current stable observation; the values below are placeholders, never historical defaults. +4. **Run the final importer dry run.** It reads Resend without mutation and must reproduce the approved aggregate counts. +5. **Run the timing preflight.** Require `DATABASE_URL` present, `TEST_DATABASE_URL` absent, and enough wall-clock time to complete before the importer-recorded cancellation deadline. A positive scheduled count requires a non-null future deadline; zero scheduled requires a null deadline. +6. **Apply the import.** Use the explicit production-database acknowledgement. Require exact counts, immutable configuration identity, zero new outreach approvals, and no provider mutation. +7. **Run cancellation dry run.** It reads Neon and Resend and emits aggregate counts only. Require no unexpected provider-scheduled records and no invalid or ambiguous imported records. +8. **Apply cancellation.** Use the same explicit database acknowledgement and expected scheduled count. The operator cancels only one exact provider-bound record at a time, settles only its matching legacy job, and never derives a target from a recipient. +9. **Use exact-record recovery when interrupted.** Rerun the same apply command. For an imported unresolved ID absent from the scheduled list, the operator must use exact `get(id)`: `canceled` settles Neon without a second cancel; `scheduled` remains eligible; missing, malformed, delivered, sent, failed, or otherwise ambiguous results halt the cutover unresolved. +10. **Require the final provider re-list.** The operator's bounded paginated re-list and an immediate cancellation dry run must report zero unresolved imported schedules, zero unexpected provider schedules, and zero provider scheduled messages remaining. A full-settlement apply rerun must issue zero cancellations. +11. **Deploy the hard boundary.** Deploy the reviewed Neon-only form implementation while the firewall still blocks all three POST paths. Verify the deployed artifact contains no legacy NDJSON, Loops, or provider-scheduled campaign path before routing traffic to it. +12. **Remove the firewall block.** Remove the three exact POST blocks only after the hard-boundary deployment, health checks, and zero-inventory reconciliation all pass. Submit the approved canaries and reconcile their Neon/provider effects by aggregate and exact fixture keys. + +Failure handling has two explicit branches: + +- **Pre-import insufficient-window failure: no database or provider mutation occurred.** Restore all three blocked acquisition POST routes and choose a later safe window. Record the closed failure class, remove the temporary firewall rule deliberately, and do not leave ingress accidentally blocked after abandoning this attempt. +- **Post-import or cancellation failure: keep all three acquisition POST routes blocked.** Do not route traffic to an earlier handler or remove any of the three firewall blocks. Reconcile Neon and Resend under the exact-record recovery procedure, then deploy or restore the reviewed Neon-only hard boundary. Restore ingress only after a reviewed Neon-only boundary is active and every accepted Neon and provider effect is reconciled. Treat the block as an attended incident control with a named owner until that safe boundary is restored; never reopen merely to end the incident. + +The attended command sequence is: ```bash +EXPECTED_CONTACTS= +EXPECTED_SCHEDULED= npm run growth:import-resend -- --dry-run test -n "${DATABASE_URL:-}" && test -z "${TEST_DATABASE_URL:-}" env -u TEST_DATABASE_URL npm run growth:import-resend -- --apply --expected-contacts "$EXPECTED_CONTACTS" --expected-scheduled "$EXPECTED_SCHEDULED" --allow-database-url-apply +npm run growth:cancel-resend -- --dry-run +env -u TEST_DATABASE_URL npm run growth:cancel-resend -- --apply --expected-scheduled "$EXPECTED_SCHEDULED" --allow-database-url-apply +env -u TEST_DATABASE_URL npm run growth:cancel-resend -- --apply --expected-scheduled "$EXPECTED_SCHEDULED" --allow-database-url-apply +npm run growth:cancel-resend -- --dry-run ``` -The importer never mutates Resend. If it reports pending legacy cancellations, an authorized operator must query exact IDs only in a restricted non-recorded database session, cancel each individually in Resend, verify the per-ID count equals the aggregate, and destroy the ephemeral checklist. Never bulk-cancel, export, log, or paste provider IDs. +The importer never mutates Resend. `growth:cancel-resend` is the only legacy-provider cancellation path. Never use a bulk action, recipient-derived lookup, dashboard checklist, or ad hoc provider command. ## 7. Shadow, allowlist, launch, and rollback @@ -333,7 +365,9 @@ Then set `DELIVERY_ENABLED=true` with campaign enrollment/leasing false and rest 4. Enable leasing last, first for internal/test recipients, then a small new-whitepaper cohort. 5. Review daily before expansion; use the thresholds in the operations runbook. -Immediate halt: set campaign leasing false, then delivery false if recipient safety is uncertain, then enrollment false. Leave cron on only if it is needed for fulfillment/recovery and is behaving correctly; otherwise disable cron too. Preserve ledgers, unknown outcomes, recovery state, cohort timestamp, and provider records. Roll back code only after switches are confirmed and leases settle/expire. +Immediate halt: set campaign leasing false, then delivery false if recipient safety is uncertain, then enrollment false. Leave cron on only if it is needed for fulfillment/recovery and is behaving correctly; otherwise disable cron too. Preserve ledgers, unknown outcomes, recovery state, cohort timestamp, and provider records. + +Before rolling back to any prior deployment, first restore the exact Vercel Firewall blocks for `POST /api/leads`, `POST /api/newsletter`, and `POST /api/whitepaper-signup`; no prior deployment may receive those requests. Keep all three paths blocked until the reviewed Neon-only hard boundary is restored. While blocked, reconcile every request already accepted by Neon and every corresponding provider effect, including unknown or interrupted outcomes, before reopening ingress. Never reopen a form path merely because code rollback completed. ## Appendix A: secret-free dogfood evidence template diff --git a/libs/growth/project.json b/libs/growth/project.json index 6deb91b48..c81b3ab40 100644 --- a/libs/growth/project.json +++ b/libs/growth/project.json @@ -10,7 +10,8 @@ "{workspaceRoot}/scripts/apply-migrations*", "{workspaceRoot}/scripts/growth-database-preflight*", "{workspaceRoot}/scripts/growth-control*", - "{workspaceRoot}/scripts/import-resend-lifecycle*" + "{workspaceRoot}/scripts/import-resend-lifecycle*", + "{workspaceRoot}/scripts/cancel-resend-lifecycle*" ] }, "targets": { diff --git a/libs/growth/vite.operator-cli.config.mts b/libs/growth/vite.operator-cli.config.mts index c6da321b7..9c1b50169 100644 --- a/libs/growth/vite.operator-cli.config.mts +++ b/libs/growth/vite.operator-cli.config.mts @@ -16,6 +16,7 @@ export default defineConfig({ 'scripts/apply-migrations.spec.ts', 'scripts/growth-control.spec.ts', 'scripts/import-resend-lifecycle.spec.ts', + 'scripts/cancel-resend-lifecycle.spec.ts', ], }, }); diff --git a/package.json b/package.json index b862a5053..ab3191f32 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "generate-whitepaper": "npx tsx apps/website/scripts/generate-whitepaper.ts", "db:migrate": "tsx scripts/apply-migrations.mts", "growth:control": "tsx scripts/growth-control.mts", + "growth:cancel-resend": "tsx scripts/cancel-resend-lifecycle.mts", "growth:import-resend": "tsx scripts/import-resend-lifecycle.mts", "marketing:channels:x:auth": "tsx --env-file=.env marketing/channels/src/x/auth-cli.ts", "marketing:channels:x:smoke": "tsx --env-file=.env marketing/channels/scripts/smoke.ts", diff --git a/scripts/cancel-resend-lifecycle.mts b/scripts/cancel-resend-lifecycle.mts new file mode 100644 index 000000000..a741fe961 --- /dev/null +++ b/scripts/cancel-resend-lifecycle.mts @@ -0,0 +1,1719 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + createDatabaseExecutor, + type SqlExecutor, +} from '../libs/growth/src/index.ts'; + +const PAGE_SIZE = 100; +const MAX_PAGES = 100; +const MAX_TOTAL_RECORDS = PAGE_SIZE * MAX_PAGES; +const MAX_PROVIDER_ID_LENGTH = 200; +const MAX_PROVIDER_REQUEST_MS = 10_000; +// Resend inventory and exact-email wrappers are small; one MiB is a closed, +// conservative ceiling that prevents provider bodies from growing unchecked. +const MAX_PROVIDER_RESPONSE_BYTES = 1_048_576; +const CANCELLATION_CLAIM_GRACE_MS = 5_000; +const DELIVERY_SAFETY_MARGIN_MS = 5 * 60_000; +const CUTOVER_CONFIGURATION_EVENT_KEY = + 'legacy:resend:cutover:v1:configuration'; +const CUTOVER_CONFIGURATION_KIND = 'legacy.resend_cutover_configured'; +const CANCELLATION_ACTIVITY_KIND = 'legacy.resend_schedule_cancelled'; +const USAGE = + 'Usage: npm run growth:cancel-resend -- --dry-run | --apply --expected-scheduled N [--allow-database-url-apply]'; + +type Environment = Record; + +export type ProviderListResponse = + | { + data: { object: 'list'; data: T[]; has_more: boolean }; + error: null; + } + | { data: null; error: unknown }; + +interface ProviderRequestOptions { + signal?: AbortSignal; +} + +export interface LegacyCancellationClient { + emails: { + list( + options: { limit: number; after?: string }, + request?: ProviderRequestOptions + ): Promise>; + cancel( + id: string, + request?: ProviderRequestOptions + ): Promise<{ data: unknown | null; error: unknown | null }>; + get( + id: string, + request?: ProviderRequestOptions + ): Promise<{ data: unknown | null; error: unknown | null }>; + }; +} + +export interface LegacyCancellationDependencies { + environment: Environment; + createClient(apiKey: string): LegacyCancellationClient; + createExecutor(databaseUrl: string): SqlExecutor; + writeOutput(line: string): void; + writeError(line: string): void; + now?: () => Date; +} + +export function createAbortableResendCancellationClient( + apiKey: string, + fetchImplementation: typeof fetch = fetch +): LegacyCancellationClient { + async function readBoundedJson( + response: Response, + signal?: AbortSignal + ): Promise<{ ok: true; value: unknown } | { ok: false }> { + const body = response.body; + if (body === null) return { ok: false }; + const reader = body.getReader(); + const rawContentLength = response.headers.get('content-length'); + const declaredContentLength = + rawContentLength !== null && /^\d+$/u.test(rawContentLength) + ? Number(rawContentLength) + : null; + const contentEncoding = response.headers.get('content-encoding'); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + let completed = false; + + async function readChunk(): Promise> { + if (!signal) return reader.read(); + if (signal.aborted) throw new DOMException('Aborted', 'AbortError'); + return new Promise((resolveRead, rejectRead) => { + const abort = () => + rejectRead(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + reader + .read() + .then(resolveRead, rejectRead) + .finally(() => { + signal.removeEventListener('abort', abort); + }); + }); + } + + try { + if ( + declaredContentLength !== null && + (!Number.isSafeInteger(declaredContentLength) || + declaredContentLength > MAX_PROVIDER_RESPONSE_BYTES) + ) { + return { ok: false }; + } + while (true) { + const chunk = await readChunk(); + if (chunk.done) break; + totalBytes += chunk.value.byteLength; + if (totalBytes > MAX_PROVIDER_RESPONSE_BYTES) return { ok: false }; + chunks.push(chunk.value); + } + if ( + declaredContentLength !== null && + (contentEncoding === null || contentEncoding === 'identity') && + totalBytes !== declaredContentLength + ) { + return { ok: false }; + } + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const value = JSON.parse(text) as unknown; + completed = true; + return { ok: true, value }; + } catch (error) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + return { ok: false }; + } finally { + if (!completed) { + try { + await reader.cancel(); + } catch { + // The transport is already closed; no response details are surfaced. + } + } + reader.releaseLock(); + } + } + + async function request( + path: string, + method: 'GET' | 'POST', + options?: ProviderRequestOptions + ): Promise<{ data: unknown | null; error: unknown | null }> { + const response = await fetchImplementation( + `https://api.resend.com${path}`, + { + method, + headers: { + accept: 'application/json', + 'accept-encoding': 'identity', + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + signal: options?.signal, + } + ); + const decoded = await readBoundedJson(response, options?.signal); + if (!decoded.ok) return { data: null, error: null }; + const payload = decoded.value; + return response.ok + ? { data: payload, error: null } + : { data: null, error: payload }; + } + + return { + emails: { + list: async (options, requestOptions) => { + const query = new URLSearchParams({ limit: String(options.limit) }); + if (options.after) query.set('after', options.after); + return request( + `/emails?${query.toString()}`, + 'GET', + requestOptions + ) as Promise>; + }, + get: (id, requestOptions) => + request(`/emails/${encodeURIComponent(id)}`, 'GET', requestOptions), + cancel: (id, requestOptions) => + request( + `/emails/${encodeURIComponent(id)}/cancel`, + 'POST', + requestOptions + ), + }, + }; +} + +type FailureCode = + | 'apply_database_guard_failed' + | 'cancellation_operator_already_running' + | 'cancellation_deadline_expired' + | 'cutover_configuration_invalid' + | 'database_cancellation_failed' + | 'database_inventory_failed' + | 'dry_run_database_guard_failed' + | 'immutable_inventory_invalid' + | 'provider_api_key_missing' + | 'provider_cancel_outcome_unknown' + | 'provider_cancel_response_malformed' + | 'provider_emails_list_failed' + | 'provider_emails_pagination_invalid' + | 'provider_emails_payload_invalid' + | 'provider_emails_response_malformed' + | 'provider_emails_request_timeout' + | 'provider_inventory_not_empty' + | 'provider_inventory_unexpected' + | 'provider_lookup_ambiguous' + | 'provider_lookup_malformed' + | 'provider_lookup_missing' + | 'provider_lookup_terminal' + | 'provider_lookup_timeout' + | 'scheduled_count_drift' + | 'unresolved_inventory_mismatch' + | 'usage_error'; + +class CancellationFailure extends Error { + constructor(readonly code: FailureCode) { + super(code); + this.name = 'CancellationFailure'; + } +} + +function fail(code: FailureCode): never { + throw new CancellationFailure(code); +} + +type ParsedArguments = + | { mode: 'dry_run' } + | { + mode: 'apply'; + expectedScheduled: number; + allowDatabaseUrlApply: boolean; + }; + +function countArgument(value: string | undefined): number { + if (value === undefined || !/^(0|[1-9][0-9]*)$/u.test(value)) { + fail('usage_error'); + } + const count = Number(value); + if (!Number.isSafeInteger(count)) fail('usage_error'); + return count; +} + +function parseArguments(argv: readonly string[]): ParsedArguments { + if (argv.length === 1 && argv[0] === '--dry-run') { + return { mode: 'dry_run' }; + } + if (argv[0] !== '--apply') fail('usage_error'); + let expectedScheduled: number | undefined; + let allowDatabaseUrlApply = false; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if ( + argument === '--expected-scheduled' && + expectedScheduled === undefined + ) { + expectedScheduled = countArgument(argv[index + 1]); + index += 1; + } else if ( + argument === '--allow-database-url-apply' && + !allowDatabaseUrlApply + ) { + allowDatabaseUrlApply = true; + } else { + fail('usage_error'); + } + } + if (expectedScheduled === undefined) fail('usage_error'); + return { mode: 'apply', expectedScheduled, allowDatabaseUrlApply }; +} + +function databaseUrlForDryRun(environment: Environment): string { + const testDatabaseUrl = environment['TEST_DATABASE_URL']; + const databaseUrl = environment['DATABASE_URL']; + if (testDatabaseUrl && !databaseUrl) return testDatabaseUrl; + if (databaseUrl && !testDatabaseUrl) return databaseUrl; + fail('dry_run_database_guard_failed'); +} + +function databaseUrlForApply( + args: Extract, + environment: Environment +): string { + const testDatabaseUrl = environment['TEST_DATABASE_URL']; + const databaseUrl = environment['DATABASE_URL']; + if (testDatabaseUrl && !databaseUrl && !args.allowDatabaseUrlApply) { + return testDatabaseUrl; + } + if (databaseUrl && !testDatabaseUrl && args.allowDatabaseUrlApply) { + return databaseUrl; + } + fail('apply_database_guard_failed'); +} + +function currentTime(dependencies: LegacyCancellationDependencies): Date { + const value = dependencies.now?.() ?? new Date(); + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + fail('database_cancellation_failed'); + } + return value; +} + +function boundedProviderId( + value: unknown, + code: + | 'immutable_inventory_invalid' + | 'provider_emails_payload_invalid' + | 'provider_lookup_malformed' +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_PROVIDER_ID_LENGTH || + !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value) + ) { + fail(code); + } + return value; +} + +function validDate(value: unknown, code: FailureCode): Date { + const date = + value instanceof Date ? new Date(value) : new Date(String(value)); + if ( + (typeof value !== 'string' && !(value instanceof Date)) || + Number.isNaN(date.getTime()) + ) { + fail(code); + } + return date; +} + +interface ConfigurationRow extends Record { + event_key: string; + contact_id: string | null; + project_id: string | null; + occurred_at: Date | string; + kind: string; + data: Record; +} + +interface CutoverConfiguration { + snapshotAt: Date; + cancellationDeadline: Date | null; + expectedContacts: number; + expectedScheduled: number; + snapshotIdentity: string; +} + +interface ContactMarkerRow extends Record { + provider_contact_id: string | null; +} + +interface LegacyJobRow extends Record { + id: string; + contact_id: string | null; + available_at: Date | string; + provider_email_id: string | null; + status: string; + payload: Record; + last_error_code?: string | null; + lease_token?: string | null; + lease_until?: Date | string | null; +} + +interface ValidLegacyJob { + id: string; + contactId: string; + availableAt: Date; + providerEmailId: string; + status: 'pending' | 'cancelled'; + providerState: 'scheduled' | 'cancelled'; + lastErrorCode: string | null; + leaseToken: string | null; + leaseUntil: Date | null; +} + +interface Inventory { + configuration: CutoverConfiguration; + contactProviderIds: string[]; + immutableSchedules: ValidLegacyJob[]; + unresolvedSchedules: ValidLegacyJob[]; +} + +function nonNegativeSafeInteger(value: unknown): number | null { + return Number.isSafeInteger(value) && Number(value) >= 0 + ? Number(value) + : null; +} + +function readConfiguration( + row: ConfigurationRow | undefined +): CutoverConfiguration { + if ( + !row || + row.event_key !== CUTOVER_CONFIGURATION_EVENT_KEY || + row.contact_id !== null || + row.project_id !== null || + row.kind !== CUTOVER_CONFIGURATION_KIND || + row.data === null || + typeof row.data !== 'object' || + Array.isArray(row.data) + ) { + fail('cutover_configuration_invalid'); + } + const occurredAt = validDate( + row.occurred_at, + 'cutover_configuration_invalid' + ); + const snapshotAt = validDate( + row.data['snapshot_at'], + 'cutover_configuration_invalid' + ); + const expectedContacts = nonNegativeSafeInteger( + row.data['expected_contacts'] + ); + const expectedScheduled = nonNegativeSafeInteger( + row.data['expected_scheduled'] + ); + const identity = row.data['snapshot_identity']; + if ( + snapshotAt.getTime() !== occurredAt.getTime() || + expectedContacts === null || + expectedScheduled === null || + typeof identity !== 'string' || + !/^[a-f0-9]{64}$/u.test(identity) + ) { + fail('cutover_configuration_invalid'); + } + const rawDeadline = row.data['cancellation_deadline']; + const cancellationDeadline = + rawDeadline === null + ? null + : validDate(rawDeadline, 'cutover_configuration_invalid'); + return { + snapshotAt, + cancellationDeadline, + expectedContacts, + expectedScheduled, + snapshotIdentity: identity, + }; +} + +function validatePayload(row: LegacyJobRow): 'scheduled' | 'cancelled' { + const payload = row.payload; + if ( + payload === null || + typeof payload !== 'object' || + Array.isArray(payload) || + payload['imported'] !== true || + payload['legacy_type'] !== 'scheduled_message' || + payload['provider'] !== 'resend' || + (payload['provider_state'] !== 'scheduled' && + payload['provider_state'] !== 'cancelled') + ) { + fail('immutable_inventory_invalid'); + } + if (payload['provider_state'] === 'cancelled') { + validDate(payload['cancelled_at'], 'immutable_inventory_invalid'); + } + return payload['provider_state']; +} + +function validateJob(row: LegacyJobRow): ValidLegacyJob { + const providerState = validatePayload(row); + const providerEmailId = boundedProviderId( + row.provider_email_id, + 'immutable_inventory_invalid' + ); + const leaseToken = row.lease_token ?? null; + const leaseUntil = + row.lease_until === undefined || row.lease_until === null + ? null + : validDate(row.lease_until, 'immutable_inventory_invalid'); + if ( + typeof row.id !== 'string' || + row.id.length === 0 || + row.id.length > 200 || + typeof row.contact_id !== 'string' || + row.contact_id.length === 0 || + row.contact_id.length > 200 || + (row.status !== 'pending' && row.status !== 'cancelled') || + (providerState === 'cancelled' && row.status !== 'cancelled') || + (row.last_error_code !== undefined && + row.last_error_code !== null && + (typeof row.last_error_code !== 'string' || + row.last_error_code.length === 0 || + row.last_error_code.length > 100)) || + (leaseToken !== null && + (typeof leaseToken !== 'string' || + !/^[a-f0-9-]{36}$/u.test(leaseToken))) || + (leaseToken === null) !== (leaseUntil === null) + ) { + fail('immutable_inventory_invalid'); + } + return { + id: row.id, + contactId: row.contact_id, + availableAt: validDate(row.available_at, 'immutable_inventory_invalid'), + providerEmailId, + status: row.status, + providerState, + lastErrorCode: row.last_error_code ?? null, + leaseToken, + leaseUntil, + }; +} + +function uniqueValues(values: readonly string[]): boolean { + return new Set(values).size === values.length; +} + +function inventoryIdentity( + contacts: readonly string[], + schedules: readonly string[] +): string { + return createHash('sha256') + .update( + [ + 'contacts', + String(contacts.length), + ...[...contacts].sort(), + 'scheduled_messages', + String(schedules.length), + ...[...schedules].sort(), + ].join('\0') + ) + .digest('hex'); +} + +async function readInventory(executor: SqlExecutor): Promise { + try { + return await executor.transaction(async (transaction) => { + const configurationResult = await transaction.execute( + `/* growth:cancel-read-cutover-configuration */ + select event_key, contact_id, project_id, occurred_at, kind, data + from growth_activity + where event_key = $1`, + [CUTOVER_CONFIGURATION_EVENT_KEY] + ); + if (configurationResult.rows.length !== 1) { + fail('cutover_configuration_invalid'); + } + const configuration = readConfiguration(configurationResult.rows[0]); + const markerResult = await transaction.execute( + `/* growth:cancel-read-contact-markers */ + select payload->>'provider_contact_id' as provider_contact_id + from growth_jobs + where kind = 'legacy' + and provider_email_id is null + and payload->>'legacy_type' = 'contact_marker' + order by payload->>'provider_contact_id'` + ); + const immutableResult = await transaction.execute( + `/* growth:cancel-read-immutable-schedules */ + select id, contact_id, available_at, provider_email_id, status, payload, + last_error_code, lease_token, lease_until + from growth_jobs + where kind = 'legacy' + and provider_email_id is not null + and payload->>'legacy_type' = 'scheduled_message' + order by provider_email_id` + ); + const unresolvedResult = await transaction.execute( + `/* growth:cancel-read-unresolved-schedules */ + select id, contact_id, available_at, provider_email_id, status, payload, + last_error_code, lease_token, lease_until + from growth_jobs + where kind = 'legacy' + and provider_email_id is not null + and payload->>'legacy_type' = 'scheduled_message' + and payload->>'provider_state' = 'scheduled' + order by provider_email_id` + ); + const contactProviderIds = markerResult.rows.map((row) => + boundedProviderId( + row.provider_contact_id, + 'immutable_inventory_invalid' + ) + ); + const immutableSchedules = immutableResult.rows.map(validateJob); + const unresolvedSchedules = unresolvedResult.rows.map(validateJob); + const immutableJobIds = immutableSchedules.map(({ id }) => id); + const immutableProviderIds = immutableSchedules.map( + ({ providerEmailId }) => providerEmailId + ); + const unresolvedJobIds = unresolvedSchedules.map(({ id }) => id); + const unresolvedProviderIds = unresolvedSchedules.map( + ({ providerEmailId }) => providerEmailId + ); + if ( + !uniqueValues(contactProviderIds) || + !uniqueValues(immutableJobIds) || + !uniqueValues(immutableProviderIds) || + !uniqueValues(unresolvedJobIds) || + !uniqueValues(unresolvedProviderIds) || + unresolvedSchedules.some( + ({ providerState }) => providerState !== 'scheduled' + ) || + unresolvedSchedules.some( + ({ id, providerEmailId }) => + !immutableSchedules.some( + (candidate) => + candidate.id === id && + candidate.providerEmailId === providerEmailId + ) + ) + ) { + fail('immutable_inventory_invalid'); + } + if ( + contactProviderIds.length !== configuration.expectedContacts || + immutableSchedules.length !== configuration.expectedScheduled || + inventoryIdentity(contactProviderIds, immutableProviderIds) !== + configuration.snapshotIdentity + ) { + fail('immutable_inventory_invalid'); + } + const reconstructedDeadline = + immutableSchedules.length === 0 + ? null + : new Date( + Math.min( + ...immutableSchedules.map(({ availableAt }) => + availableAt.getTime() + ) + ) - DELIVERY_SAFETY_MARGIN_MS + ); + if ( + (reconstructedDeadline === null) !== + (configuration.cancellationDeadline === null) || + (reconstructedDeadline !== null && + configuration.cancellationDeadline !== null && + reconstructedDeadline.getTime() !== + configuration.cancellationDeadline.getTime()) + ) { + fail('cutover_configuration_invalid'); + } + return { + configuration, + contactProviderIds, + immutableSchedules, + unresolvedSchedules, + }; + }); + } catch (error) { + if (error instanceof CancellationFailure) throw error; + fail('database_inventory_failed'); + } +} + +interface ProviderEmail { + id: string; + lastEvent: string; +} + +interface ProviderResponseWrapper { + data: unknown | null; + error: unknown | null; +} + +function providerResponseWrapper( + value: unknown, + malformedCode: + | 'provider_emails_response_malformed' + | 'provider_lookup_malformed' + | 'provider_cancel_response_malformed' +): ProviderResponseWrapper { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(malformedCode); + } + const input = value as Record; + if ( + !Object.hasOwn(input, 'data') || + !Object.hasOwn(input, 'error') || + input['data'] === undefined || + input['error'] === undefined || + (input['data'] === null) === (input['error'] === null) + ) { + fail(malformedCode); + } + return { + data: input['data'], + error: input['error'], + } as ProviderResponseWrapper; +} + +function providerEmail(value: unknown): ProviderEmail { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail('provider_emails_payload_invalid'); + } + const input = value as Record; + const id = boundedProviderId(input['id'], 'provider_emails_payload_invalid'); + const lastEvent = input['last_event']; + if ( + typeof lastEvent !== 'string' || + lastEvent.length === 0 || + lastEvent.length > 50 || + !/^[a-z][a-z0-9_-]*$/u.test(lastEvent) + ) { + fail('provider_emails_payload_invalid'); + } + return { id, lastEvent }; +} + +function providerListPage(response: unknown): { + data: ProviderEmail[]; + hasMore: boolean; +} { + const wrapper = providerResponseWrapper( + response, + 'provider_emails_response_malformed' + ); + if (wrapper.error !== null || wrapper.data === null) { + fail('provider_emails_list_failed'); + } + const data = wrapper.data as Record; + if ( + data.object !== 'list' || + !Array.isArray(data.data) || + typeof data.has_more !== 'boolean' + ) { + fail('provider_emails_payload_invalid'); + } + if (data.data.length > PAGE_SIZE) { + fail('provider_emails_pagination_invalid'); + } + return { data: data.data.map(providerEmail), hasMore: data.has_more }; +} + +async function listProviderScheduled( + client: LegacyCancellationClient, + context: ProviderDeadlineContext +): Promise> { + const all = new Map(); + const seenCursors = new Set(); + let after: string | undefined; + for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber += 1) { + let response: ProviderListResponse; + try { + response = await requestProvider( + context, + 'provider_emails_request_timeout', + 'legacy_resend_provider_list_timeout', + (signal) => + client.emails.list( + after ? { limit: PAGE_SIZE, after } : { limit: PAGE_SIZE }, + { signal } + ) + ); + } catch (error) { + if (error instanceof CancellationFailure) throw error; + fail('provider_emails_list_failed'); + } + if (context.configuration.cancellationDeadline !== null) { + await requireFutureDeadline( + context.executor, + context.configuration, + context.unresolved, + context.dependencies, + context.persist + ); + } + const page = providerListPage(response); + if (all.size + page.data.length > MAX_TOTAL_RECORDS) { + fail('provider_emails_pagination_invalid'); + } + for (const email of page.data) { + if (all.has(email.id)) fail('provider_emails_pagination_invalid'); + all.set(email.id, email); + } + if (!page.hasMore) { + return new Set( + [...all.values()] + .filter(({ lastEvent }) => lastEvent === 'scheduled') + .map(({ id }) => id) + ); + } + const next = page.data.at(-1)?.id; + if (!next || next === after || seenCursors.has(next)) { + fail('provider_emails_pagination_invalid'); + } + seenCursors.add(next); + after = next; + } + fail('provider_emails_pagination_invalid'); +} + +function setsEqual( + left: ReadonlySet, + right: ReadonlySet +): boolean { + return ( + left.size === right.size && [...left].every((value) => right.has(value)) + ); +} + +function deadlineIsFuture( + configuration: CutoverConfiguration, + at: Date +): boolean { + return ( + configuration.cancellationDeadline !== null && + configuration.cancellationDeadline.getTime() > at.getTime() + ); +} + +async function settleCancellation( + executor: SqlExecutor, + job: ValidLegacyJob, + occurredAt: Date, + claimToken: string | null = null +): Promise { + try { + await executor.transaction(async (transaction) => { + const settled = await transaction.execute<{ id: string }>( + `/* growth:cancel-settle-job */ + update growth_jobs + set status = 'cancelled', + lease_token = null, + lease_until = null, + last_error_code = null, + payload = payload || jsonb_build_object( + 'provider_state', 'cancelled', + 'cancelled_at', $2::timestamptz + ) + where id = $1 + and kind = 'legacy' + and provider_email_id = $3 + and payload->>'legacy_type' = 'scheduled_message' + and payload->>'provider_state' = 'scheduled' + and ($4::uuid is null or lease_token = $4::uuid) + returning id`, + [job.id, occurredAt, job.providerEmailId, claimToken] + ); + if (settled.rows.length !== 1 || settled.rows[0]?.id !== job.id) { + fail('database_cancellation_failed'); + } + await transaction.execute( + `/* growth:cancel-insert-activity */ + insert into growth_activity ( + event_key, contact_id, occurred_at, kind, data + ) values ( + $1, $2, $3, '${CANCELLATION_ACTIVITY_KIND}', + jsonb_build_object('provider', 'resend') + )`, + [ + `legacy:resend:scheduled:${job.id}:cancelled`, + job.contactId, + occurredAt, + ] + ); + }); + } catch (error) { + if (error instanceof CancellationFailure) throw error; + fail('database_cancellation_failed'); + } +} + +async function claimCancellation( + executor: SqlExecutor, + job: ValidLegacyJob, + claimedAt: Date, + configuration: CutoverConfiguration +): Promise { + const deadline = configuration.cancellationDeadline; + if (deadline === null) fail('cutover_configuration_invalid'); + const claimToken = randomUUID(); + const leaseUntil = new Date( + Math.min( + deadline.getTime(), + claimedAt.getTime() + + MAX_PROVIDER_REQUEST_MS + + CANCELLATION_CLAIM_GRACE_MS + ) + ); + let claimed: { rows: Array<{ id: string }> }; + try { + claimed = await executor.execute<{ id: string }>( + `/* growth:cancel-claim-job */ + update growth_jobs + set lease_token = $4::uuid, + lease_until = $5::timestamptz, + last_error_code = 'legacy_resend_cancel_outcome_unknown', + updated_at = $6::timestamptz + where id = $1 + and kind = 'legacy' + and provider_email_id = $2 + and payload->>'legacy_type' = 'scheduled_message' + and payload->>'provider_state' = 'scheduled' + and ( + lease_token is null + or lease_until <= $3::timestamptz + ) + returning id`, + [ + job.id, + job.providerEmailId, + claimedAt, + claimToken, + leaseUntil, + claimedAt, + ] + ); + } catch { + fail('database_cancellation_failed'); + } + if (claimed.rows.length === 0) fail('cancellation_operator_already_running'); + if (claimed.rows.length !== 1 || claimed.rows[0]?.id !== job.id) { + fail('database_cancellation_failed'); + } + return claimToken; +} + +type ClosedErrorCode = + | 'legacy_resend_cancel_provider_failed' + | 'legacy_resend_cancel_outcome_unknown' + | 'legacy_resend_cancel_response_malformed' + | 'legacy_resend_cancellation_deadline_expired' + | 'legacy_resend_lookup_ambiguous' + | 'legacy_resend_lookup_malformed' + | 'legacy_resend_lookup_missing' + | 'legacy_resend_lookup_terminal' + | 'legacy_resend_lookup_timeout' + | 'legacy_resend_provider_list_timeout'; + +async function persistUnresolved( + executor: SqlExecutor, + job: ValidLegacyJob, + code: ClosedErrorCode, + occurredAt: Date, + claimToken: string | null = null +): Promise { + try { + const persisted = await executor.execute<{ id: string }>( + `/* growth:cancel-persist-error */ + update growth_jobs + set last_error_code = $3, + updated_at = $4, + lease_token = null, + lease_until = null + where id = $1 + and kind = 'legacy' + and provider_email_id = $2 + and payload->>'legacy_type' = 'scheduled_message' + and payload->>'provider_state' = 'scheduled' + and ( + ($5::uuid is null and ( + lease_token is null + or lease_until <= $4::timestamptz + )) + or lease_token = $5::uuid + ) + returning id`, + [job.id, job.providerEmailId, code, occurredAt, claimToken] + ); + if (persisted.rows.length !== 1 || persisted.rows[0]?.id !== job.id) { + fail('database_cancellation_failed'); + } + } catch (error) { + if (error instanceof CancellationFailure) throw error; + fail('database_cancellation_failed'); + } +} + +async function expireUnresolved( + executor: SqlExecutor, + jobs: readonly ValidLegacyJob[], + occurredAt: Date, + claimToken: string | null = null +): Promise { + for (const [index, job] of jobs.entries()) { + await persistUnresolved( + executor, + job, + 'legacy_resend_cancellation_deadline_expired', + occurredAt, + index === 0 ? claimToken : null + ); + } + fail('cancellation_deadline_expired'); +} + +async function requireFutureDeadline( + executor: SqlExecutor, + configuration: CutoverConfiguration, + unresolved: readonly ValidLegacyJob[], + dependencies: LegacyCancellationDependencies, + persist: boolean, + claimToken: string | null = null +): Promise { + const at = currentTime(dependencies); + if (deadlineIsFuture(configuration, at)) return at; + if (persist && unresolved.length > 0) { + return expireUnresolved(executor, unresolved, at, claimToken); + } + fail('cancellation_deadline_expired'); +} + +interface ProviderDeadlineContext { + executor: SqlExecutor; + configuration: CutoverConfiguration; + unresolved: readonly ValidLegacyJob[]; + dependencies: LegacyCancellationDependencies; + persist: boolean; + claimToken?: string; +} + +async function requestProvider( + context: ProviderDeadlineContext, + timeoutFailure: FailureCode, + timeoutCode: ClosedErrorCode, + operation: (signal: AbortSignal) => Promise +): Promise { + const deadline = context.configuration.cancellationDeadline; + const startedAt = + deadline === null + ? currentTime(context.dependencies) + : await requireFutureDeadline( + context.executor, + context.configuration, + context.unresolved, + context.dependencies, + context.persist, + context.claimToken ?? null + ); + const timeoutMs = + deadline === null + ? MAX_PROVIDER_REQUEST_MS + : Math.max( + 1, + Math.min( + MAX_PROVIDER_REQUEST_MS, + deadline.getTime() - startedAt.getTime() + ) + ); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await operation(controller.signal); + } catch (error) { + if (error instanceof CancellationFailure) throw error; + if (controller.signal.aborted) { + if (context.persist) { + const occurredAt = currentTime(context.dependencies); + for (const job of context.unresolved) { + await persistUnresolved( + context.executor, + job, + timeoutCode, + occurredAt, + context.claimToken ?? null + ); + } + } + fail(timeoutFailure); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +type LookupOutcome = + | { kind: 'canceled' } + | { kind: 'scheduled' } + | { kind: 'unresolved'; code: ClosedErrorCode; failure: FailureCode }; + +const RESEND_ERROR_NAMES = new Set([ + 'invalid_idempotency_key', + 'validation_error', + 'missing_api_key', + 'restricted_api_key', + 'invalid_api_key', + 'not_found', + 'method_not_allowed', + 'invalid_idempotent_request', + 'concurrent_idempotent_requests', + 'invalid_attachment', + 'invalid_from_address', + 'invalid_access', + 'invalid_parameter', + 'invalid_region', + 'missing_required_field', + 'monthly_quota_exceeded', + 'daily_quota_exceeded', + 'rate_limit_exceeded', + 'security_error', + 'application_error', + 'internal_server_error', +]); + +function lookupErrorOutcome(error: unknown): LookupOutcome { + if (error === null || typeof error !== 'object' || Array.isArray(error)) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_malformed', + failure: 'provider_lookup_malformed', + }; + } + const input = error as Record; + const name = input['name']; + const statusCode = input['statusCode']; + if ( + typeof name !== 'string' || + name.length === 0 || + name.length > 100 || + !/^[a-z][a-z0-9_]*$/u.test(name) || + !RESEND_ERROR_NAMES.has(name) || + (statusCode !== null && !Number.isSafeInteger(statusCode)) || + typeof input['message'] !== 'string' + ) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_malformed', + failure: 'provider_lookup_malformed', + }; + } + if (name === 'not_found' && statusCode === 404) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_missing', + failure: 'provider_lookup_missing', + }; + } + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_ambiguous', + failure: 'provider_lookup_ambiguous', + }; +} + +async function lookupExact( + client: LegacyCancellationClient, + providerEmailId: string, + context: ProviderDeadlineContext +): Promise { + let response: unknown; + try { + response = await requestProvider( + context, + 'provider_lookup_timeout', + 'legacy_resend_lookup_timeout', + (signal) => client.emails.get(providerEmailId, { signal }) + ); + } catch (error) { + if (error instanceof CancellationFailure) throw error; + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_ambiguous', + failure: 'provider_lookup_ambiguous', + }; + } + let wrapper: ProviderResponseWrapper; + try { + wrapper = providerResponseWrapper(response, 'provider_lookup_malformed'); + } catch (error) { + if ( + error instanceof CancellationFailure && + error.code === 'provider_lookup_malformed' + ) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_malformed', + failure: 'provider_lookup_malformed', + }; + } + throw error; + } + if (wrapper.data === null) { + return lookupErrorOutcome(wrapper.error); + } + if (typeof wrapper.data !== 'object' || Array.isArray(wrapper.data)) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_malformed', + failure: 'provider_lookup_malformed', + }; + } + const data = wrapper.data as Record; + let id: string; + try { + id = boundedProviderId(data['id'], 'provider_lookup_malformed'); + } catch { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_malformed', + failure: 'provider_lookup_malformed', + }; + } + if ( + id !== providerEmailId || + data['object'] !== 'email' || + typeof data['last_event'] !== 'string' || + data['last_event'].length === 0 || + data['last_event'].length > 50 + ) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_malformed', + failure: 'provider_lookup_malformed', + }; + } + if (data['last_event'] === 'canceled') return { kind: 'canceled' }; + if (data['last_event'] === 'scheduled') return { kind: 'scheduled' }; + if ( + data['last_event'] === 'delivered' || + data['last_event'] === 'sent' || + data['last_event'] === 'failed' + ) { + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_terminal', + failure: 'provider_lookup_terminal', + }; + } + return { + kind: 'unresolved', + code: 'legacy_resend_lookup_ambiguous', + failure: 'provider_lookup_ambiguous', + }; +} + +async function verifyDryRunInventory( + client: LegacyCancellationClient, + inventory: Inventory, + providerScheduled: ReadonlySet, + context: ProviderDeadlineContext +): Promise { + const remainingUnresolved = new Set( + inventory.unresolvedSchedules.map(({ providerEmailId }) => providerEmailId) + ); + const verifiedScheduled = new Set(providerScheduled); + let missingUnresolved = 0; + for (const job of inventory.unresolvedSchedules) { + if (providerScheduled.has(job.providerEmailId)) continue; + missingUnresolved += 1; + const outcome = await lookupExact(client, job.providerEmailId, { + ...context, + unresolved: [job], + }); + await requireFutureDeadline( + context.executor, + context.configuration, + context.unresolved, + context.dependencies, + context.persist + ); + if (outcome.kind === 'canceled') { + remainingUnresolved.delete(job.providerEmailId); + } else if (outcome.kind === 'scheduled') { + verifiedScheduled.add(job.providerEmailId); + } else { + fail(outcome.failure); + } + } + if (!setsEqual(remainingUnresolved, verifiedScheduled)) { + fail('unresolved_inventory_mismatch'); + } + return missingUnresolved; +} + +function successfulCancelResult( + result: { data: unknown | null; error: unknown | null }, + expectedId: string +): boolean { + if ( + result.error !== null || + result.data === null || + typeof result.data !== 'object' || + Array.isArray(result.data) + ) { + return false; + } + const id = (result.data as Record)['id']; + const object = (result.data as Record)['object']; + return id === expectedId && object === 'email'; +} + +function summary(input: { + mode: 'dry_run' | 'apply'; + inventory: Inventory; + providerScheduled: ReadonlySet; + missingUnresolved: number; + unexpectedProviderScheduled: number; + providerScheduledRemaining?: number; + at: Date; +}) { + const deadline = input.inventory.configuration.cancellationDeadline; + return { + command: 'cancel-resend-lifecycle', + mode: input.mode, + immutable_contacts: input.inventory.contactProviderIds.length, + immutable_scheduled: input.inventory.immutableSchedules.length, + unresolved_imported: input.inventory.unresolvedSchedules.length, + provider_scheduled: input.providerScheduled.size, + missing_unresolved: input.missingUnresolved, + unexpected_provider_scheduled: input.unexpectedProviderScheduled, + cancellation_remaining_seconds: + deadline === null + ? null + : Math.max( + 0, + Math.floor((deadline.getTime() - input.at.getTime()) / 1_000) + ), + ...(input.providerScheduledRemaining === undefined + ? {} + : { provider_scheduled_remaining: input.providerScheduledRemaining }), + }; +} + +async function applyCancellation( + executor: SqlExecutor, + client: LegacyCancellationClient, + initialInventory: Inventory, + initialProviderScheduled: Set, + dependencies: LegacyCancellationDependencies +): Promise<{ output: Record; success: boolean }> { + const importedProviderIds = new Set( + initialInventory.immutableSchedules.map( + ({ providerEmailId }) => providerEmailId + ) + ); + const unexpected = [...initialProviderScheduled].filter( + (id) => !importedProviderIds.has(id) + ); + if (unexpected.length > 0) fail('provider_inventory_unexpected'); + + const verifiedScheduled = new Set(initialProviderScheduled); + let missingUnresolved = 0; + const lookups: Array<{ + job: ValidLegacyJob; + outcome: LookupOutcome; + checkpointAt: Date; + }> = []; + for (const job of initialInventory.unresolvedSchedules) { + const requiresExactRecovery = job.lastErrorCode !== null; + if ( + initialProviderScheduled.has(job.providerEmailId) && + !requiresExactRecovery + ) { + continue; + } + missingUnresolved += 1; + const outcome = await lookupExact(client, job.providerEmailId, { + executor, + configuration: initialInventory.configuration, + unresolved: [job], + dependencies, + persist: true, + }); + const checkpointAt = await requireFutureDeadline( + executor, + initialInventory.configuration, + initialInventory.unresolvedSchedules, + dependencies, + true + ); + if ( + job.leaseToken !== null && + job.leaseUntil !== null && + job.leaseUntil.getTime() > checkpointAt.getTime() && + outcome.kind !== 'canceled' + ) { + fail('cancellation_operator_already_running'); + } + lookups.push({ job, outcome, checkpointAt }); + if (outcome.kind === 'scheduled') { + verifiedScheduled.add(job.providerEmailId); + } else if (outcome.kind === 'canceled') { + verifiedScheduled.delete(job.providerEmailId); + } + } + const unresolvedLookups = lookups.filter( + ( + entry + ): entry is typeof entry & { + outcome: Extract; + } => entry.outcome.kind === 'unresolved' + ); + if (unresolvedLookups.length > 0) { + for (const { job, outcome, checkpointAt } of unresolvedLookups) { + await persistUnresolved(executor, job, outcome.code, checkpointAt); + } + fail(unresolvedLookups[0]?.outcome.failure ?? 'provider_lookup_ambiguous'); + } + const settledRecoveryJobIds = new Set(); + for (const { job, outcome } of lookups) { + if (outcome.kind === 'canceled') { + const settlementAt = await requireFutureDeadline( + executor, + initialInventory.configuration, + initialInventory.unresolvedSchedules.filter( + ({ id }) => !settledRecoveryJobIds.has(id) + ), + dependencies, + true + ); + await settleCancellation(executor, job, settlementAt); + settledRecoveryJobIds.add(job.id); + } + } + + let inventory = await readInventory(executor); + const unresolvedIds = new Set( + inventory.unresolvedSchedules.map(({ providerEmailId }) => providerEmailId) + ); + if (!setsEqual(unresolvedIds, verifiedScheduled)) { + fail('unresolved_inventory_mismatch'); + } + + let cancellationFailed = false; + for ( + let index = 0; + index < inventory.unresolvedSchedules.length; + index += 1 + ) { + const job = inventory.unresolvedSchedules[index] as ValidLegacyJob; + const claimAt = await requireFutureDeadline( + executor, + inventory.configuration, + inventory.unresolvedSchedules.slice(index), + dependencies, + true + ); + const claimToken = await claimCancellation( + executor, + job, + claimAt, + inventory.configuration + ); + let result: unknown; + try { + result = await requestProvider( + { + executor, + configuration: inventory.configuration, + unresolved: [job], + dependencies, + persist: true, + claimToken, + }, + 'provider_cancel_outcome_unknown', + 'legacy_resend_cancel_outcome_unknown', + (signal) => client.emails.cancel(job.providerEmailId, { signal }) + ); + } catch (error) { + if (error instanceof CancellationFailure) throw error; + await persistUnresolved( + executor, + job, + 'legacy_resend_cancel_outcome_unknown', + currentTime(dependencies), + claimToken + ); + fail('provider_cancel_outcome_unknown'); + } + const requestCompletedAt = await requireFutureDeadline( + executor, + inventory.configuration, + inventory.unresolvedSchedules.slice(index), + dependencies, + true, + claimToken + ); + let wrapper: ProviderResponseWrapper; + try { + wrapper = providerResponseWrapper( + result, + 'provider_cancel_response_malformed' + ); + } catch (error) { + if ( + error instanceof CancellationFailure && + error.code === 'provider_cancel_response_malformed' + ) { + await persistUnresolved( + executor, + job, + 'legacy_resend_cancel_outcome_unknown', + requestCompletedAt, + claimToken + ); + fail('provider_cancel_outcome_unknown'); + } + throw error; + } + if (wrapper.error !== null) { + await persistUnresolved( + executor, + job, + 'legacy_resend_cancel_provider_failed', + currentTime(dependencies), + claimToken + ); + cancellationFailed = true; + break; + } + if (!successfulCancelResult(wrapper, job.providerEmailId)) { + await persistUnresolved( + executor, + job, + 'legacy_resend_cancel_outcome_unknown', + currentTime(dependencies), + claimToken + ); + fail('provider_cancel_outcome_unknown'); + } + await settleCancellation(executor, job, requestCompletedAt, claimToken); + } + + inventory = await readInventory(executor); + const finalProviderScheduled = await listProviderScheduled(client, { + executor, + configuration: inventory.configuration, + unresolved: inventory.unresolvedSchedules, + dependencies, + persist: true, + }); + await requireFutureDeadline( + executor, + inventory.configuration, + inventory.unresolvedSchedules, + dependencies, + true + ); + const finalUnexpected = [...finalProviderScheduled].filter( + (id) => !importedProviderIds.has(id) + ).length; + const outputAt = await requireFutureDeadline( + executor, + inventory.configuration, + inventory.unresolvedSchedules, + dependencies, + true + ); + const output = { + ...summary({ + mode: 'apply', + inventory, + providerScheduled: initialProviderScheduled, + missingUnresolved, + unexpectedProviderScheduled: finalUnexpected, + providerScheduledRemaining: finalProviderScheduled.size, + at: outputAt, + }), + unresolved_imported: inventory.unresolvedSchedules.length, + }; + const success = + !cancellationFailed && + inventory.unresolvedSchedules.length === 0 && + finalUnexpected === 0 && + finalProviderScheduled.size === 0; + return { output, success }; +} + +export async function mainCancelResendLifecycle( + argv: readonly string[] = process.argv.slice(2), + dependencies: LegacyCancellationDependencies = DEFAULT_DEPENDENCIES +): Promise { + let executor: SqlExecutor | undefined; + try { + const args = parseArguments(argv); + const databaseUrl = + args.mode === 'apply' + ? databaseUrlForApply(args, dependencies.environment) + : databaseUrlForDryRun(dependencies.environment); + const apiKey = dependencies.environment['RESEND_API_KEY']; + if (!apiKey) fail('provider_api_key_missing'); + const client = dependencies.createClient(apiKey); + executor = dependencies.createExecutor(databaseUrl); + const inventory = await readInventory(executor); + if ( + args.mode === 'apply' && + args.expectedScheduled !== inventory.configuration.expectedScheduled + ) { + fail('scheduled_count_drift'); + } + if ( + inventory.configuration.expectedScheduled === 0 && + (inventory.configuration.cancellationDeadline !== null || + inventory.immutableSchedules.length !== 0 || + inventory.unresolvedSchedules.length !== 0) + ) { + fail('cutover_configuration_invalid'); + } + const providerContext: ProviderDeadlineContext = { + executor, + configuration: inventory.configuration, + unresolved: inventory.unresolvedSchedules, + dependencies, + persist: false, + }; + const providerScheduled = await listProviderScheduled( + client, + providerContext + ); + const importedProviderIds = new Set( + inventory.immutableSchedules.map(({ providerEmailId }) => providerEmailId) + ); + const unexpectedProviderScheduled = [...providerScheduled].filter( + (id) => !importedProviderIds.has(id) + ).length; + if (unexpectedProviderScheduled > 0) { + fail('provider_inventory_unexpected'); + } + if (inventory.configuration.expectedScheduled > 0) { + await requireFutureDeadline( + executor, + inventory.configuration, + inventory.unresolvedSchedules, + dependencies, + args.mode === 'apply' + ); + } + if (inventory.configuration.expectedScheduled === 0) { + if (providerScheduled.size !== 0) fail('provider_inventory_unexpected'); + dependencies.writeOutput( + JSON.stringify({ + ...summary({ + mode: args.mode, + inventory, + providerScheduled, + missingUnresolved: 0, + unexpectedProviderScheduled: 0, + ...(args.mode === 'apply' ? { providerScheduledRemaining: 0 } : {}), + at: currentTime(dependencies), + }), + }) + ); + return 0; + } + if (args.mode === 'dry_run') { + const missingUnresolved = await verifyDryRunInventory( + client, + inventory, + providerScheduled, + providerContext + ); + const outputAt = await requireFutureDeadline( + executor, + inventory.configuration, + inventory.unresolvedSchedules, + dependencies, + false + ); + dependencies.writeOutput( + JSON.stringify( + summary({ + mode: 'dry_run', + inventory, + providerScheduled, + missingUnresolved, + unexpectedProviderScheduled, + at: outputAt, + }) + ) + ); + return 0; + } + const applied = await applyCancellation( + executor, + client, + inventory, + providerScheduled, + dependencies + ); + dependencies.writeOutput(JSON.stringify(applied.output)); + if (!applied.success) fail('provider_inventory_not_empty'); + return 0; + } catch (error) { + const code = + error instanceof CancellationFailure + ? error.code + : 'database_cancellation_failed'; + dependencies.writeError( + code === 'usage_error' + ? USAGE + : `Resend lifecycle cancellation failed: ${code}` + ); + return code === 'usage_error' ? 2 : 1; + } finally { + if (executor) { + try { + await executor.close?.(); + } catch { + // Never redisclose a database/provider error from cleanup. + } + } + } +} + +const DEFAULT_DEPENDENCIES: LegacyCancellationDependencies = { + environment: process.env, + createClient: (apiKey) => createAbortableResendCancellationClient(apiKey), + createExecutor: (databaseUrl) => createDatabaseExecutor(databaseUrl), + writeOutput: (line) => process.stdout.write(`${line}\n`), + writeError: (line) => process.stderr.write(`${line}\n`), +}; + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : null; +if (invokedPath === import.meta.url) { + void mainCancelResendLifecycle().then((exitCode) => { + process.exitCode = exitCode; + }); +} diff --git a/scripts/cancel-resend-lifecycle.spec.ts b/scripts/cancel-resend-lifecycle.spec.ts new file mode 100644 index 000000000..ef930b8c1 --- /dev/null +++ b/scripts/cancel-resend-lifecycle.spec.ts @@ -0,0 +1,3001 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// This repository-level operator deliberately sits outside the Nx growth project. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + createDatabaseExecutor, + type SqlExecutor, + type SqlQueryResult, + type SqlTransaction, +} from '../libs/growth/src/index.ts'; +import { applyMigrations } from './apply-migrations.mts'; + +import { + createAbortableResendCancellationClient, + mainCancelResendLifecycle, + type LegacyCancellationClient, + type LegacyCancellationDependencies, +} from './cancel-resend-lifecycle.mts'; + +const now = new Date('2026-09-02T12:00:00.000Z'); +const futureDeadline = '2026-09-03T11:55:00.000Z'; +const contactProviderId = 'opaque_contact_1'; +const scheduledProviderId = 'opaque_email_1'; +const otherScheduledProviderId = 'opaque_email_2'; +const jobId = '00000000-0000-4000-8000-000000000101'; +const otherJobId = '00000000-0000-4000-8000-000000000102'; +const contactId = '00000000-0000-4000-8000-000000000001'; + +interface CancellationJob extends Record { + id: string; + contact_id: string; + available_at: Date; + provider_email_id: string; + status: string; + payload: Record; + last_error_code: string | null; + lease_token: string | null; + lease_until: Date | null; +} + +interface CancellationState { + configuration: Record | null; + contactMarkers: Array; + jobs: CancellationJob[]; + activities: Map>; + queries: Array<{ sql: string; parameters: readonly unknown[] }>; + failSettlementOnce?: boolean; + ignoreErrorPersist?: boolean; +} + +interface ProviderRequestOptions { + signal?: AbortSignal; +} + +function waitForAbort(options?: ProviderRequestOptions): Promise { + if (!options?.signal) { + return Promise.reject(new Error('abort signal missing')); + } + return new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => reject(new DOMException('private timeout', 'AbortError')), + { once: true } + ); + }); +} + +function snapshotIdentity( + contacts: readonly string[], + scheduled: readonly string[] +): string { + return createHash('sha256') + .update( + [ + 'contacts', + String(contacts.length), + ...[...contacts].sort(), + 'scheduled_messages', + String(scheduled.length), + ...[...scheduled].sort(), + ].join('\0') + ) + .digest('hex'); +} + +function legacyJob(input?: Partial): CancellationJob { + return { + id: jobId, + contact_id: contactId, + available_at: new Date('2026-09-03T12:00:00.000Z'), + provider_email_id: scheduledProviderId, + status: 'pending', + payload: { + imported: true, + legacy_type: 'scheduled_message', + provider: 'resend', + provider_state: 'scheduled', + }, + last_error_code: null, + lease_token: null, + lease_until: null, + ...input, + }; +} + +function cancellationState(input?: { + expectedContacts?: number; + expectedScheduled?: number; + deadline?: string | null; + contactMarkers?: Array; + jobs?: CancellationJob[]; +}): CancellationState { + const contactMarkers = input?.contactMarkers ?? [contactProviderId]; + const jobs = input?.jobs ?? [legacyJob()]; + const expectedContacts = input?.expectedContacts ?? contactMarkers.length; + const expectedScheduled = input?.expectedScheduled ?? jobs.length; + const boundedContacts = contactMarkers.filter( + (value): value is string => typeof value === 'string' + ); + const scheduled = jobs.map(({ provider_email_id }) => provider_email_id); + return { + configuration: { + event_key: 'legacy:resend:cutover:v1:configuration', + contact_id: null, + project_id: null, + occurred_at: new Date('2026-09-02T11:00:00.000Z'), + kind: 'legacy.resend_cutover_configured', + data: { + snapshot_at: '2026-09-02T11:00:00.000Z', + cancellation_deadline: + input && 'deadline' in input ? input.deadline : futureDeadline, + expected_contacts: expectedContacts, + expected_scheduled: expectedScheduled, + snapshot_identity: snapshotIdentity(boundedContacts, scheduled), + }, + }, + contactMarkers, + jobs, + activities: new Map(), + queries: [], + }; +} + +function marker(sql: string): string | undefined { + return /\/\* growth:([a-z0-9-]+) \*\//u.exec(sql)?.[1]; +} + +function cancellationExecutor(state: CancellationState): SqlExecutor { + const transaction: SqlTransaction = { + async execute>( + sql: string, + parameters: readonly unknown[] = [] + ): Promise> { + state.queries.push({ sql, parameters: [...parameters] }); + switch (marker(sql)) { + case 'cancel-claim-job': { + const found = state.jobs.find( + ({ id, provider_email_id, payload, lease_token, lease_until }) => + id === parameters[0] && + provider_email_id === parameters[1] && + payload['provider_state'] === 'scheduled' && + (lease_token === null || + (lease_until !== null && + lease_until.getTime() <= + new Date(String(parameters[2])).getTime())) + ); + if (found) { + found.lease_token = String(parameters[3]); + found.lease_until = new Date(String(parameters[4])); + if ( + sql.includes( + "last_error_code = 'legacy_resend_cancel_outcome_unknown'" + ) + ) { + found.last_error_code = 'legacy_resend_cancel_outcome_unknown'; + } + } + return { + rows: found ? [{ id: found.id }] : [], + } as unknown as SqlQueryResult; + } + case 'cancel-read-cutover-configuration': + return { + rows: state.configuration ? [state.configuration] : [], + } as unknown as SqlQueryResult; + case 'cancel-read-contact-markers': + return { + rows: state.contactMarkers.map((provider_contact_id) => ({ + provider_contact_id, + })), + } as unknown as SqlQueryResult; + case 'cancel-read-immutable-schedules': + return { rows: state.jobs } as unknown as SqlQueryResult; + case 'cancel-read-unresolved-schedules': + return { + rows: state.jobs.filter( + ({ payload }) => payload['provider_state'] === 'scheduled' + ), + } as unknown as SqlQueryResult; + case 'cancel-settle-job': { + if (state.failSettlementOnce) { + state.failSettlementOnce = false; + throw new Error('private database failure'); + } + const found = state.jobs.find( + ({ id, provider_email_id, payload, lease_token }) => + id === parameters[0] && + provider_email_id === parameters[2] && + payload['provider_state'] === 'scheduled' && + (parameters[3] === null || lease_token === parameters[3]) + ); + if (!found) return { rows: [] } as SqlQueryResult; + found.status = 'cancelled'; + found.last_error_code = null; + found.lease_token = null; + found.lease_until = null; + found.payload = { + ...found.payload, + provider_state: 'cancelled', + cancelled_at: new Date(String(parameters[1])).toISOString(), + }; + return { + rows: [{ id: found.id }], + } as unknown as SqlQueryResult; + } + case 'cancel-insert-activity': { + const eventKey = String(parameters[0]); + if (state.activities.has(eventKey)) { + if (/on conflict \(event_key\) do nothing/u.test(sql)) { + return { rows: [] } as SqlQueryResult; + } + throw new Error('activity conflict'); + } + const activity = { + event_key: eventKey, + contact_id: parameters[1], + occurred_at: parameters[2], + kind: 'legacy.resend_schedule_cancelled', + data: { provider: 'resend' }, + }; + state.activities.set(eventKey, activity); + return { rows: [activity] } as unknown as SqlQueryResult; + } + case 'cancel-persist-error': { + if (state.ignoreErrorPersist) { + return { rows: [] } as SqlQueryResult; + } + const found = state.jobs.find(({ id }) => id === parameters[0]); + if ( + found && + found.payload['provider_state'] === 'scheduled' && + (parameters[4] === null || found.lease_token === parameters[4]) + ) { + found.last_error_code = String(parameters[2]); + found.lease_token = null; + found.lease_until = null; + return { + rows: [{ id: found.id }], + } as unknown as SqlQueryResult; + } + return { rows: [] } as SqlQueryResult; + } + default: + throw new Error(`Unexpected SQL marker: ${marker(sql) ?? 'missing'}`); + } + }, + }; + return { + execute: transaction.execute, + async transaction( + operation: (inner: SqlTransaction) => Promise + ): Promise { + const jobs = structuredClone(state.jobs); + const activities = structuredClone(state.activities); + try { + return await operation(transaction); + } catch (error) { + state.jobs = jobs; + state.activities = activities; + throw error; + } + }, + close: vi.fn(async () => undefined), + }; +} + +type ProviderEmail = Record & { id: string }; + +function exactProviderEmail( + id: string, + lastEvent: + | 'bounced' + | 'canceled' + | 'clicked' + | 'complained' + | 'delivered' + | 'delivery_delayed' + | 'failed' + | 'opened' + | 'queued' + | 'scheduled' + | 'sent' +): Record { + return { + bcc: null, + cc: null, + created_at: '2026-09-01T00:00:00.000Z', + from: 'Private Sender ', + html: null, + id, + last_event: lastEvent, + object: 'email', + reply_to: null, + scheduled_at: lastEvent === 'scheduled' ? '2026-09-03T12:00:00.000Z' : null, + subject: 'Private subject', + text: null, + to: ['private-recipient@example.invalid'], + }; +} + +function providerError( + name: string, + statusCode: number | null, + message = 'private-provider-message@example.invalid' +): Record { + return { message, name, statusCode }; +} + +function providerHarness(input?: { + pages?: ProviderEmail[][]; + getResults?: Record; + cancelResults?: Record< + string, + { data: unknown | null; error: unknown | null } + >; +}) { + const canceledIds = new Set(); + let pages = input?.pages ?? [ + [ + { + id: scheduledProviderId, + to: ['private-recipient@example.invalid'], + subject: 'Private subject', + last_event: 'scheduled', + }, + ], + ]; + const list = vi.fn( + async ( + options: { limit: number; after?: string }, + _request?: ProviderRequestOptions + ) => { + const index = options.after + ? pages.findIndex((page) => page.at(-1)?.id === options.after) + 1 + : 0; + return { + data: { + object: 'list' as const, + data: pages[index] ?? [], + has_more: index < pages.length - 1, + }, + error: null, + }; + } + ); + const get = vi.fn(async (id: string, _request?: ProviderRequestOptions) => + input?.getResults?.[id] + ? input.getResults[id] + : canceledIds.has(id) + ? { data: exactProviderEmail(id, 'canceled'), error: null } + : { data: null, error: { message: 'private provider error' } } + ); + const cancel = vi.fn( + async (id: string, _request?: ProviderRequestOptions) => { + const result = input?.cancelResults?.[id] ?? { + data: { id, object: 'email' }, + error: null, + }; + if (result.error === null && result.data !== null) { + canceledIds.add(id); + pages = pages.map((page) => page.filter((email) => email.id !== id)); + } + return result; + } + ); + return { + list, + get, + cancel, + client: { emails: { list, get, cancel } } as LegacyCancellationClient, + }; +} + +function mainHarness(input?: { + state?: CancellationState; + provider?: ReturnType; + executor?: SqlExecutor; + environment?: Record; + now?: () => Date; +}) { + const state = input?.state ?? cancellationState(); + const provider = input?.provider ?? providerHarness(); + const executor = input?.executor ?? cancellationExecutor(state); + const output: string[] = []; + const errors: string[] = []; + const createClient = vi.fn(() => provider.client); + const createExecutor = vi.fn((databaseUrl: string) => { + void databaseUrl; + return executor; + }); + const dependencies: LegacyCancellationDependencies = { + environment: input?.environment ?? { + RESEND_API_KEY: 'private-api-key', + TEST_DATABASE_URL: 'postgres://private-test-database', + }, + createClient, + createExecutor, + writeOutput: (line) => output.push(line), + writeError: (line) => errors.push(line), + now: input?.now ?? (() => new Date(now)), + }; + return { + state, + provider, + executor, + output, + errors, + createClient, + createExecutor, + dependencies, + }; +} + +function applyArguments(expectedScheduled = 1): string[] { + return ['--apply', '--expected-scheduled', String(expectedScheduled)]; +} + +function allRenderedText(harness: ReturnType): string { + return [...harness.output, ...harness.errors].join('\n'); +} + +function cancellationWriteMarkers(state: CancellationState): string[] { + const writeMarkers = new Set([ + 'cancel-claim-job', + 'cancel-insert-activity', + 'cancel-persist-error', + 'cancel-settle-job', + ]); + return state.queries + .map(({ sql }) => marker(sql)) + .filter( + (value): value is string => value !== undefined && writeMarkers.has(value) + ); +} + +describe('mainCancelResendLifecycle', () => { + it('uses the exact encoded Resend list, get, and cancellation wire contract', async () => { + const calls: Array<{ + input: string | URL | Request; + init?: RequestInit; + }> = []; + const fetchImplementation = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ input, init }); + const url = String(input); + const payload = url.endsWith('/cancel') + ? { id: 'fixture/id?part', object: 'email' } + : url.includes('?') + ? { object: 'list', data: [], has_more: false } + : exactProviderEmail('fixture/id?part', 'scheduled'); + return new Response(JSON.stringify(payload), { + headers: { 'content-type': 'application/json' }, + }); + } + ); + const client = createAbortableResendCancellationClient( + 'synthetic-key', + fetchImplementation as typeof fetch + ); + const controller = new AbortController(); + + await client.emails.list( + { limit: 100, after: 'fixture/cursor?' }, + { signal: controller.signal } + ); + await client.emails.get('fixture/id?part', { + signal: controller.signal, + }); + await client.emails.cancel('fixture/id?part', { + signal: controller.signal, + }); + + expect(calls.map(({ input }) => String(input))).toEqual([ + 'https://api.resend.com/emails?limit=100&after=fixture%2Fcursor%3F', + 'https://api.resend.com/emails/fixture%2Fid%3Fpart', + 'https://api.resend.com/emails/fixture%2Fid%3Fpart/cancel', + ]); + expect(calls.map(({ init }) => init?.method)).toEqual([ + 'GET', + 'GET', + 'POST', + ]); + const cancellationHeaders = new Headers(calls[2]?.init?.headers); + expect({ + authorizationIsCorrect: + cancellationHeaders.get('authorization') === 'Bearer synthetic-key', + contentTypeIsCorrect: + cancellationHeaders.get('content-type') === 'application/json', + }).toEqual({ authorizationIsCorrect: true, contentTypeIsCorrect: true }); + expect(calls.map(({ init }) => init?.signal)).toEqual([ + controller.signal, + controller.signal, + controller.signal, + ]); + }); + + it.each([ + [ + 'declared oversized list', + 'list', + new Response( + JSON.stringify({ object: 'list', data: [], has_more: false }), + { + headers: { 'content-length': '1048577' }, + } + ), + ], + [ + 'chunked oversized get', + 'get', + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('"')); + controller.enqueue(new Uint8Array(1_048_576).fill(97)); + controller.enqueue(new TextEncoder().encode('"')); + controller.close(); + }, + }) + ), + ], + [ + 'understated oversized cancellation', + 'cancel', + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('"')); + controller.enqueue(new Uint8Array(1_048_576).fill(97)); + controller.enqueue(new TextEncoder().encode('"')); + controller.close(); + }, + }), + { headers: { 'content-length': '2' } } + ), + ], + ['invalid JSON list', 'list', new Response('{')], + [ + 'malformed encoding get', + 'get', + new Response(Uint8Array.from([0xc3, 0x28])), + ], + [ + 'truncated cancellation', + 'cancel', + new Response('{}', { headers: { 'content-length': '10' } }), + ], + ] as const)( + 'bounds a %s response before JSON parsing', + async (_name, method, response) => { + const fetchImplementation = vi.fn(async () => response); + const client = createAbortableResendCancellationClient( + 'synthetic-key', + fetchImplementation as typeof fetch + ); + const result = + method === 'list' + ? await client.emails.list({ limit: 100 }) + : method === 'get' + ? await client.emails.get('fixture-id') + : await client.emails.cancel('fixture-id'); + + expect(result).toEqual({ data: null, error: null }); + } + ); + + it.each(['list', 'get', 'cancel'] as const)( + 'uses the supplied AbortSignal in the default %s transport', + async (method) => { + const fetchImplementation = vi.fn( + async (_input: string | URL | Request, init?: RequestInit) => + waitForAbort({ signal: init?.signal ?? undefined }) + ); + const client = createAbortableResendCancellationClient( + 'private-api-key', + fetchImplementation as typeof fetch + ); + const controller = new AbortController(); + const request = + method === 'list' + ? client.emails.list({ limit: 100 }, { signal: controller.signal }) + : method === 'get' + ? client.emails.get(scheduledProviderId, { + signal: controller.signal, + }) + : client.emails.cancel(scheduledProviderId, { + signal: controller.signal, + }); + + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchImplementation).toHaveBeenCalledTimes(1); + expect(fetchImplementation.mock.calls[0]?.[1]?.signal).toBe( + controller.signal + ); + } + ); + + it.each(['list', 'get', 'cancel'] as const)( + 'aborts and cancels a hanging default %s response body stream', + async (method) => { + let bodyCanceled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"data":')); + }, + pull: () => new Promise(() => undefined), + cancel() { + bodyCanceled = true; + }, + }) + ); + const fetchImplementation = vi.fn(async () => response); + const client = createAbortableResendCancellationClient( + 'synthetic-key', + fetchImplementation as typeof fetch + ); + const controller = new AbortController(); + const request = + method === 'list' + ? client.emails.list({ limit: 100 }, { signal: controller.signal }) + : method === 'get' + ? client.emails.get('fixture-id', { signal: controller.signal }) + : client.emails.cancel('fixture-id', { signal: controller.signal }); + await vi.waitFor(() => expect(fetchImplementation).toHaveBeenCalled()); + + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(bodyCanceled).toBe(true); + } + ); + + it('maps a malformed default list body to a closed read-only provider category', async () => { + const client = createAbortableResendCancellationClient( + 'synthetic-key', + vi.fn(async () => new Response('{')) as typeof fetch + ); + const harness = mainHarness(); + + expect( + await mainCancelResendLifecycle(['--dry-run'], { + ...harness.dependencies, + createClient: () => client, + }) + ).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_emails_response_malformed', + ]); + expect(cancellationWriteMarkers(harness.state)).toEqual([]); + }); + + it('maps a malformed default exact-get body to the closed lookup category', async () => { + const responses = [ + new Response( + JSON.stringify({ object: 'list', data: [], has_more: false }) + ), + new Response(Uint8Array.from([0xc3, 0x28])), + ]; + const client = createAbortableResendCancellationClient( + 'synthetic-key', + vi.fn(async () => responses.shift() as Response) as typeof fetch + ); + const harness = mainHarness(); + + expect( + await mainCancelResendLifecycle(applyArguments(), { + ...harness.dependencies, + createClient: () => client, + }) + ).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_lookup_malformed', + ]); + expect(harness.state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_lookup_malformed' + ); + }); + + it('maps a malformed default cancellation body to outcome unknown', async () => { + const responses = [ + new Response( + JSON.stringify({ + object: 'list', + data: [{ id: scheduledProviderId, last_event: 'scheduled' }], + has_more: false, + }) + ), + new Response('{'), + ]; + const client = createAbortableResendCancellationClient( + 'synthetic-key', + vi.fn(async () => responses.shift() as Response) as typeof fetch + ); + const harness = mainHarness(); + + expect( + await mainCancelResendLifecycle(applyArguments(), { + ...harness.dependencies, + createClient: () => client, + }) + ).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_cancel_outcome_unknown', + ]); + expect(harness.state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancel_outcome_unknown' + ); + }); + + it('dry-run reads Neon and every bounded Resend page, emits aggregate counts, and never cancels', async () => { + const provider = providerHarness({ + pages: [ + [{ id: scheduledProviderId, last_event: 'scheduled' }], + [{ id: 'opaque_delivered_1', last_event: 'delivered' }], + ], + }); + const harness = mainHarness({ provider }); + + const exitCode = await mainCancelResendLifecycle( + ['--dry-run'], + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(harness.createExecutor).toHaveBeenCalledWith( + 'postgres://private-test-database' + ); + expect(provider.list).toHaveBeenNthCalledWith( + 1, + { limit: 100 }, + { signal: expect.any(AbortSignal) } + ); + expect(provider.list).toHaveBeenNthCalledWith( + 2, + { + limit: 100, + after: scheduledProviderId, + }, + { signal: expect.any(AbortSignal) } + ); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(JSON.parse(String(harness.output[0]))).toEqual({ + command: 'cancel-resend-lifecycle', + mode: 'dry_run', + immutable_contacts: 1, + immutable_scheduled: 1, + unresolved_imported: 1, + provider_scheduled: 1, + missing_unresolved: 0, + unexpected_provider_scheduled: 0, + cancellation_remaining_seconds: 86_100, + }); + }); + + it('fails a positive dry-run when the immutable window expires at output time without writing', async () => { + const deadline = new Date('2026-09-02T12:00:00.004Z'); + const state = cancellationState({ + deadline: deadline.toISOString(), + jobs: [ + legacyJob({ + available_at: new Date(deadline.getTime() + 5 * 60_000), + }), + ], + }); + let clockReads = 0; + const harness = mainHarness({ + state, + now: () => { + clockReads += 1; + return new Date( + clockReads < 4 ? deadline.getTime() - 1 : deadline.getTime() + ); + }, + }); + + expect( + await mainCancelResendLifecycle(['--dry-run'], harness.dependencies) + ).toBe(1); + expect(harness.output).toEqual([]); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: cancellation_deadline_expired', + ]); + expect(cancellationWriteMarkers(state)).toEqual([]); + expect(state.jobs[0]?.last_error_code).toBeNull(); + }); + + it('dry-run exact-checks missing unresolved records and rejects ambiguity without mutation', async () => { + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'delivered'), + error: null, + }, + }, + }); + const harness = mainHarness({ provider }); + + const exitCode = await mainCancelResendLifecycle( + ['--dry-run'], + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.get).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(harness.state.jobs[0]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + last_error_code: null, + }); + }); + + it('aborts a hung initial provider list at the closed request maximum', async () => { + vi.useFakeTimers(); + try { + const provider = providerHarness(); + provider.list.mockImplementationOnce((_options, request) => + waitForAbort(request) + ); + const harness = mainHarness({ provider }); + let settled = false; + const result = mainCancelResendLifecycle( + ['--dry-run'], + harness.dependencies + ).then((exitCode) => { + settled = true; + return exitCode; + }); + + await vi.advanceTimersByTimeAsync(9_999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(await result).toBe(1); + expect(provider.list.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_emails_request_timeout', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps an apply preflight read-only when the initial provider page times out', async () => { + vi.useFakeTimers(); + try { + const state = cancellationState(); + const provider = providerHarness(); + provider.list.mockImplementationOnce((_options, request) => + waitForAbort(request) + ); + const harness = mainHarness({ state, provider }); + const result = mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(await result).toBe(1); + expect(cancellationWriteMarkers(state)).toEqual([]); + expect(state.jobs[0]?.last_error_code).toBeNull(); + expect(state.activities).toHaveLength(0); + expect(provider.cancel).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps an apply preflight read-only when a later pagination page times out', async () => { + vi.useFakeTimers(); + try { + const provider = providerHarness(); + provider.list + .mockResolvedValueOnce({ + data: { + object: 'list', + data: [{ id: scheduledProviderId, last_event: 'scheduled' }], + has_more: true, + }, + error: null, + }) + .mockImplementationOnce((_options, request) => waitForAbort(request)); + const harness = mainHarness({ provider }); + const result = mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(await result).toBe(1); + expect(provider.list).toHaveBeenCalledTimes(2); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(cancellationWriteMarkers(harness.state)).toEqual([]); + expect(harness.state.jobs[0]?.last_error_code).toBeNull(); + expect(harness.state.activities).toHaveLength(0); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps an apply preflight read-only when a later provider page is malformed', async () => { + const state = cancellationState(); + const provider = providerHarness(); + provider.list + .mockResolvedValueOnce({ + data: { + object: 'list', + data: [{ id: scheduledProviderId, last_event: 'scheduled' }], + has_more: true, + }, + error: null, + }) + .mockResolvedValueOnce(undefined as never); + const harness = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(provider.list).toHaveBeenCalledTimes(2); + expect(cancellationWriteMarkers(state)).toEqual([]); + expect(state.jobs[0]?.last_error_code).toBeNull(); + expect(state.activities).toHaveLength(0); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + }); + + it('aborts a hung exact lookup and persists a closed lookup timeout', async () => { + vi.useFakeTimers(); + try { + const provider = providerHarness({ pages: [[]] }); + provider.get.mockImplementationOnce((_id, request) => + waitForAbort(request) + ); + const harness = mainHarness({ provider }); + const result = mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(await result).toBe(1); + expect(provider.get).toHaveBeenCalledTimes(1); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(harness.state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_lookup_timeout' + ); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps an ambiguously timed-out cancel unresolved and starts no later cancel', async () => { + vi.useFakeTimers(); + try { + const state = cancellationState({ + jobs: [ + legacyJob(), + legacyJob({ + id: otherJobId, + provider_email_id: otherScheduledProviderId, + }), + ], + }); + const provider = providerHarness({ + pages: [ + [ + { id: scheduledProviderId, last_event: 'scheduled' }, + { id: otherScheduledProviderId, last_event: 'scheduled' }, + ], + ], + }); + provider.cancel.mockImplementationOnce((_id, request) => + waitForAbort(request) + ); + const harness = mainHarness({ state, provider }); + const result = mainCancelResendLifecycle( + applyArguments(2), + harness.dependencies + ); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(await result).toBe(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]).toMatchObject({ + payload: { provider_state: 'scheduled' }, + last_error_code: 'legacy_resend_cancel_outcome_unknown', + }); + expect(state.jobs[1]?.payload['provider_state']).toBe('scheduled'); + expect(state.activities).toHaveLength(0); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + } finally { + vi.useRealTimers(); + } + }); + + it('exact-checks an earlier cancel timeout before any later-run cancellation', async () => { + vi.useFakeTimers(); + const state = cancellationState(); + const provider = providerHarness({ + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'canceled'), + error: null, + }, + }, + }); + provider.cancel.mockImplementationOnce((_id, request) => + waitForAbort(request) + ); + const first = mainHarness({ state, provider }); + try { + const firstResult = mainCancelResendLifecycle( + applyArguments(), + first.dependencies + ); + await vi.advanceTimersByTimeAsync(10_000); + expect(await firstResult).toBe(1); + } finally { + vi.useRealTimers(); + } + let rerunListCount = 0; + provider.list.mockImplementation(async () => { + rerunListCount += 1; + return { + data: { + object: 'list' as const, + data: + rerunListCount === 1 + ? [{ id: scheduledProviderId, last_event: 'scheduled' }] + : [], + has_more: false, + }, + error: null, + }; + }); + const second = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), second.dependencies) + ).toBe(0); + expect(provider.get).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]?.payload['provider_state']).toBe('cancelled'); + }); + + it('derives provider timeout from the shorter immutable cancellation window', async () => { + vi.useFakeTimers(); + vi.setSystemTime(now); + try { + const deadline = new Date(now.getTime() + 50); + const state = cancellationState({ + deadline: deadline.toISOString(), + jobs: [ + legacyJob({ + available_at: new Date(deadline.getTime() + 5 * 60_000), + }), + ], + }); + const provider = providerHarness(); + provider.list.mockImplementationOnce((_options, request) => + waitForAbort(request) + ); + const harness = mainHarness({ + state, + provider, + now: () => new Date(Date.now()), + }); + let settled = false; + const result = mainCancelResendLifecycle( + ['--dry-run'], + harness.dependencies + ).then((exitCode) => { + settled = true; + return exitCode; + }); + + await vi.advanceTimersByTimeAsync(49); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(await result).toBe(1); + expect(provider.list.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_emails_request_timeout', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('dry-run rejects a provider-scheduled record whose immutable job is already settled', async () => { + const state = cancellationState({ + jobs: [ + legacyJob({ + status: 'cancelled', + payload: { + imported: true, + legacy_type: 'scheduled_message', + provider: 'resend', + provider_state: 'cancelled', + cancelled_at: '2026-09-02T11:30:00.000Z', + }, + }), + ], + }); + const harness = mainHarness({ state }); + + const exitCode = await mainCancelResendLifecycle( + ['--dry-run'], + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(harness.provider.get).not.toHaveBeenCalled(); + expect(harness.provider.cancel).not.toHaveBeenCalled(); + }); + + it('requires expected scheduled and the importer database guard before provider or Neon access', async () => { + for (const testCase of [ + { + argv: ['--apply'], + environment: { + RESEND_API_KEY: 'private-api-key', + TEST_DATABASE_URL: 'postgres://private-test-database', + }, + expectedCode: 2, + }, + { + argv: applyArguments(), + environment: { + RESEND_API_KEY: 'private-api-key', + DATABASE_URL: 'postgres://private-production-database', + }, + expectedCode: 1, + }, + { + argv: [...applyArguments(), '--allow-database-url-apply'], + environment: { + RESEND_API_KEY: 'private-api-key', + TEST_DATABASE_URL: 'postgres://private-test-database', + DATABASE_URL: 'postgres://private-production-database', + }, + expectedCode: 1, + }, + ]) { + const harness = mainHarness({ environment: testCase.environment }); + const exitCode = await mainCancelResendLifecycle( + testCase.argv, + harness.dependencies + ); + expect(exitCode).toBe(testCase.expectedCode); + expect(harness.createClient).not.toHaveBeenCalled(); + expect(harness.createExecutor).not.toHaveBeenCalled(); + } + }); + + it('allows only one concurrent apply operator to reach an exact provider cancel', async () => { + const state = cancellationState(); + const provider = providerHarness(); + let releaseFirstCancel: (() => void) | undefined; + const firstCancelReleased = new Promise((resolve) => { + releaseFirstCancel = resolve; + }); + const normalCancel = provider.cancel.getMockImplementation(); + let cancelCount = 0; + provider.cancel.mockImplementation(async (...parameters) => { + cancelCount += 1; + if (cancelCount === 1) await firstCancelReleased; + return normalCancel?.(...parameters) as Promise<{ + data: unknown | null; + error: unknown | null; + }>; + }); + const first = mainHarness({ + state, + provider, + executor: cancellationExecutor(state), + }); + const second = mainHarness({ + state, + provider, + executor: cancellationExecutor(state), + }); + + const firstResult = mainCancelResendLifecycle( + applyArguments(), + first.dependencies + ); + await vi.waitFor(() => expect(provider.cancel).toHaveBeenCalledTimes(1)); + const secondResult = await mainCancelResendLifecycle( + applyArguments(), + second.dependencies + ); + releaseFirstCancel?.(); + + expect(secondResult).toBe(1); + expect(second.errors).toEqual([ + 'Resend lifecycle cancellation failed: cancellation_operator_already_running', + ]); + expect(await firstResult).toBe(0); + expect(provider.cancel).toHaveBeenCalledTimes(1); + }); + + it('allows DATABASE_URL apply only with the explicit acknowledgement', async () => { + const harness = mainHarness({ + environment: { + RESEND_API_KEY: 'private-api-key', + DATABASE_URL: 'postgres://private-production-database', + }, + }); + + const exitCode = await mainCancelResendLifecycle( + [...applyArguments(), '--allow-database-url-apply'], + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(harness.createExecutor).toHaveBeenCalledWith( + 'postgres://private-production-database' + ); + }); + + it('halts before mutation when provider has a scheduled ID outside the immutable imported set', async () => { + const provider = providerHarness({ + pages: [ + [ + { id: scheduledProviderId, last_event: 'scheduled' }, + { id: 'opaque_unexpected_1', last_event: 'scheduled' }, + ], + ], + }); + const state = cancellationState({ + deadline: futureDeadline, + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.000Z'), + }), + ], + }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.get).not.toHaveBeenCalled(); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(cancellationWriteMarkers(state)).toEqual([]); + expect(harness.state.activities).toHaveLength(0); + expect(state.jobs[0]?.last_error_code).toBeNull(); + }); + + it('checks missing unresolved IDs exactly before comparing the verified scheduled subset', async () => { + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'scheduled'), + error: null, + }, + }, + }); + const harness = mainHarness({ provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(provider.get).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + }); + + it('requires a stored non-null future deadline for a positive schedule count', async () => { + for (const deadline of [null, now.toISOString()]) { + const state = cancellationState({ + deadline, + ...(deadline === null + ? {} + : { + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.000Z'), + }), + ], + }), + }); + const harness = mainHarness({ state }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(harness.provider.cancel).not.toHaveBeenCalled(); + } + }); + + it('accepts the zero-schedule null-deadline boundary with no get or cancel calls', async () => { + const state = cancellationState({ + expectedContacts: 0, + expectedScheduled: 0, + deadline: null, + contactMarkers: [], + jobs: [], + }); + const provider = providerHarness({ pages: [[]] }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(0), + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(provider.get).not.toHaveBeenCalled(); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(JSON.parse(String(harness.output[0]))).toMatchObject({ + unresolved_imported: 0, + unexpected_provider_scheduled: 0, + provider_scheduled_remaining: 0, + cancellation_remaining_seconds: null, + }); + }); + + it('rejects zero expected schedules unless deadline and all schedule inventories are empty', async () => { + const cases = [ + { + state: cancellationState({ + expectedContacts: 0, + expectedScheduled: 0, + deadline: futureDeadline, + contactMarkers: [], + jobs: [], + }), + provider: providerHarness({ pages: [[]] }), + }, + { + state: cancellationState({ + expectedScheduled: 0, + deadline: null, + }), + provider: providerHarness({ pages: [[]] }), + }, + { + state: cancellationState({ + expectedContacts: 0, + expectedScheduled: 0, + deadline: null, + contactMarkers: [], + jobs: [], + }), + provider: providerHarness(), + }, + ]; + for (const testCase of cases) { + const harness = mainHarness(testCase); + const exitCode = await mainCancelResendLifecycle( + applyArguments(0), + harness.dependencies + ); + expect(exitCode).toBe(1); + expect(testCase.provider.get).not.toHaveBeenCalled(); + expect(testCase.provider.cancel).not.toHaveBeenCalled(); + } + }); + + it('cancels one exact provider record and atomically settles only its job with one stable activity', async () => { + const second = legacyJob({ + id: otherJobId, + provider_email_id: otherScheduledProviderId, + }); + const state = cancellationState({ jobs: [legacyJob(), second] }); + const provider = providerHarness({ + pages: [ + [ + { id: scheduledProviderId, last_event: 'scheduled' }, + { id: otherScheduledProviderId, last_event: 'scheduled' }, + ], + ], + }); + const normalCancel = provider.cancel.getMockImplementation(); + provider.cancel.mockImplementation(async (id) => { + const claimed = state.jobs.find( + ({ provider_email_id }) => provider_email_id === id + ); + expect(claimed).toMatchObject({ + last_error_code: 'legacy_resend_cancel_outcome_unknown', + lease_token: expect.any(String), + lease_until: expect.any(Date), + }); + return normalCancel?.(id) as Promise<{ + data: unknown | null; + error: unknown | null; + }>; + }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(2), + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(provider.cancel.mock.calls.map(([id]) => id)).toEqual([ + scheduledProviderId, + otherScheduledProviderId, + ]); + for (const call of provider.cancel.mock.calls) { + expect(call[1]?.signal).toBeInstanceOf(AbortSignal); + } + expect(state.jobs).toMatchObject([ + { + id: jobId, + status: 'cancelled', + payload: { provider_state: 'cancelled' }, + }, + { + id: otherJobId, + status: 'cancelled', + payload: { provider_state: 'cancelled' }, + }, + ]); + expect([...state.activities.keys()]).toEqual([ + `legacy:resend:scheduled:${jobId}:cancelled`, + `legacy:resend:scheduled:${otherJobId}:cancelled`, + ]); + expect(JSON.parse(String(harness.output[0]))).toMatchObject({ + cancellation_remaining_seconds: 86_100, + }); + const settlement = state.queries.find( + ({ sql }) => marker(sql) === 'cancel-settle-job' + ); + expect(settlement?.parameters.slice(0, 3)).toEqual([ + jobId, + now, + scheduledProviderId, + ]); + expect(settlement?.parameters[3]).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u + ); + }); + + it('persists one closed category and leaves the exact job unresolved on provider error or ambiguity', async () => { + const cases: Array< + [ + { data: unknown | null; error: unknown | null }, + ( + | 'legacy_resend_cancel_provider_failed' + | 'legacy_resend_cancel_outcome_unknown' + ) + ] + > = [ + [ + { data: null, error: { message: 'raw provider failure' } }, + 'legacy_resend_cancel_provider_failed', + ], + [{ data: null, error: null }, 'legacy_resend_cancel_outcome_unknown'], + [ + { data: { id: scheduledProviderId }, error: null }, + 'legacy_resend_cancel_outcome_unknown', + ], + ]; + for (const [result, expectedCode] of cases) { + const state = cancellationState(); + const provider = providerHarness({ + cancelResults: { [scheduledProviderId]: result }, + }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(state.jobs[0]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + last_error_code: expectedCode, + }); + expect(allRenderedText(harness)).not.toContain('raw provider failure'); + } + }); + + it('fails closed when the exact cancellation error category cannot be persisted', async () => { + const state = cancellationState(); + state.ignoreErrorPersist = true; + const provider = providerHarness({ + cancelResults: { + [scheduledProviderId]: { + data: null, + error: { message: 'private provider error' }, + }, + }, + }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: database_cancellation_failed', + ]); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancel_outcome_unknown' + ); + }); + + it('rolls back exact job settlement when the stable cancellation activity conflicts', async () => { + const state = cancellationState(); + const eventKey = `legacy:resend:scheduled:${jobId}:cancelled`; + state.activities.set(eventKey, { + event_key: eventKey, + kind: 'conflicting.kind', + }); + const harness = mainHarness({ state }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(state.jobs[0]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + }); + expect(state.activities.get(eventKey)?.['kind']).toBe('conflicting.kind'); + }); + + it('recovers provider success plus Neon settlement failure through exact canceled lookup without a second cancel', async () => { + const state = cancellationState(); + state.failSettlementOnce = true; + const provider = providerHarness(); + const first = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), first.dependencies) + ).toBe(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]?.payload['provider_state']).toBe('scheduled'); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancel_outcome_unknown' + ); + + let rerunListCalls = 0; + provider.list.mockImplementation(async () => { + rerunListCalls += 1; + return { + data: { + object: 'list', + data: + rerunListCalls === 1 + ? [{ id: scheduledProviderId, last_event: 'scheduled' }] + : [], + has_more: false, + }, + error: null, + }; + }); + + const second = mainHarness({ state, provider }); + expect( + await mainCancelResendLifecycle(applyArguments(), second.dependencies) + ).toBe(0); + expect(provider.get).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]?.payload['provider_state']).toBe('cancelled'); + }); + + it('exact-checks a thrown cancel outcome on rerun even when the provider list is stale', async () => { + const state = cancellationState(); + const provider = providerHarness({ + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'canceled'), + error: null, + }, + }, + }); + provider.cancel.mockRejectedValueOnce( + new Error('private-provider-message@example.invalid') + ); + const first = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), first.dependencies) + ).toBe(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancel_outcome_unknown' + ); + expect(first.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_cancel_outcome_unknown', + ]); + + let rerunListCalls = 0; + provider.list.mockImplementation(async () => { + rerunListCalls += 1; + return { + data: { + object: 'list', + data: + rerunListCalls === 1 + ? [{ id: scheduledProviderId, last_event: 'scheduled' }] + : [], + has_more: false, + }, + error: null, + }; + }); + const second = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), second.dependencies) + ).toBe(0); + expect(provider.get).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(allRenderedText(second)).not.toMatch(/@|opaque_|private/u); + }); + + it('exact-checks a malformed cancel response on rerun even when the provider list is stale', async () => { + const state = cancellationState(); + const provider = providerHarness({ + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'canceled'), + error: null, + }, + }, + }); + provider.cancel.mockResolvedValueOnce(undefined as never); + const first = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), first.dependencies) + ).toBe(1); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancel_outcome_unknown' + ); + + let rerunListCalls = 0; + provider.list.mockImplementation(async () => { + rerunListCalls += 1; + return { + data: { + object: 'list', + data: + rerunListCalls === 1 + ? [{ id: scheduledProviderId, last_event: 'scheduled' }] + : [], + has_more: false, + }, + error: null, + }; + }); + const second = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), second.dependencies) + ).toBe(0); + expect(provider.get).toHaveBeenCalledTimes(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(allRenderedText(second)).not.toMatch(/@|opaque_|private/u); + }); + + it('recovers an expired durable cancellation claim through exact lookup before trusting a stale list', async () => { + const state = cancellationState({ + jobs: [ + legacyJob({ + last_error_code: 'legacy_resend_cancel_outcome_unknown', + lease_token: '00000000-0000-4000-8000-000000000999', + lease_until: new Date(now.getTime() - 1), + }), + ], + }); + let listCalls = 0; + const provider = providerHarness({ + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'canceled'), + error: null, + }, + }, + }); + provider.list.mockImplementation(async () => { + listCalls += 1; + return { + data: { + object: 'list', + data: + listCalls === 1 + ? [{ id: scheduledProviderId, last_event: 'scheduled' }] + : [], + has_more: false, + }, + error: null, + }; + }); + const harness = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(0); + expect(provider.get).toHaveBeenCalledTimes(1); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(state.jobs[0]).toMatchObject({ + status: 'cancelled', + payload: { provider_state: 'cancelled' }, + lease_token: null, + lease_until: null, + }); + }); + + it('does not settle another recovered record after an ambiguous exact lookup', async () => { + const second = legacyJob({ + id: otherJobId, + provider_email_id: otherScheduledProviderId, + }); + const state = cancellationState({ jobs: [legacyJob(), second] }); + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'delivered'), + error: null, + }, + [otherScheduledProviderId]: { + data: exactProviderEmail(otherScheduledProviderId, 'canceled'), + error: null, + }, + }, + }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(2), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.get.mock.calls.map(([id]) => id)).toEqual([ + scheduledProviderId, + otherScheduledProviderId, + ]); + for (const call of provider.get.mock.calls) { + expect(call[1]?.signal).toBeInstanceOf(AbortSignal); + } + expect(provider.cancel).not.toHaveBeenCalled(); + expect(state.jobs[1]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + }); + expect(state.activities).toHaveLength(0); + }); + + it.each([ + ['missing', { data: null, error: providerError('not_found', 404) }], + ['malformed', { data: { id: scheduledProviderId }, error: null }], + [ + 'missing discriminator', + { + data: { id: scheduledProviderId, last_event: 'canceled' }, + error: null, + }, + ], + [ + 'delivered', + { + data: exactProviderEmail(scheduledProviderId, 'delivered'), + error: null, + }, + ], + [ + 'otherwise ambiguous', + { + data: exactProviderEmail(scheduledProviderId, 'queued'), + error: null, + }, + ], + ])('leaves a %s exact lookup unresolved and halts', async (_name, result) => { + const state = cancellationState(); + const provider = providerHarness({ + pages: [[]], + getResults: { [scheduledProviderId]: result }, + }); + const harness = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(state.jobs[0]?.payload['provider_state']).toBe('scheduled'); + expect(state.jobs[0]?.last_error_code).toMatch( + /^legacy_resend_lookup_(missing|malformed|terminal|ambiguous)$/u + ); + }); + + it('classifies only a structurally valid not_found 404 lookup as missing', async () => { + const state = cancellationState(); + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { + data: null, + error: providerError('not_found', 404), + }, + }, + }); + const harness = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(state.jobs[0]?.last_error_code).toBe('legacy_resend_lookup_missing'); + expect(allRenderedText(harness)).not.toMatch( + /@|opaque_|private-provider-message/u + ); + }); + + it('classifies a thrown exact lookup as ambiguous without exposing it', async () => { + const state = cancellationState(); + const provider = providerHarness({ pages: [[]] }); + provider.get.mockRejectedValueOnce( + new Error('private-thrown-message@example.invalid') + ); + const harness = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_lookup_ambiguous' + ); + expect(allRenderedText(harness)).not.toMatch( + /@|opaque_|private-thrown-message/u + ); + }); + + it.each([ + ['auth', providerError('invalid_api_key', 401)], + ['rate limit', providerError('rate_limit_exceeded', 429)], + ['internal', providerError('internal_server_error', 500)], + ['not-found name with non-404 status', providerError('not_found', 500)], + [ + '404 status with non-not-found name', + providerError('application_error', 404), + ], + ])( + 'classifies a valid %s provider error as ambiguous', + async (_name, error) => { + const state = cancellationState(); + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { data: null, error }, + }, + }); + const harness = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_lookup_ambiguous' + ); + expect(allRenderedText(harness)).not.toMatch( + /@|opaque_|private-provider-message/u + ); + } + ); + + it.each([ + ['null error', { data: null, error: null }], + ['non-object error', { data: null, error: 'private-raw-error' }], + [ + 'missing message', + { data: null, error: { name: 'not_found', statusCode: 404 } }, + ], + [ + 'unbounded name', + { + data: null, + error: providerError('x'.repeat(101), 404), + }, + ], + [ + 'unknown name', + { data: null, error: providerError('unknown_error', 500) }, + ], + [ + 'invalid status', + { data: null, error: providerError('not_found', 404.5) }, + ], + [ + 'both data and error', + { + data: exactProviderEmail(scheduledProviderId, 'scheduled'), + error: providerError('not_found', 404), + }, + ], + ])( + 'classifies a %s exact lookup response as malformed', + async (_name, result) => { + const state = cancellationState(); + const provider = providerHarness({ + pages: [[]], + getResults: { [scheduledProviderId]: result }, + }); + const harness = mainHarness({ state, provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_lookup_malformed' + ); + expect(allRenderedText(harness)).not.toMatch( + /@|opaque_|private-provider-message|private-raw-error/u + ); + } + ); + + it.each([ + ['undefined', undefined], + ['null', null], + ['primitive', 7], + ['null/null', { data: null, error: null }], + [ + 'data/error', + { + data: { object: 'list', data: [], has_more: false }, + error: providerError('application_error', 500), + }, + ], + ])( + 'classifies a malformed %s list wrapper without leakage', + async (_name, response) => { + const provider = providerHarness(); + provider.list.mockResolvedValueOnce(response as never); + const harness = mainHarness({ provider }); + + expect( + await mainCancelResendLifecycle(['--dry-run'], harness.dependencies) + ).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_emails_response_malformed', + ]); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + } + ); + + it.each([ + ['undefined', undefined], + ['null', null], + ['primitive', 7], + [ + 'data/error', + { + data: exactProviderEmail(scheduledProviderId, 'scheduled'), + error: providerError('application_error', 500), + }, + ], + ])( + 'classifies a malformed %s get wrapper without leakage', + async (_name, response) => { + const provider = providerHarness({ pages: [[]] }); + provider.get.mockResolvedValueOnce(response as never); + const harness = mainHarness({ provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(harness.state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_lookup_malformed' + ); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_lookup_malformed', + ]); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + } + ); + + it.each([ + ['undefined', undefined], + ['null', null], + ['primitive', 7], + [ + 'data/error', + { + data: { id: scheduledProviderId, object: 'email' }, + error: providerError('application_error', 500), + }, + ], + ])( + 'classifies a malformed %s cancel wrapper without leakage', + async (_name, response) => { + const provider = providerHarness(); + provider.cancel.mockResolvedValueOnce(response as never); + const harness = mainHarness({ provider }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(harness.state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancel_outcome_unknown' + ); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: provider_cancel_outcome_unknown', + ]); + expect(allRenderedText(harness)).not.toMatch(/@|opaque_|private/u); + } + ); + + it('uses the deadline category when time expires during an exact lookup checkpoint', async () => { + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }), + ], + }); + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'delivered'), + error: null, + }, + }, + }); + let clockReads = 0; + const harness = mainHarness({ + state, + provider, + now: () => { + clockReads += 1; + return new Date( + clockReads < 5 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.002Z' + ); + }, + }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.get).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(state.jobs[0]?.last_error_code).toBe( + 'legacy_resend_cancellation_deadline_expired' + ); + }); + + it('rechecks the deadline immediately before each recovered settlement', async () => { + const first = legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }); + const second = legacyJob({ + id: otherJobId, + provider_email_id: otherScheduledProviderId, + available_at: new Date('2026-09-02T12:05:00.001Z'), + }); + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [first, second], + }); + const provider = providerHarness({ + pages: [[]], + getResults: { + [scheduledProviderId]: { + data: exactProviderEmail(scheduledProviderId, 'canceled'), + error: null, + }, + [otherScheduledProviderId]: { + data: exactProviderEmail(otherScheduledProviderId, 'canceled'), + error: null, + }, + }, + }); + let clockReads = 0; + const harness = mainHarness({ + state, + provider, + now: () => { + clockReads += 1; + return new Date( + clockReads < 9 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.002Z' + ); + }, + }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(2), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(state.jobs[0]?.payload['provider_state']).toBe('cancelled'); + expect(state.jobs[1]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + last_error_code: 'legacy_resend_cancellation_deadline_expired', + }); + }); + + it('selects a locally cancelled provider-unsubscribed job while provider state remains scheduled', async () => { + const state = cancellationState({ + jobs: [legacyJob({ status: 'cancelled' })], + }); + const harness = mainHarness({ state }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(0); + expect(harness.provider.cancel).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + const unresolvedQuery = state.queries.find( + ({ sql }) => marker(sql) === 'cancel-read-unresolved-schedules' + ); + expect(unresolvedQuery?.sql).toContain( + "payload->>'provider_state' = 'scheduled'" + ); + expect(unresolvedQuery?.sql).not.toMatch(/\bstatus\s*=/u); + }); + + it('uses complete immutable queries and rejects count, hash, null, duplicate, unbounded, or job-state drift', async () => { + const invalidStates: CancellationState[] = []; + const countDrift = cancellationState(); + (countDrift.configuration?.['data'] as Record)[ + 'expected_scheduled' + ] = 2; + invalidStates.push(countDrift); + const hashDrift = cancellationState(); + (hashDrift.configuration?.['data'] as Record)[ + 'snapshot_identity' + ] = '0'.repeat(64); + invalidStates.push(hashDrift); + invalidStates.push( + cancellationState({ deadline: '2026-09-04T11:55:00.000Z' }) + ); + invalidStates.push(cancellationState({ contactMarkers: [null] })); + invalidStates.push( + cancellationState({ + contactMarkers: [contactProviderId, contactProviderId], + }) + ); + invalidStates.push( + cancellationState({ + jobs: [legacyJob({ provider_email_id: 'x'.repeat(201) })], + }) + ); + invalidStates.push( + cancellationState({ jobs: [legacyJob({ status: 'processing' })] }) + ); + + for (const state of invalidStates) { + const harness = mainHarness({ state }); + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(harness.provider.cancel).not.toHaveBeenCalled(); + } + + const queries = invalidStates[0]?.queries ?? []; + const markerQuery = queries.find( + ({ sql }) => marker(sql) === 'cancel-read-contact-markers' + )?.sql; + const scheduleQuery = queries.find( + ({ sql }) => marker(sql) === 'cancel-read-immutable-schedules' + )?.sql; + expect(markerQuery).toContain("payload->>'legacy_type' = 'contact_marker'"); + expect(markerQuery).not.toMatch(/\bstatus\s*=/u); + expect(scheduleQuery).toContain( + "payload->>'legacy_type' = 'scheduled_message'" + ); + expect(scheduleQuery).not.toMatch(/provider_state|\bstatus\s*=/u); + }); + + it('requires the final bounded provider re-list to contain zero scheduled messages', async () => { + const provider = providerHarness(); + provider.cancel.mockImplementationOnce(async (id: string) => ({ + data: { id, object: 'email' }, + error: null, + })); + const harness = mainHarness({ provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.list).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(harness.output[0]))).toMatchObject({ + unresolved_imported: 0, + unexpected_provider_scheduled: 0, + provider_scheduled_remaining: 1, + }); + }); + + it('reruns after full settlement with zero get and cancel calls', async () => { + const state = cancellationState(); + const provider = providerHarness(); + const first = mainHarness({ state, provider }); + expect( + await mainCancelResendLifecycle(applyArguments(), first.dependencies) + ).toBe(0); + provider.get.mockClear(); + provider.cancel.mockClear(); + const second = mainHarness({ state, provider }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + second.dependencies + ); + + expect(exitCode).toBe(0); + expect(provider.get).not.toHaveBeenCalled(); + expect(provider.cancel).not.toHaveBeenCalled(); + }); + + it('keeps initial provider preflight read-only when its page crosses the deadline', async () => { + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }), + ], + }); + const provider = providerHarness({ + cancelResults: { + [scheduledProviderId]: { + data: null, + error: { + message: + 'raw failure for private-recipient@example.invalid / Private subject', + }, + }, + }, + }); + let clockReads = 0; + const harness = mainHarness({ + state, + provider, + now: () => { + clockReads += 1; + return new Date( + clockReads === 1 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.002Z' + ); + }, + }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.cancel).not.toHaveBeenCalled(); + expect(cancellationWriteMarkers(state)).toEqual([]); + expect(state.jobs[0]?.last_error_code).toBeNull(); + expect(state.activities).toHaveLength(0); + expect(allRenderedText(harness)).not.toMatch( + /@|opaque_|Private subject|private-recipient|raw failure|postgres:/u + ); + }); + + it('does not settle after a successful cancel call crosses the deadline', async () => { + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }), + legacyJob({ + id: otherJobId, + provider_email_id: otherScheduledProviderId, + available_at: new Date('2026-09-02T12:06:00.001Z'), + }), + ], + }); + const provider = providerHarness({ + pages: [ + [ + { id: scheduledProviderId, last_event: 'scheduled' }, + { id: otherScheduledProviderId, last_event: 'scheduled' }, + ], + ], + }); + const harness = mainHarness({ + state, + provider, + now: () => + new Date( + provider.cancel.mock.calls.length === 0 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.001Z' + ), + }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(2), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(provider.cancel).toHaveBeenCalledWith(scheduledProviderId, { + signal: expect.any(AbortSignal), + }); + expect(state.jobs).toHaveLength(2); + for (const job of state.jobs) { + expect(job).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + last_error_code: 'legacy_resend_cancellation_deadline_expired', + }); + } + expect(state.activities).toHaveLength(0); + }); + + it('fails when the final provider re-list crosses the deadline', async () => { + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }), + ], + }); + const provider = providerHarness(); + const harness = mainHarness({ + state, + provider, + now: () => + new Date( + provider.list.mock.calls.length < 2 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.001Z' + ), + }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]?.payload['provider_state']).toBe('cancelled'); + expect(state.activities).toHaveLength(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: cancellation_deadline_expired', + ]); + }); + + it('enforces the fresh output-time clock before reporting apply success', async () => { + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }), + ], + }); + const provider = providerHarness(); + let postFinalListReads = 0; + const harness = mainHarness({ + state, + provider, + now: () => { + if (provider.list.mock.calls.length < 2) { + return new Date('2026-09-02T12:00:00.000Z'); + } + postFinalListReads += 1; + return new Date( + postFinalListReads < 3 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.001Z' + ); + }, + }); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(harness.output).toHaveLength(0); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: cancellation_deadline_expired', + ]); + }); + + it('rewrites remaining unresolved rows when the final provider re-list crosses the deadline', async () => { + const state = cancellationState({ + deadline: '2026-09-02T12:00:00.001Z', + jobs: [ + legacyJob({ + available_at: new Date('2026-09-02T12:05:00.001Z'), + }), + ], + }); + const provider = providerHarness({ + cancelResults: { + [scheduledProviderId]: { + data: null, + error: providerError('application_error', 500), + }, + }, + }); + const harness = mainHarness({ + state, + provider, + now: () => + new Date( + provider.list.mock.calls.length < 2 + ? '2026-09-02T12:00:00.000Z' + : '2026-09-02T12:00:00.001Z' + ), + }); + + const exitCode = await mainCancelResendLifecycle( + applyArguments(), + harness.dependencies + ); + + expect(exitCode).toBe(1); + expect(provider.cancel).toHaveBeenCalledTimes(1); + expect(state.jobs[0]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + last_error_code: 'legacy_resend_cancellation_deadline_expired', + }); + expect(state.activities).toHaveLength(0); + expect(allRenderedText(harness)).not.toMatch( + /@|opaque_|private-provider-message/u + ); + }); + + it('documents distinct pre-import and post-import failure branches', async () => { + const runbook = await readFile( + resolve( + process.cwd(), + 'docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md' + ), + 'utf8' + ); + + expect(runbook).toContain( + 'Pre-import insufficient-window failure: no database or provider mutation occurred.' + ); + expect(runbook).toContain( + 'Restore all three blocked acquisition POST routes and choose a later safe window.' + ); + expect(runbook).toContain( + 'Post-import or cancellation failure: keep all three acquisition POST routes blocked.' + ); + expect(runbook).toContain( + 'Restore ingress only after a reviewed Neon-only boundary is active and every accepted Neon and provider effect is reconciled.' + ); + }); + + it('documents a zero-work preview apply rerun followed by a final dry-run', async () => { + const runbook = await readFile( + resolve( + process.cwd(), + 'docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md' + ), + 'utf8' + ); + const preview = runbook.slice( + runbook.indexOf( + '### PREVIEW LIVE — explicit authorization required', + runbook.indexOf('## 6.') + ), + runbook.indexOf( + '### PRODUCTION LIVE — explicit authorization required', + runbook.indexOf('## 6.') + ) + ); + const applyCommand = + 'env -u DATABASE_URL npm run growth:cancel-resend -- --apply --expected-scheduled "$EXPECTED_SCHEDULED"'; + const firstApply = preview.indexOf(applyCommand); + const secondApply = preview.indexOf(applyCommand, firstApply + 1); + const finalDryRun = preview.lastIndexOf( + 'npm run growth:cancel-resend -- --dry-run' + ); + + expect(firstApply).toBeGreaterThan(-1); + expect(secondApply).toBeGreaterThan(firstApply); + expect(finalDryRun).toBeGreaterThan(secondApply); + }); + + it('documents a zero-work production apply rerun followed by a final dry-run', async () => { + const runbook = await readFile( + resolve( + process.cwd(), + 'docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md' + ), + 'utf8' + ); + const production = runbook.slice( + runbook.indexOf( + '### PRODUCTION LIVE — explicit authorization required', + runbook.indexOf('## 6.') + ), + runbook.indexOf('## 7.') + ); + const applyCommand = + 'env -u TEST_DATABASE_URL npm run growth:cancel-resend -- --apply --expected-scheduled "$EXPECTED_SCHEDULED" --allow-database-url-apply'; + const firstApply = production.indexOf(applyCommand); + const secondApply = production.indexOf(applyCommand, firstApply + 1); + const finalDryRun = production.lastIndexOf( + 'npm run growth:cancel-resend -- --dry-run' + ); + + expect(firstApply).toBeGreaterThan(-1); + expect(secondApply).toBeGreaterThan(firstApply); + expect(finalDryRun).toBeGreaterThan(secondApply); + }); +}); + +const integrationDatabaseUrl = process.env['TEST_DATABASE_URL']; +const describeDatabase = + process.env['GROWTH_INTEGRATION'] === '1' && integrationDatabaseUrl + ? describe + : describe.skip; + +describeDatabase( + integrationDatabaseUrl + ? 'cancel Resend lifecycle against disposable TEST_DATABASE_URL' + : 'cancel Resend lifecycle integration intentionally skipped', + () => { + const integrationSource = 'cancel-resend-lifecycle-integration'; + let inspector: SqlExecutor; + + async function cleanupIntegrationRows(): Promise { + await inspector.execute( + `delete from growth_activity + where contact_id in ( + select id from growth_contacts where source = $1 + ) + or ( + event_key = 'legacy:resend:cutover:v1:configuration' + and data->>'operator_integration' = 'true' + )`, + [integrationSource] + ); + await inspector.execute( + `delete from growth_jobs + where payload->>'operator_integration' = 'true'` + ); + await inspector.execute('delete from growth_contacts where source = $1', [ + integrationSource, + ]); + } + + beforeAll(async () => { + if (!integrationDatabaseUrl) { + throw new Error( + 'TEST_DATABASE_URL is required for this integration lane' + ); + } + inspector = createDatabaseExecutor(integrationDatabaseUrl); + await applyMigrations({ + directory: resolve(process.cwd(), 'migrations'), + executor: inspector, + }); + }); + + beforeEach(async () => { + await cleanupIntegrationRows(); + const configuration = await inspector.execute<{ count: string }>( + `select count(*)::text as count + from growth_activity + where event_key = 'legacy:resend:cutover:v1:configuration'` + ); + if (configuration.rows[0]?.count !== '0') { + throw new Error( + 'Disposable integration database contains a non-test cutover configuration' + ); + } + }); + + afterEach(async () => { + await cleanupIntegrationRows(); + }); + + afterAll(async () => { + await inspector?.close?.(); + }); + + async function seedDatabaseCancellation(): Promise<{ + contactId: string; + deadline: Date; + jobId: string; + providerContactId: string; + providerEmailId: string; + scheduledAt: Date; + }> { + const run = randomUUID().replaceAll('-', ''); + const seededContactId = randomUUID(); + const seededJobId = randomUUID(); + const markerJobId = randomUUID(); + const seededProviderContactId = `integration_contact_${run}`; + const seededProviderEmailId = `integration_email_${run}`; + const snapshotAt = new Date('2099-01-01T00:00:00.000Z'); + const scheduledAt = new Date('2099-01-02T00:00:00.000Z'); + const deadline = new Date(scheduledAt.getTime() - 5 * 60_000); + await inspector.execute( + `insert into growth_contacts ( + id, email_normalized, email_lookup_hmac, + email_hmac_key_version, source + ) values ($1, $2, $3, 1, $4)`, + [ + seededContactId, + `${run}@example.invalid`, + `integration:${run}`, + integrationSource, + ] + ); + await inspector.execute( + `insert into growth_jobs ( + id, kind, contact_id, status, available_at, idempotency_key, + payload, provider_email_id, delivery_status + ) values ( + $1, 'legacy', $2, 'cancelled', $3, $4, $5::jsonb, + null, 'not_submitted' + )`, + [ + markerJobId, + seededContactId, + snapshotAt, + `integration:marker:${run}`, + JSON.stringify({ + imported: true, + legacy_type: 'contact_marker', + operator_integration: true, + provider: 'resend', + provider_contact_id: seededProviderContactId, + }), + ] + ); + await inspector.execute( + `insert into growth_jobs ( + id, kind, contact_id, status, available_at, idempotency_key, + payload, provider_email_id, delivery_status + ) values ( + $1, 'legacy', $2, 'pending', $3, $4, $5::jsonb, + $6, 'not_submitted' + )`, + [ + seededJobId, + seededContactId, + scheduledAt, + `integration:schedule:${run}`, + JSON.stringify({ + imported: true, + legacy_type: 'scheduled_message', + operator_integration: true, + provider: 'resend', + provider_state: 'scheduled', + }), + seededProviderEmailId, + ] + ); + await inspector.execute( + `insert into growth_activity ( + event_key, contact_id, project_id, occurred_at, kind, data + ) values ( + 'legacy:resend:cutover:v1:configuration', null, null, $1, + 'legacy.resend_cutover_configured', $2::jsonb + )`, + [ + snapshotAt, + JSON.stringify({ + snapshot_at: snapshotAt.toISOString(), + cancellation_deadline: deadline.toISOString(), + expected_contacts: 1, + expected_scheduled: 1, + snapshot_identity: snapshotIdentity( + [seededProviderContactId], + [seededProviderEmailId] + ), + operator_integration: true, + }), + ] + ); + return { + contactId: seededContactId, + deadline, + jobId: seededJobId, + providerContactId: seededProviderContactId, + providerEmailId: seededProviderEmailId, + scheduledAt, + }; + } + + function databaseHarness( + provider: ReturnType, + deadline: Date, + createExecutor: (databaseUrl: string) => SqlExecutor = (databaseUrl) => + createDatabaseExecutor(databaseUrl) + ) { + const output: string[] = []; + const errors: string[] = []; + const dependencies: LegacyCancellationDependencies = { + environment: { + RESEND_API_KEY: 'private-integration-key', + TEST_DATABASE_URL: integrationDatabaseUrl, + }, + createClient: () => provider.client, + createExecutor, + writeOutput: (line) => output.push(line), + writeError: (line) => errors.push(line), + now: () => new Date(deadline.getTime() - 60 * 60_000), + }; + return { dependencies, errors, output }; + } + + it('reconstructs real JSONB and timestamptz inventory in dry-run', async () => { + const seeded = await seedDatabaseCancellation(); + const provider = providerHarness({ + pages: [[{ id: seeded.providerEmailId, last_event: 'scheduled' }]], + }); + const harness = databaseHarness(provider, seeded.deadline); + + expect( + await mainCancelResendLifecycle(['--dry-run'], harness.dependencies) + ).toBe(0); + expect(JSON.parse(String(harness.output[0]))).toMatchObject({ + immutable_contacts: 1, + immutable_scheduled: 1, + unresolved_imported: 1, + provider_scheduled: 1, + cancellation_remaining_seconds: 3_600, + }); + const decoded = await inspector.execute<{ + available_at: Date; + payload: Record; + }>( + `select available_at, payload + from growth_jobs + where id = $1`, + [seeded.jobId] + ); + expect(decoded.rows[0]?.available_at).toBeInstanceOf(Date); + expect(decoded.rows[0]?.payload).toMatchObject({ + legacy_type: 'scheduled_message', + provider_state: 'scheduled', + }); + expect(provider.cancel).not.toHaveBeenCalled(); + }); + + it('settles one real row and records one stable activity', async () => { + const seeded = await seedDatabaseCancellation(); + const provider = providerHarness({ + pages: [[{ id: seeded.providerEmailId, last_event: 'scheduled' }]], + }); + const first = databaseHarness(provider, seeded.deadline); + + expect( + await mainCancelResendLifecycle(applyArguments(), first.dependencies) + ).toBe(0); + const settled = await inspector.execute<{ + last_error_code: string | null; + payload: Record; + status: string; + }>( + 'select status, payload, last_error_code from growth_jobs where id = $1', + [seeded.jobId] + ); + expect(settled.rows[0]).toMatchObject({ + status: 'cancelled', + payload: { provider_state: 'cancelled' }, + last_error_code: null, + }); + const activities = await inspector.execute<{ count: string }>( + `select count(*)::text as count + from growth_activity + where event_key = $1 + and kind = 'legacy.resend_schedule_cancelled'`, + [`legacy:resend:scheduled:${seeded.jobId}:cancelled`] + ); + expect(activities.rows).toEqual([{ count: '1' }]); + const replay = databaseHarness(provider, seeded.deadline); + expect( + await mainCancelResendLifecycle(applyArguments(), replay.dependencies) + ).toBe(0); + expect(provider.cancel).toHaveBeenCalledTimes(1); + }); + + it('rolls back an activity conflict and recovers by exact get without a second cancel', async () => { + const seeded = await seedDatabaseCancellation(); + const eventKey = `legacy:resend:scheduled:${seeded.jobId}:cancelled`; + await inspector.execute( + `insert into growth_activity ( + event_key, contact_id, occurred_at, kind, data + ) values ($1, $2, $3, 'integration.conflict', '{}')`, + [eventKey, seeded.contactId, new Date(seeded.deadline.getTime() - 1)] + ); + const provider = providerHarness({ + pages: [[{ id: seeded.providerEmailId, last_event: 'scheduled' }]], + getResults: { + [seeded.providerEmailId]: { + data: exactProviderEmail(seeded.providerEmailId, 'canceled'), + error: null, + }, + }, + }); + const first = databaseHarness(provider, seeded.deadline); + + expect( + await mainCancelResendLifecycle(applyArguments(), first.dependencies) + ).toBe(1); + const rolledBack = await inspector.execute<{ + payload: Record; + status: string; + }>('select status, payload from growth_jobs where id = $1', [ + seeded.jobId, + ]); + expect(rolledBack.rows[0]).toMatchObject({ + status: 'pending', + payload: { provider_state: 'scheduled' }, + }); + await inspector.execute( + 'delete from growth_activity where event_key = $1', + [eventKey] + ); + const second = databaseHarness(provider, seeded.deadline); + + expect( + await mainCancelResendLifecycle(applyArguments(), second.dependencies) + ).toBe(0); + expect(provider.get).toHaveBeenCalledWith(seeded.providerEmailId, { + signal: expect.any(AbortSignal), + }); + expect(provider.cancel).toHaveBeenCalledTimes(1); + }); + + it('fails closed on a real zero-row settlement conflict', async () => { + const seeded = await seedDatabaseCancellation(); + const provider = providerHarness({ + pages: [[{ id: seeded.providerEmailId, last_event: 'scheduled' }]], + }); + const normalCancel = provider.cancel.getMockImplementation(); + provider.cancel.mockImplementationOnce(async (...parameters) => { + await inspector.execute( + `update growth_jobs + set status = 'cancelled', + payload = payload || '{"provider_state":"cancelled","cancelled_at":"2099-01-01T00:00:00.000Z"}'::jsonb + where id = $1`, + [seeded.jobId] + ); + return normalCancel?.(...parameters) as Promise<{ + data: unknown | null; + error: unknown | null; + }>; + }); + const harness = databaseHarness(provider, seeded.deadline); + + expect( + await mainCancelResendLifecycle(applyArguments(), harness.dependencies) + ).toBe(1); + expect(harness.errors).toEqual([ + 'Resend lifecycle cancellation failed: database_cancellation_failed', + ]); + const activities = await inspector.execute<{ count: string }>( + `select count(*)::text as count + from growth_activity + where event_key = $1`, + [`legacy:resend:scheduled:${seeded.jobId}:cancelled`] + ); + expect(activities.rows).toEqual([{ count: '0' }]); + }); + + it('serializes concurrent applies with a real durable row claim', async () => { + const seeded = await seedDatabaseCancellation(); + const provider = providerHarness({ + pages: [[{ id: seeded.providerEmailId, last_event: 'scheduled' }]], + }); + const normalCancel = provider.cancel.getMockImplementation(); + let releaseCancel: (() => void) | undefined; + const cancelReleased = new Promise((resolve) => { + releaseCancel = resolve; + }); + provider.cancel.mockImplementationOnce(async (...parameters) => { + const claimed = await inspector.execute<{ + last_error_code: string | null; + lease_token: string | null; + lease_until: Date | null; + }>( + `select last_error_code, lease_token, lease_until + from growth_jobs + where id = $1`, + [seeded.jobId] + ); + expect(claimed.rows[0]).toMatchObject({ + last_error_code: 'legacy_resend_cancel_outcome_unknown', + lease_token: expect.any(String), + lease_until: expect.any(Date), + }); + await cancelReleased; + return normalCancel?.(...parameters) as Promise<{ + data: unknown | null; + error: unknown | null; + }>; + }); + const first = databaseHarness(provider, seeded.deadline); + const second = databaseHarness(provider, seeded.deadline); + + const firstResult = mainCancelResendLifecycle( + applyArguments(), + first.dependencies + ); + await vi.waitFor(() => expect(provider.cancel).toHaveBeenCalledTimes(1), { + timeout: 10_000, + }); + expect( + await mainCancelResendLifecycle(applyArguments(), second.dependencies) + ).toBe(1); + expect(second.errors).toEqual([ + 'Resend lifecycle cancellation failed: cancellation_operator_already_running', + ]); + releaseCancel?.(); + expect(await firstResult).toBe(0); + expect(provider.cancel).toHaveBeenCalledTimes(1); + }); + } +); From f5200fd4dde394372a3cae8684971cabc7738fa8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:38:41 -0700 Subject: [PATCH 06/15] feat: add durable website growth boundary --- .../api/_internal/read-bounded-body.spec.ts | 110 ++++ .../app/api/_internal/read-bounded-body.ts | 52 ++ apps/website/src/lib/growth/email-keyring.ts | 76 +++ .../src/lib/growth/form-client.spec.ts | 286 ++++++++++ apps/website/src/lib/growth/form-client.ts | 200 +++++++ .../src/lib/growth/form-policy.spec.ts | 50 ++ apps/website/src/lib/growth/form-policy.ts | 46 ++ .../website/src/lib/growth/form-route.spec.ts | 234 ++++++++ apps/website/src/lib/growth/form-route.ts | 165 ++++++ .../src/lib/growth/lifecycle-client.spec.ts | 518 ++++++++++++++++++ .../src/lib/growth/lifecycle-client.ts | 231 ++++++++ apps/website/tsconfig.json | 3 +- package-lock.json | 7 + package.json | 1 + 14 files changed, 1978 insertions(+), 1 deletion(-) create mode 100644 apps/website/src/app/api/_internal/read-bounded-body.spec.ts create mode 100644 apps/website/src/app/api/_internal/read-bounded-body.ts create mode 100644 apps/website/src/lib/growth/email-keyring.ts create mode 100644 apps/website/src/lib/growth/form-client.spec.ts create mode 100644 apps/website/src/lib/growth/form-client.ts create mode 100644 apps/website/src/lib/growth/form-policy.spec.ts create mode 100644 apps/website/src/lib/growth/form-policy.ts create mode 100644 apps/website/src/lib/growth/form-route.spec.ts create mode 100644 apps/website/src/lib/growth/form-route.ts create mode 100644 apps/website/src/lib/growth/lifecycle-client.spec.ts create mode 100644 apps/website/src/lib/growth/lifecycle-client.ts 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/lib/growth/email-keyring.ts b/apps/website/src/lib/growth/email-keyring.ts new file mode 100644 index 000000000..751948354 --- /dev/null +++ b/apps/website/src/lib/growth/email-keyring.ts @@ -0,0 +1,76 @@ +import 'server-only'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import type { + EmailHmacKey, + EmailHmacKeyring, +} from '@threadplane-internal/growth'; + +function version(value: string | undefined): number { + if (!value || !/^\d+$/u.test(value)) { + throw new Error('Growth email HMAC active version is required'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 32_767) { + throw new Error('Growth email HMAC active version is invalid'); + } + return parsed; +} + +function previousKey(candidate: unknown): EmailHmacKey { + 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 ( + Object.keys(record).length !== 2 || + typeof record['version'] !== 'number' || + !Number.isSafeInteger(record['version']) || + record['version'] < 1 || + record['version'] > 32_767 || + typeof record['secret'] !== 'string' || + Buffer.byteLength(record['secret'], 'utf8') < 32 + ) { + throw new Error('Growth email HMAC previous key is invalid'); + } + return { version: record['version'], secret: record['secret'] }; +} + +export function loadEmailHmacKeyring( + environment: Readonly> = process.env +): EmailHmacKeyring { + const secret = environment['GROWTH_EMAIL_HMAC_ACTIVE_SECRET']; + if (!secret || Buffer.byteLength(secret, 'utf8') < 32) { + throw new Error('Growth email HMAC active secret is required'); + } + const active = { + version: version(environment['GROWTH_EMAIL_HMAC_ACTIVE_VERSION']), + secret, + }; + const rawPrevious = environment['GROWTH_EMAIL_HMAC_PREVIOUS_KEYS']; + if (!rawPrevious) return { active }; + + let parsed: unknown; + try { + parsed = JSON.parse(rawPrevious) as unknown; + } catch { + throw new Error('Growth email HMAC previous keys are invalid'); + } + if (!Array.isArray(parsed)) { + throw new Error('Growth email HMAC previous keys must be an array'); + } + const previous = parsed.map(previousKey); + const versions = new Set([active.version]); + for (const key of previous) { + if (versions.has(key.version)) { + throw new Error('Growth email HMAC key versions must be unique'); + } + versions.add(key.version); + } + return { active, previous }; +} diff --git a/apps/website/src/lib/growth/form-client.spec.ts b/apps/website/src/lib/growth/form-client.spec.ts new file mode 100644 index 000000000..002b11749 --- /dev/null +++ b/apps/website/src/lib/growth/form-client.spec.ts @@ -0,0 +1,286 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getAcquisitionSessionId, + growthFormRequestSnapshot, + type GrowthFormFacts, +} from './form-client'; + +const SESSION_KEY = 'threadplane_acquisition_session_v1'; + +describe('growth form request snapshots', () => { + beforeEach(() => { + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it('reuses only a valid unexpired acquisition session UUID', () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + id: '30000000-0000-4000-8000-000000000003', + expiresAt: 2_000, + }) + ); + expect(getAcquisitionSessionId(1_000)).toBe( + '30000000-0000-4000-8000-000000000003' + ); + + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ id: 'not-a-uuid', expiresAt: 2_000 }) + ); + expect(getAcquisitionSessionId(1_000)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u + ); + + const expired = JSON.parse(String(sessionStorage.getItem(SESSION_KEY))) as { + id: string; + }; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ id: expired.id, expiresAt: 999 }) + ); + expect(getAcquisitionSessionId(1_000)).not.toBe(expired.id); + }); + + it('replaces an acquisition session whose expiry exceeds the fixed TTL', () => { + const now = 10_000; + const corruptId = '30000000-0000-4000-8000-000000000003'; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ id: corruptId, expiresAt: now + 1_800_001 }) + ); + + const replacement = getAcquisitionSessionId(now); + const stored = JSON.parse(String(sessionStorage.getItem(SESSION_KEY))) as { + expiresAt: number; + id: string; + }; + + expect(replacement).not.toBe(corruptId); + expect(stored).toEqual({ + expiresAt: now + 1_800_000, + id: replacement, + }); + + const boundaryId = '40000000-0000-4000-8000-000000000004'; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ id: boundaryId, expiresAt: now + 1_800_000 }) + ); + expect(getAcquisitionSessionId(now)).toBe(boundaryId); + }); + + it('retains the full uncertain-retry identity and facts despite session storage changes', () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + id: '30000000-0000-4000-8000-000000000003', + expiresAt: Date.now() + 60_000, + }) + ); + const first = growthFormRequestSnapshot(null, { + email: 'reader@example.com', + paper: 'chat', + }); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + id: '40000000-0000-4000-8000-000000000004', + expiresAt: Date.now() + 60_000, + }) + ); + + const replay = growthFormRequestSnapshot(first, { + email: 'reader@example.com', + paper: 'chat', + }); + + expect(replay).toBe(first); + expect(replay.acquisition_session_id).toBe( + '30000000-0000-4000-8000-000000000003' + ); + expect(replay.facts).toEqual({ + email: 'reader@example.com', + paper: 'chat', + }); + }); + + it('starts a new immutable snapshot when submitted facts change', () => { + const mutableFacts = { email: 'first@example.com', message: 'First' }; + const first = growthFormRequestSnapshot(null, mutableFacts); + mutableFacts.message = 'mutated after capture'; + + expect(first.facts).toEqual({ + email: 'first@example.com', + message: 'First', + }); + + const edited = growthFormRequestSnapshot(first, { + email: 'first@example.com', + message: 'Edited', + }); + expect(edited.submission_id).not.toBe(first.submission_id); + expect(edited.facts.message).toBe('Edited'); + }); + + it('deeply snapshots nested facts and ignores object key order on retry', () => { + const mutableFacts = { + email: 'reader@example.com', + metadata: { interests: ['angular', 'agents'] }, + }; + const first = growthFormRequestSnapshot(null, mutableFacts); + mutableFacts.metadata.interests[0] = 'mutated'; + + expect(first.facts).toEqual({ + email: 'reader@example.com', + metadata: { interests: ['angular', 'agents'] }, + }); + expect(Object.isFrozen(first.facts)).toBe(true); + expect(Object.isFrozen(first.facts.metadata)).toBe(true); + expect(Object.isFrozen(first.facts.metadata.interests)).toBe(true); + + const retry = growthFormRequestSnapshot(first, { + metadata: { interests: ['angular', 'agents'] }, + email: 'reader@example.com', + }); + expect(retry).toBe(first); + }); + + it('uses code-unit ordering for distinct canonically equivalent Unicode keys', () => { + const composed = '\u00e9'; + const decomposed = 'e\u0301'; + const firstFacts: Record = {}; + firstFacts[composed] = 'composed'; + firstFacts[decomposed] = 'decomposed'; + const reverseFacts: Record = {}; + reverseFacts[decomposed] = 'decomposed'; + reverseFacts[composed] = 'composed'; + + const first = growthFormRequestSnapshot(null, firstFacts); + const retry = growthFormRequestSnapshot(first, reverseFacts); + + expect(composed).not.toBe(decomposed); + expect(retry).toBe(first); + expect(retry.fingerprint).toBe(first.fingerprint); + }); + + it.each([ + ['numeric-looking key beyond array-index range', '4294967295'], + ['negative key', '-1'], + ['padded key', '01'], + ['symbol key', Symbol('extra')], + ] as const)( + 'rejects an enumerable array property outside dense indices: %s', + (_name, extraKey) => { + const entries = ['value']; + const first = growthFormRequestSnapshot(null, { entries }); + Object.defineProperty(entries, extraKey, { + configurable: true, + enumerable: true, + value: 'hidden', + writable: true, + }); + + expect(() => + growthFormRequestSnapshot(first, { entries } as never) + ).toThrow('Growth form facts must be JSON-safe'); + } + ); + + it('accepts and deeply freezes the recursive JSON facts contract', () => { + const facts = { + active: true, + count: 2, + email: 'reader@example.com', + metadata: { + empty: null, + interests: ['angular', { agents: true }], + }, + } satisfies GrowthFormFacts; + + const snapshot = growthFormRequestSnapshot(null, facts); + + expect(snapshot.facts).toEqual(facts); + expect(Object.isFrozen(snapshot.facts)).toBe(true); + expect(Object.isFrozen(snapshot.facts.metadata)).toBe(true); + expect(Object.isFrozen(snapshot.facts.metadata.interests)).toBe(true); + expect(Object.isFrozen(snapshot.facts.metadata.interests[1])).toBe(true); + }); + + it.each([ + [ + 'cycle', + () => { + const value: Record = {}; + value['self'] = value; + return value; + }, + ], + ['bigint', () => ({ value: 1n })], + ['undefined', () => ({ value: undefined })], + ['function', () => ({ value: () => undefined })], + ['symbol', () => ({ value: Symbol('value') })], + ['undefined array entry', () => ({ value: [undefined] })], + ['function array entry', () => ({ value: [() => undefined] })], + ['symbol array entry', () => ({ value: [Symbol('value')] })], + ['symbol key', () => ({ [Symbol('key')]: 'value' })], + ['NaN', () => ({ value: Number.NaN })], + ['positive infinity', () => ({ value: Number.POSITIVE_INFINITY })], + ['negative infinity', () => ({ value: Number.NEGATIVE_INFINITY })], + ['negative zero', () => ({ value: -0 })], + ['Date', () => ({ value: new Date(0) })], + ['Map', () => ({ value: new Map([['key', 'value']]) })], + ['Set', () => ({ value: new Set(['value']) })], + [ + 'class instance', + () => ({ + value: new (class FormFact { + readonly value = 'value'; + })(), + }), + ], + ['null-prototype object', () => ({ value: Object.create(null) })], + [ + 'non-enumerable property', + () => { + const value = {}; + Object.defineProperty(value, 'hidden', { value: 'hidden' }); + return { value }; + }, + ], + [ + 'sparse array', + () => { + const value = new Array(2) as unknown[]; + value[1] = 'value'; + return { value }; + }, + ], + ] as const)( + 'rejects non-JSON facts without coercion: %s', + (_name, create) => { + expect(() => growthFormRequestSnapshot(null, create() as never)).toThrow( + 'Growth form facts must be JSON-safe' + ); + } + ); + + it('uses one closed snapshot error without echoing fact keys or values', () => { + let message = ''; + try { + growthFormRequestSnapshot(null, { + private_field_name: undefined, + visible: 'private-field-value', + } as never); + } catch (error) { + message = String(error); + } + + expect(message).toBe('Error: Growth form facts must be JSON-safe'); + expect(message).not.toContain('private_field_name'); + expect(message).not.toContain('private-field-value'); + }); +}); diff --git a/apps/website/src/lib/growth/form-client.ts b/apps/website/src/lib/growth/form-client.ts new file mode 100644 index 000000000..3813c3b19 --- /dev/null +++ b/apps/website/src/lib/growth/form-client.ts @@ -0,0 +1,200 @@ +const ACQUISITION_SESSION_KEY = 'threadplane_acquisition_session_v1'; +const ACQUISITION_SESSION_TTL_MS = 30 * 60 * 1_000; +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; + +export const FORM_POLICY_REFRESH_MESSAGE = + 'This form changed. Refresh the page before submitting again.'; + +interface StoredSession { + id: string; + expiresAt: number; +} + +function newUuid(): string { + return globalThis.crypto.randomUUID(); +} + +export function getAcquisitionSessionId(now = Date.now()): string { + try { + const raw = sessionStorage.getItem(ACQUISITION_SESSION_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StoredSession; + if ( + typeof parsed.id === 'string' && + UUID_V4.test(parsed.id) && + typeof parsed.expiresAt === 'number' && + Number.isFinite(parsed.expiresAt) && + parsed.expiresAt > now && + parsed.expiresAt <= now + ACQUISITION_SESSION_TTL_MS + ) { + return parsed.id.toLowerCase(); + } + } + } catch { + // Storage availability must not block form submission. + } + + const id = newUuid(); + if (!UUID_V4.test(id)) { + throw new Error('A secure UUID generator is required'); + } + try { + sessionStorage.setItem( + ACQUISITION_SESSION_KEY, + JSON.stringify({ id, expiresAt: now + ACQUISITION_SESSION_TTL_MS }) + ); + } catch { + // The request can still carry the in-memory acquisition identity. + } + return id; +} + +export type GrowthFormJsonPrimitive = boolean | number | string | null; +export type GrowthFormJsonValue = + | GrowthFormJsonPrimitive + | readonly GrowthFormJsonValue[] + | GrowthFormFacts; +export interface GrowthFormFacts { + readonly [key: string]: GrowthFormJsonValue; +} + +type DeepReadonlyJson = + Value extends GrowthFormJsonPrimitive + ? Value + : Value extends readonly (infer Entry extends GrowthFormJsonValue)[] + ? readonly DeepReadonlyJson[] + : Value extends GrowthFormFacts + ? { readonly [Key in keyof Value]: DeepReadonlyJson } + : never; + +export interface GrowthFormRequestSnapshot< + Facts extends GrowthFormFacts = GrowthFormFacts +> { + acquisition_session_id: string; + submission_id: string; + facts: DeepReadonlyJson; + fingerprint: string; +} + +const INVALID_FORM_FACTS = 'Growth form facts must be JSON-safe'; + +function invalidFormFacts(): never { + throw new Error(INVALID_FORM_FACTS); +} + +function isJsonArray( + value: GrowthFormJsonValue +): value is readonly GrowthFormJsonValue[] { + return Array.isArray(value); +} + +function copyJsonValue( + value: unknown, + ancestors: Set +): GrowthFormJsonValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || Object.is(value, -0)) invalidFormFacts(); + return value; + } + if (typeof value !== 'object') invalidFormFacts(); + if (ancestors.has(value)) invalidFormFacts(); + + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) invalidFormFacts(); + const indexKeys = Reflect.ownKeys(value).filter( + (key) => key !== 'length' + ); + if ( + indexKeys.length !== value.length || + indexKeys.some( + (key, index) => typeof key !== 'string' || key !== String(index) + ) + ) { + invalidFormFacts(); + } + const copy: GrowthFormJsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index) + ); + if (!descriptor?.enumerable || !('value' in descriptor)) { + invalidFormFacts(); + } + copy.push(copyJsonValue(descriptor.value, ancestors)); + } + return copy; + } + + if (Object.getPrototypeOf(value) !== Object.prototype) invalidFormFacts(); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) invalidFormFacts(); + + const copy: Record = {}; + for (const key of (keys as string[]).sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + )) { + const descriptor = descriptors[key]; + if (!descriptor?.enumerable || !('value' in descriptor)) { + invalidFormFacts(); + } + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + value: copyJsonValue(descriptor.value, ancestors), + writable: true, + }); + } + return copy; + } finally { + ancestors.delete(value); + } +} + +function copyGrowthFormFacts(value: unknown): GrowthFormFacts { + try { + const copy = copyJsonValue(value, new Set()); + if (copy === null || typeof copy !== 'object' || isJsonArray(copy)) { + invalidFormFacts(); + } + return copy; + } catch { + throw new Error(INVALID_FORM_FACTS); + } +} + +function deepFreeze(value: Value): Value { + if (value !== null && typeof value === 'object') { + Object.freeze(value); + for (const entry of Object.values(value as Record)) { + deepFreeze(entry); + } + } + return value; +} + +export function growthFormRequestSnapshot( + current: GrowthFormRequestSnapshot | null, + facts: Facts +): GrowthFormRequestSnapshot { + const capturedFacts = copyGrowthFormFacts(facts) as Facts; + const fingerprint = JSON.stringify(capturedFacts); + if (current?.fingerprint === fingerprint) return current; + return Object.freeze({ + acquisition_session_id: getAcquisitionSessionId(), + submission_id: newUuid(), + facts: deepFreeze(capturedFacts) as DeepReadonlyJson, + fingerprint, + }); +} diff --git a/apps/website/src/lib/growth/form-policy.spec.ts b/apps/website/src/lib/growth/form-policy.spec.ts new file mode 100644 index 000000000..25e8a4e4c --- /dev/null +++ b/apps/website/src/lib/growth/form-policy.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { + CONTACT_OUTREACH_DISCLOSURE, + GROWTH_FORM_POLICY_VERSION, + NEWSLETTER_OUTREACH_DISCLOSURE, + WHITEPAPER_OUTREACH_DISCLOSURE, + getFormPolicy, + matchesSubmittedFormPolicy, +} from './form-policy'; + +describe('server form policy', () => { + it('fails closed unless the growth policy is explicitly configured', () => { + expect(() => getFormPolicy({})).toThrow(/GROWTH_FORM_POLICY/u); + expect(() => getFormPolicy({ GROWTH_FORM_POLICY: 'legacy' })).toThrow(); + expect(() => getFormPolicy({ GROWTH_FORM_POLICY: 'unknown' })).toThrow(); + }); + + it('selects route behavior and exact client disclosure from one server-only switch', () => { + expect(getFormPolicy({ GROWTH_FORM_POLICY: 'growth_v1' })).toEqual({ + mode: 'growth_v1', + version: GROWTH_FORM_POLICY_VERSION, + disclosures: expect.objectContaining({ + whitepaper: expect.any(String), + newsletter: expect.any(String), + contact: expect.any(String), + }), + }); + expect(WHITEPAPER_OUTREACH_DISCLOSURE).toBe( + 'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.' + ); + expect(CONTACT_OUTREACH_DISCLOSURE).toBe( + 'By sending, you agree Brian may follow up by email about your request.' + ); + expect(NEWSLETTER_OUTREACH_DISCLOSURE).toBe( + 'Subscribe to Threadplane updates and a short, three-email welcome from Brian. Unsubscribe anytime.' + ); + }); + + it('rejects missing or stale submitted versions in growth mode', () => { + const policy = getFormPolicy({ GROWTH_FORM_POLICY: 'growth_v1' }); + expect(matchesSubmittedFormPolicy(policy, undefined)).toBe(false); + expect(matchesSubmittedFormPolicy(policy, 'growth_v1.stale')).toBe(false); + expect(matchesSubmittedFormPolicy(policy, GROWTH_FORM_POLICY_VERSION)).toBe( + true + ); + }); +}); diff --git a/apps/website/src/lib/growth/form-policy.ts b/apps/website/src/lib/growth/form-policy.ts new file mode 100644 index 000000000..9233ab6f0 --- /dev/null +++ b/apps/website/src/lib/growth/form-policy.ts @@ -0,0 +1,46 @@ +import 'server-only'; + +export const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-01'; + +export const WHITEPAPER_OUTREACH_DISCLOSURE = + 'Send me the guide and a short, three-email follow-up from Brian about building with Threadplane. Unsubscribe anytime.'; +export const CONTACT_OUTREACH_DISCLOSURE = + 'By sending, you agree Brian may follow up by email about your request.'; +export const NEWSLETTER_OUTREACH_DISCLOSURE = + 'Subscribe to Threadplane updates and a short, three-email welcome from Brian. Unsubscribe anytime.'; + +export interface PublicFormPolicy { + mode: 'growth_v1'; + version: typeof GROWTH_FORM_POLICY_VERSION; + disclosures: { + contact: string; + newsletter: string; + whitepaper: string; + }; +} + +const GROWTH_V1_POLICY: PublicFormPolicy = Object.freeze({ + mode: 'growth_v1', + version: GROWTH_FORM_POLICY_VERSION, + disclosures: Object.freeze({ + contact: CONTACT_OUTREACH_DISCLOSURE, + newsletter: NEWSLETTER_OUTREACH_DISCLOSURE, + whitepaper: WHITEPAPER_OUTREACH_DISCLOSURE, + }), +}); + +export function getFormPolicy( + environment: Readonly> = process.env +): PublicFormPolicy { + if (environment['GROWTH_FORM_POLICY']?.trim() !== 'growth_v1') { + throw new Error('GROWTH_FORM_POLICY must be growth_v1'); + } + return GROWTH_V1_POLICY; +} + +export function matchesSubmittedFormPolicy( + policy: PublicFormPolicy, + submittedVersion: string | undefined +): boolean { + return submittedVersion === policy.version; +} diff --git a/apps/website/src/lib/growth/form-route.spec.ts b/apps/website/src/lib/growth/form-route.spec.ts new file mode 100644 index 000000000..675b5dc93 --- /dev/null +++ b/apps/website/src/lib/growth/form-route.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { loadEmailHmacKeyring } from './email-keyring'; +import { getFormPolicy, GROWTH_FORM_POLICY_VERSION } from './form-policy'; +import * as formRoute from './form-route'; +import { + defaultGrowthFormRouteDependencies, + nudgeLifecycle, + readBoundedJsonObject, + stalePolicyResponse, +} from './form-route'; + +describe('growth form route boundary', () => { + it('does not expose the legacy silent-truncation text helper', () => { + expect('text' in formRoute).toBe(false); + }); + + it('wires the server policy into the default route dependencies', () => { + expect(defaultGrowthFormRouteDependencies().getPolicy).toBe(getFormPolicy); + }); + + it.each([ + ['', 'application/json'], + ['{', 'application/json'], + ['null', 'application/json'], + ['[]', 'application/json'], + ['{}', 'text/plain'], + ])('rejects malformed or non-object JSON: %s', async (body, contentType) => { + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + headers: { 'content-type': contentType }, + body, + }); + + await expect(readBoundedJsonObject(request, 32)).resolves.toBeNull(); + }); + + it('cancels an unread body when content type is missing or invalid', async () => { + let cancelled = false; + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + body: new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + duplex: 'half', + } as RequestInit); + + await expect(readBoundedJsonObject(request, 32)).resolves.toBeNull(); + expect(cancelled).toBe(true); + expect(request.body?.locked).toBe(false); + }); + + it('returns a closed null result when invalid-content-type cancellation fails', async () => { + let cancellationAttempts = 0; + const request = new Request('https://threadplane.ai/api/contact', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: new ReadableStream({ + cancel() { + cancellationAttempts += 1; + throw new Error('private cancellation detail'); + }, + }), + duplex: 'half', + } as RequestInit); + + await expect(readBoundedJsonObject(request, 32)).resolves.toBeNull(); + expect(cancellationAttempts).toBe(1); + expect(request.body?.locked).toBe(false); + }); + + it('returns only the current growth policy version for stale submissions', async () => { + const response = stalePolicyResponse({ + mode: 'growth_v1', + version: GROWTH_FORM_POLICY_VERSION, + disclosures: { + contact: 'contact disclosure', + newsletter: 'newsletter disclosure', + whitepaper: 'whitepaper disclosure', + }, + }); + + expect(response.status).toBe(409); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('retry-after')).toBe('0'); + await expect(response.json()).resolves.toEqual({ + error: 'This form changed. Please retry.', + policy_version: GROWTH_FORM_POLICY_VERSION, + retryable: true, + }); + }); + + it('loads a closed keyring shape without exposing invalid secret material', () => { + expect( + loadEmailHmacKeyring({ + GROWTH_EMAIL_HMAC_ACTIVE_SECRET: 'a'.repeat(32), + GROWTH_EMAIL_HMAC_ACTIVE_VERSION: '2', + GROWTH_EMAIL_HMAC_PREVIOUS_KEYS: JSON.stringify([ + { version: 1, secret: 'b'.repeat(32) }, + ]), + }) + ).toEqual({ + active: { version: 2, secret: 'a'.repeat(32) }, + previous: [{ version: 1, secret: 'b'.repeat(32) }], + }); + + const sensitiveMalformedValue = '{"secret":"do-not-expose"'; + expect(() => + loadEmailHmacKeyring({ + GROWTH_EMAIL_HMAC_ACTIVE_SECRET: 'a'.repeat(32), + GROWTH_EMAIL_HMAC_ACTIVE_VERSION: '2', + GROWTH_EMAIL_HMAC_PREVIOUS_KEYS: sensitiveMalformedValue, + }) + ).toThrow('Growth email HMAC previous keys are invalid'); + try { + loadEmailHmacKeyring({ + GROWTH_EMAIL_HMAC_ACTIVE_SECRET: 'a'.repeat(32), + GROWTH_EMAIL_HMAC_ACTIVE_VERSION: '2', + GROWTH_EMAIL_HMAC_PREVIOUS_KEYS: sensitiveMalformedValue, + }); + } catch (error) { + expect(String(error)).not.toContain('do-not-expose'); + } + }); +}); + +describe('nudgeLifecycle', () => { + it('uses the same Dawn origin and service secret as scheduled dispatch', async () => { + const invoke = vi.fn().mockResolvedValue({ + operatorAlerts: [], + threadId: '00000000-0000-4000-8000-000000000001', + }); + + await nudgeLifecycle( + { submissionId: '00000000-0000-4000-8000-000000000002' }, + { + environment: { + LIFECYCLE_DAWN_URL: 'https://lifecycle.example', + LIFECYCLE_SERVICE_SECRET: 'service-secret', + LIFECYCLE_NUDGE_URL: 'https://legacy.example', + LIFECYCLE_NUDGE_SECRET: 'legacy-secret', + }, + invoke, + } + ); + + expect(invoke).toHaveBeenCalledWith({ + baseUrl: 'https://lifecycle.example', + serviceSecret: 'service-secret', + submissionId: '00000000-0000-4000-8000-000000000002', + timeoutMs: 2_000, + trigger: 'nudge', + }); + }); + + it('sends only the committed submission identity to the lifecycle service', async () => { + const invoke = vi.fn().mockResolvedValue({ + operatorAlerts: [], + threadId: '00000000-0000-4000-8000-000000000001', + }); + + await nudgeLifecycle( + { + submissionId: '00000000-0000-4000-8000-000000000002', + email: 'private@example.com', + name: 'Private Name', + message: 'Private message', + } as { submissionId: string }, + { + environment: { + LIFECYCLE_DAWN_URL: 'https://lifecycle.example', + LIFECYCLE_SERVICE_SECRET: 'service-secret', + }, + invoke, + } + ); + + const serializedCall = JSON.stringify(invoke.mock.calls); + expect(serializedCall).toContain('00000000-0000-4000-8000-000000000002'); + expect(serializedCall).not.toContain('private@example.com'); + expect(serializedCall).not.toContain('Private Name'); + expect(serializedCall).not.toContain('Private message'); + }); + + it('preserves a configured nonblank lifecycle secret as opaque bytes', async () => { + const opaqueSecret = ' synthetic-secret-with-padding '; + let receivedExactSecret = false; + const invoke = vi.fn().mockImplementation(async (input) => { + receivedExactSecret = input.serviceSecret === opaqueSecret; + return { + operatorAlerts: [], + threadId: '00000000-0000-4000-8000-000000000001', + }; + }); + + await nudgeLifecycle( + { submissionId: '00000000-0000-4000-8000-000000000002' }, + { + environment: { + LIFECYCLE_DAWN_URL: 'https://lifecycle.example', + LIFECYCLE_SERVICE_SECRET: opaqueSecret, + }, + invoke, + } + ); + + expect(receivedExactSecret).toBe(true); + }); + + it('does nothing until both shared lifecycle settings are configured', async () => { + const invoke = vi.fn(); + + await nudgeLifecycle( + { submissionId: '00000000-0000-4000-8000-000000000002' }, + { environment: {}, invoke } + ); + await nudgeLifecycle( + { submissionId: '00000000-0000-4000-8000-000000000002' }, + { + environment: { + LIFECYCLE_DAWN_URL: 'https://lifecycle.example', + LIFECYCLE_SERVICE_SECRET: ' ', + }, + invoke, + } + ); + + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/website/src/lib/growth/form-route.ts b/apps/website/src/lib/growth/form-route.ts new file mode 100644 index 000000000..402b968e6 --- /dev/null +++ b/apps/website/src/lib/growth/form-route.ts @@ -0,0 +1,165 @@ +import 'server-only'; + +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + acceptFormSubmission, + createDatabaseExecutor, + type AcceptFormSubmissionInput, + type AcceptFormSubmissionResult, + type EmailHmacKeyring, + type SqlExecutor, +} from '@threadplane-internal/growth'; + +import { readBoundedBody } from '../../app/api/_internal/read-bounded-body'; +import { loadEmailHmacKeyring } from './email-keyring'; +import { getFormPolicy, type PublicFormPolicy } from './form-policy'; +import { invokeLifecycle } from './lifecycle-client'; + +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; + +export interface GrowthFormRouteDependencies { + getPolicy: () => PublicFormPolicy; + accept: ( + executor: SqlExecutor, + input: AcceptFormSubmissionInput + ) => Promise; + createDatabase: () => SqlExecutor; + loadKeyring: () => EmailHmacKeyring; + now: () => Date; + nudge: (input: { submissionId: string }) => Promise; +} + +export function defaultGrowthFormRouteDependencies(): GrowthFormRouteDependencies { + return { + getPolicy: getFormPolicy, + accept: acceptFormSubmission, + createDatabase: () => createDatabaseExecutor(), + loadKeyring: loadEmailHmacKeyring, + now: () => new Date(), + nudge: nudgeLifecycle, + }; +} + +export async function readBoundedJsonObject( + request: Request, + maximumBytes: number +): Promise | null> { + if ( + request.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase() !== 'application/json' + ) { + if (request.body !== null && !request.body.locked) { + await request.body.cancel().catch(() => undefined); + } + return null; + } + const rawBody = await readBoundedBody(request, maximumBytes); + if (rawBody === null) return null; + try { + const parsed = JSON.parse(rawBody) as unknown; + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +export function jsonResponse( + body: Record, + status = 200, + headers?: HeadersInit +): Response { + return Response.json(body, { + status, + headers: { + 'Cache-Control': 'no-store', + ...headers, + }, + }); +} + +export function stalePolicyResponse(policy: PublicFormPolicy): Response { + return jsonResponse( + { + error: 'This form changed. Please retry.', + policy_version: policy.version, + retryable: true, + }, + 409, + { 'Retry-After': '0' } + ); +} + +export function strictText( + body: Record, + key: string, + maximumLength: number +): string { + const value = body[key]; + if (value === undefined) return ''; + if (typeof value !== 'string') { + throw new Error(`${key} must be text`); + } + const normalized = value.trim(); + if (normalized.length > maximumLength) { + throw new Error(`${key} is too long`); + } + return normalized; +} + +export function strictOptionalEnum( + body: Record, + key: string, + values: readonly T[] +): T | undefined { + if (body[key] === undefined || body[key] === '') return undefined; + const value = strictText(body, key, 100); + if (!values.includes(value as T)) { + throw new Error(`${key} is invalid`); + } + return value as T; +} + +export function validGrowthFormIdentities( + submissionId: string, + acquisitionSessionId: string +): boolean { + return ( + UUID_V4.test(submissionId) && + (acquisitionSessionId.length === 0 || UUID_V4.test(acquisitionSessionId)) + ); +} + +export interface LifecycleNudgeDependencies { + environment: Readonly>; + invoke: typeof invokeLifecycle; +} + +const defaultLifecycleNudgeDependencies: LifecycleNudgeDependencies = { + environment: process.env, + invoke: invokeLifecycle, +}; + +export async function nudgeLifecycle( + input: { submissionId: string }, + dependencies: LifecycleNudgeDependencies = defaultLifecycleNudgeDependencies +): Promise { + const endpoint = dependencies.environment['LIFECYCLE_DAWN_URL']?.trim(); + const secret = dependencies.environment['LIFECYCLE_SERVICE_SECRET']; + if (!endpoint || !secret || secret.trim().length === 0) return; + await dependencies.invoke({ + baseUrl: endpoint, + serviceSecret: secret, + submissionId: input.submissionId, + timeoutMs: 2_000, + trigger: 'nudge', + }); +} diff --git a/apps/website/src/lib/growth/lifecycle-client.spec.ts b/apps/website/src/lib/growth/lifecycle-client.spec.ts new file mode 100644 index 000000000..2ab97442e --- /dev/null +++ b/apps/website/src/lib/growth/lifecycle-client.spec.ts @@ -0,0 +1,518 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { invokeLifecycle } from './lifecycle-client'; + +const VALID_CRON_STATE = JSON.stringify({ + trigger: 'cron', + result: { + dispatched: 0, + leased: 0, + operatorAlerts: [], + recoveryPaused: false, + }, +}); +const REDIRECT_CASES = ([301, 302, 307, 308] as const).flatMap((status) => [ + { location: 'https://lifecycle.example/redirected', status }, + { location: 'https://other.example/redirected', status }, +]); + +function streamedResponse( + chunks: readonly Uint8Array[], + options: { + headers?: HeadersInit; + status?: number; + onCancel?: () => void; + failAfterChunks?: Error; + } = {} +): Response { + const queued = [...chunks]; + return new Response( + new ReadableStream({ + pull(controller) { + const chunk = queued.shift(); + if (chunk) { + controller.enqueue(chunk); + return; + } + if (options.failAfterChunks) { + controller.error(options.failAfterChunks); + return; + } + controller.close(); + }, + cancel() { + options.onCancel?.(); + }, + }), + { headers: options.headers, status: options.status } + ); +} + +describe('invokeLifecycle', () => { + it('uses a unique UUID thread, exact workflow route, and service bearer token', async () => { + const fetch = vi.fn().mockImplementation(async (_url, init) => { + const request = JSON.parse(String(init?.body)) as { + input: Record; + }; + return new Response( + JSON.stringify({ + ...request.input, + result: { + dispatched: 1, + leased: 1, + operatorAlerts: [], + recoveryPaused: false, + }, + }) + ); + }); + + const first = await invokeLifecycle( + { + baseUrl: 'https://lifecycle.example/', + serviceSecret: 'service-secret', + trigger: 'cron', + }, + { fetch } + ); + const second = await invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'service-secret', + submissionId: '00000000-0000-4000-8000-000000000001', + trigger: 'nudge', + }, + { fetch } + ); + + expect(first.threadId).not.toBe(second.threadId); + expect(first.threadId).toMatch(/^[0-9a-f-]{36}$/u); + const [url, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + `https://lifecycle.example/threads/${first.threadId}/runs/wait` + ); + expect(init.headers).toEqual({ + authorization: 'Bearer service-secret', + 'content-type': 'application/json', + }); + expect(init.redirect).toBe('error'); + expect(JSON.parse(String(init.body))).toEqual({ + route: '/dispatch#workflow', + input: { trigger: 'cron' }, + }); + expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ + route: '/dispatch#workflow', + input: { + submission_id: '00000000-0000-4000-8000-000000000001', + trigger: 'nudge', + }, + }); + expect(first.operatorAlerts).toEqual([]); + }); + + it.each(REDIRECT_CASES)( + 'refuses $status authenticated redirects to $location', + async ({ location, status }) => { + const fetch = vi.fn().mockImplementation(async (_url, init) => { + if (init?.redirect === 'error') { + throw new TypeError('redirect refused'); + } + return new Response(VALID_CRON_STATE); + }); + + let message = ''; + try { + await invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'synthetic-secret', + trigger: 'cron', + }, + { fetch } + ); + } catch (error) { + message = String(error); + } + + expect(status).toBeGreaterThanOrEqual(301); + expect(location).toMatch(/^https:\/\//u); + expect(message).toBe('Error: Lifecycle dispatch request failed'); + expect(message).not.toContain('synthetic-secret'); + expect(message).not.toContain(location); + } + ); + + it.each(REDIRECT_CASES)( + 'rejects an surfaced $status redirect response to $location', + async ({ location, status }) => { + const response = new Response(null, { + headers: { location }, + status, + }); + let message = ''; + try { + await invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'synthetic-secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ); + } catch (error) { + message = String(error); + } + + expect(message).toBe('Error: Lifecycle dispatch was not accepted'); + expect(message).not.toContain('synthetic-secret'); + expect(message).not.toContain(location); + } + ); + + it('returns only the closed mailbox recovery alert from Dawn state', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + trigger: 'cron', + result: { + dispatched: 0, + leased: 0, + operatorAlerts: ['mailbox_recovery_required'], + recoveryPaused: true, + }, + }) + ) + ); + + const result = await invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'service-secret', + trigger: 'cron', + }, + { fetch } + ); + + expect(result.operatorAlerts).toEqual(['mailbox_recovery_required']); + }); + + it.each(['65537', '-1', 'not-a-number'])( + 'rejects and cancels an invalid or oversized declared response length: %s', + async (contentLength) => { + let cancelled = false; + const response = streamedResponse( + [new TextEncoder().encode(VALID_CRON_STATE)], + { + headers: { 'content-length': contentLength }, + onCancel: () => { + cancelled = true; + }, + } + ); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + expect(cancelled).toBe(true); + expect(response.body?.locked).toBe(false); + } + ); + + it('rejects an understated chunked response once actual bytes exceed the cap', async () => { + let cancelled = false; + const oversizedValidJson = `${' '.repeat(65_537)}${VALID_CRON_STATE}`; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode(oversizedValidJson.slice(0, 40_000)) + ); + controller.enqueue( + new TextEncoder().encode(oversizedValidJson.slice(40_000)) + ); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'content-length': '1' } } + ); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + expect(cancelled).toBe(true); + expect(response.body?.locked).toBe(false); + }); + + it('rejects malformed UTF-8 with a closed error and releases the reader', async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0xc3, 0x28])); + }, + cancel() { + cancelled = true; + }, + }) + ); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + expect(cancelled).toBe(true); + expect(response.body?.locked).toBe(false); + }); + + it('rejects malformed JSON with a closed error and leaves the stream terminal', async () => { + const response = streamedResponse([new TextEncoder().encode('{')]); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + expect(response.bodyUsed).toBe(true); + expect(response.body?.locked).toBe(false); + }); + + it('closes stream read failures without leaking details and releases the reader', async () => { + const response = streamedResponse([], { + failAfterChunks: new Error('private stream detail'), + }); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + expect(response.body?.locked).toBe(false); + }); + + it('keeps the request timeout active while streaming the response body', async () => { + let response: Response | undefined; + const fetch = vi + .fn() + .mockImplementation(async (_url, init?: RequestInit) => { + response = new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener('abort', () => { + controller.error(new Error('private abort detail')); + }); + }, + }) + ); + return response; + }); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + timeoutMs: 250, + trigger: 'cron', + }, + { fetch } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + expect(response?.body?.locked).toBe(false); + }); + + it('cancels an unread non-success response before returning a closed error', async () => { + let cancelled = false; + const response = streamedResponse( + [new TextEncoder().encode('private body')], + { + onCancel: () => { + cancelled = true; + }, + status: 503, + } + ); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(response) } + ) + ).rejects.toThrow('Lifecycle dispatch was not accepted'); + expect(cancelled).toBe(true); + expect(response.body?.locked).toBe(false); + }); + + it.each([ + '{}', + '{"trigger":"cron","result":{"operatorAlerts":["unknown"]}}', + '{"trigger":"cron","result":{"operatorAlerts":[],"recoveryPaused":false,"leased":0,"dispatched":0},"extra":"unsafe"}', + ])('fails closed on unsupported Dawn state: %s', async (body) => { + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'service-secret', + trigger: 'cron', + }, + { fetch: vi.fn().mockResolvedValue(new Response(body)) } + ) + ).rejects.toThrow('Lifecycle dispatch returned invalid state'); + }); + + it('uses a bounded timeout and rejects non-success without exposing the secret', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response('provider detail', { status: 503 })); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'do-not-log-this', + timeoutMs: 1_000, + trigger: 'cron', + }, + { fetch } + ) + ).rejects.toThrow('Lifecycle dispatch was not accepted'); + expect(fetch.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); + expect(JSON.stringify(consoleError.mock.calls)).not.toContain( + 'do-not-log-this' + ); + consoleError.mockRestore(); + }); + + it('aborts a stalled lifecycle request at the configured timeout', async () => { + const fetch = vi.fn().mockImplementation( + async (_url: string, init?: RequestInit): Promise => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + }) + ); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'do-not-log-this', + timeoutMs: 250, + trigger: 'cron', + }, + { fetch } + ) + ).rejects.toThrow('Lifecycle dispatch request failed'); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('rejects an unsafe generated thread ID before making a request', async () => { + const fetch = vi.fn(); + + await expect( + invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + trigger: 'cron', + }, + { fetch, randomUUID: () => '../unsafe' } + ) + ).rejects.toThrow(/thread ID/u); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('never includes caller-provided PII fields in a nudge payload', async () => { + const fetch = vi.fn().mockImplementation(async (_url, init) => { + const request = JSON.parse(String(init?.body)) as { + input: Record; + }; + return new Response( + JSON.stringify({ + ...request.input, + result: { + dispatched: 0, + leased: 0, + operatorAlerts: [], + recoveryPaused: false, + }, + }) + ); + }); + + await invokeLifecycle( + { + baseUrl: 'https://lifecycle.example', + serviceSecret: 'secret', + submissionId: '00000000-0000-4000-8000-000000000001', + trigger: 'nudge', + email: 'private@example.com', + name: 'Private Name', + message: 'Private message', + } as Parameters[0], + { + fetch, + randomUUID: () => '00000000-0000-4000-8000-000000000002', + } + ); + + const body = String(fetch.mock.calls[0]?.[1]?.body); + expect(body).toContain('00000000-0000-4000-8000-000000000001'); + expect(body).not.toContain('private@example.com'); + expect(body).not.toContain('Private Name'); + expect(body).not.toContain('Private message'); + }); + + it.each(['', 'ftp://example.test', 'https://example.test/path?secret=value'])( + 'rejects an unsafe lifecycle base URL: %s', + async (baseUrl) => { + await expect( + invokeLifecycle( + { baseUrl, serviceSecret: 'secret', trigger: 'cron' }, + { fetch: vi.fn() } + ) + ).rejects.toThrow(/base URL/u); + } + ); +}); diff --git a/apps/website/src/lib/growth/lifecycle-client.ts b/apps/website/src/lib/growth/lifecycle-client.ts new file mode 100644 index 000000000..3ce8fbaae --- /dev/null +++ b/apps/website/src/lib/growth/lifecycle-client.ts @@ -0,0 +1,231 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; + +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 LIFECYCLE_RESPONSE_MAX_BYTES = 64 * 1_024; +const INVALID_LIFECYCLE_STATE = 'Lifecycle dispatch returned invalid state'; + +export interface InvokeLifecycleInput { + baseUrl: string; + serviceSecret: string; + timeoutMs?: number; + trigger: 'cron' | 'nudge'; + submissionId?: string; +} + +export interface InvokeLifecycleDependencies { + fetch: typeof fetch; + randomUUID: () => string; +} + +export type LifecycleOperatorAlert = 'mailbox_recovery_required'; + +export interface InvokeLifecycleResult { + operatorAlerts: LifecycleOperatorAlert[]; + threadId: string; +} + +const defaultDependencies: InvokeLifecycleDependencies = { + fetch, + randomUUID, +}; + +function lifecycleBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Lifecycle base URL is invalid'); + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + (url.pathname !== '/' && url.pathname !== '') || + url.search || + url.hash + ) { + throw new Error('Lifecycle base URL must be an HTTPS origin'); + } + return url.origin; +} + +function timeout(value: number | undefined): number { + const resolved = value ?? 15_000; + if (!Number.isInteger(resolved) || resolved < 250 || resolved > 30_000) { + throw new Error( + 'Lifecycle timeout must be between 250 and 30000 milliseconds' + ); + } + return resolved; +} + +function exactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [] +): boolean { + const keys = Object.keys(value); + return ( + required.every((key) => keys.includes(key)) && + keys.every((key) => required.includes(key) || optional.includes(key)) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function parseLifecycleState( + value: unknown, + input: Pick +): LifecycleOperatorAlert[] { + if ( + !isRecord(value) || + !exactKeys(value, ['trigger', 'result'], ['submission_id']) || + value['trigger'] !== input.trigger || + !isRecord(value['result']) + ) { + throw new Error('Lifecycle dispatch returned invalid state'); + } + if ( + input.submissionId + ? value['submission_id'] !== input.submissionId + : 'submission_id' in value + ) { + throw new Error('Lifecycle dispatch returned invalid state'); + } + const result = value['result']; + if ( + !exactKeys(result, [ + 'dispatched', + 'leased', + 'operatorAlerts', + 'recoveryPaused', + ]) || + !isCount(result['dispatched']) || + !isCount(result['leased']) || + typeof result['recoveryPaused'] !== 'boolean' || + !Array.isArray(result['operatorAlerts']) || + !result['operatorAlerts'].every( + (alert) => alert === 'mailbox_recovery_required' + ) + ) { + throw new Error('Lifecycle dispatch returned invalid state'); + } + const operatorAlerts = [ + ...new Set(result['operatorAlerts'] as LifecycleOperatorAlert[]), + ]; + if ( + result['recoveryPaused'] !== + operatorAlerts.includes('mailbox_recovery_required') + ) { + throw new Error('Lifecycle dispatch returned invalid state'); + } + return operatorAlerts; +} + +async function cancelResponseBody(response: Response): Promise { + if (response.body !== null && !response.body.locked) { + await response.body.cancel().catch(() => undefined); + } +} + +async function readLifecycleState(response: Response): Promise { + const declaredLength = response.headers.get('content-length'); + if (declaredLength !== null) { + const normalizedLength = declaredLength.trim(); + const byteLength = Number(normalizedLength); + if ( + !/^\d+$/u.test(normalizedLength) || + !Number.isSafeInteger(byteLength) || + byteLength > LIFECYCLE_RESPONSE_MAX_BYTES + ) { + await cancelResponseBody(response); + throw new Error(INVALID_LIFECYCLE_STATE); + } + } + + if (response.body === null) { + throw new Error(INVALID_LIFECYCLE_STATE); + } + + const reader = response.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 > LIFECYCLE_RESPONSE_MAX_BYTES) { + throw new Error(INVALID_LIFECYCLE_STATE); + } + decoded.push(decoder.decode(value, { stream: true })); + } + decoded.push(decoder.decode()); + return JSON.parse(decoded.join('')) as unknown; + } catch { + await reader.cancel().catch(() => undefined); + throw new Error(INVALID_LIFECYCLE_STATE); + } finally { + reader.releaseLock(); + } +} + +export async function invokeLifecycle( + input: InvokeLifecycleInput, + dependencies: Partial = {} +): Promise { + const resolved = { ...defaultDependencies, ...dependencies }; + const origin = lifecycleBaseUrl(input.baseUrl); + if (!input.serviceSecret) { + throw new Error('Lifecycle service secret is required'); + } + if (input.submissionId && !UUID_V4.test(input.submissionId)) { + throw new Error('Lifecycle submission ID must be a UUID v4'); + } + const threadId = resolved.randomUUID(); + if (!UUID_V4.test(threadId)) { + throw new Error('Lifecycle thread ID must be a UUID v4'); + } + + let response: Response; + try { + response = await resolved.fetch(`${origin}/threads/${threadId}/runs/wait`, { + method: 'POST', + headers: { + authorization: `Bearer ${input.serviceSecret}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + route: '/dispatch#workflow', + input: { + trigger: input.trigger, + ...(input.submissionId ? { submission_id: input.submissionId } : {}), + }, + }), + redirect: 'error', + signal: AbortSignal.timeout(timeout(input.timeoutMs)), + }); + } catch { + throw new Error('Lifecycle dispatch request failed'); + } + if (!response.ok) { + await cancelResponseBody(response); + throw new Error('Lifecycle dispatch was not accepted'); + } + const state = await readLifecycleState(response); + return { + operatorAlerts: parseLifecycleState(state, input), + threadId, + }; +} diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 5b188f1f2..116101e16 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -38,7 +38,8 @@ ], "@threadplane/telemetry/browser": [ "../../libs/telemetry/src/browser/public-api.ts" - ] + ], + "@threadplane-internal/growth": ["../../libs/growth/src/index.ts"] } }, "include": [ diff --git a/package-lock.json b/package-lock.json index 6db9f12b9..c3b34f8ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "rehype-pretty-code": "^0.14.3", "rehype-slug": "^6.0.0", "rxjs": "~7.8.0", + "server-only": "^0.0.1", "shiki": "^4.0.2" }, "devDependencies": { @@ -40681,6 +40682,12 @@ "node": ">= 0.8.0" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", diff --git a/package.json b/package.json index ab3191f32..83167c0a4 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "rehype-pretty-code": "^0.14.3", "rehype-slug": "^6.0.0", "rxjs": "~7.8.0", + "server-only": "^0.0.1", "shiki": "^4.0.2" }, "nx": { From b77309564d57917210606abdb869df4e93829a5f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 15:31:21 -0700 Subject: [PATCH 07/15] feat: persist acquisition forms in neon Co-Authored-By: Claude Opus 5 --- apps/website/src/app/api/leads/route.spec.ts | 541 +++++++++++++----- apps/website/src/app/api/leads/route.ts | 229 +++++--- .../src/app/api/newsletter/route.spec.ts | 355 ++++++++++++ apps/website/src/app/api/newsletter/route.ts | 153 +++-- .../app/api/whitepaper-signup/route.spec.ts | 378 ++++++++++++ .../src/app/api/whitepaper-signup/route.ts | 179 +++--- .../lib/growth/hard-cutover-boundary.spec.ts | 59 ++ 7 files changed, 1552 insertions(+), 342 deletions(-) create mode 100644 apps/website/src/app/api/newsletter/route.spec.ts create mode 100644 apps/website/src/app/api/whitepaper-signup/route.spec.ts create mode 100644 apps/website/src/lib/growth/hard-cutover-boundary.spec.ts diff --git a/apps/website/src/app/api/leads/route.spec.ts b/apps/website/src/app/api/leads/route.spec.ts index d7a455d2e..2fe5fbd68 100644 --- a/apps/website/src/app/api/leads/route.spec.ts +++ b/apps/website/src/app/api/leads/route.spec.ts @@ -1,192 +1,425 @@ 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, - }, -})); +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, + }), })); +// These no-op modules isolate the legacy handler during the required RED run. +vi.mock('../../../../lib/resend', () => ({ + FROM: '', + NOTIFY_TO: '', + addToAudience: vi.fn(), + sendEmail: vi.fn(), +})); +vi.mock('../../../../lib/loops', () => ({ + loopsSendEvent: vi.fn(), + loopsUpsertContact: vi.fn(), +})); vi.mock('../../../lib/analytics/server', () => ({ - captureLeadConversion: captureLeadConversionMock, - captureLeadQualified: captureLeadQualifiedMock, - captureNewsletterConversion: captureNewsletterConversionMock, - captureWhitepaperConversion: captureWhitepaperConversionMock, + captureLeadConversion: vi.fn(), + captureLeadQualified: vi.fn(), })); -import { POST as postLead } from './route'; -import { POST as postNewsletter } from '../newsletter/route'; -import { POST as postWhitepaperSignup } from '../whitepaper-signup/route'; +import type { PublicFormPolicy } from '../../../lib/growth/form-policy'; +import { POST } from './route'; -function jsonRequest(path: string, body: unknown): Request { - return new Request(`https://threadplane.ai${path}`, { +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/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..58861de0e 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -1,73 +1,158 @@ -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 }); +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..a5bcb7d65 --- /dev/null +++ b/apps/website/src/app/api/newsletter/route.spec.ts @@ -0,0 +1,355 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +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, + }), +})); + +// These no-op modules isolate the legacy handler during the required RED run. +vi.mock('../../../../lib/resend', () => ({ + FROM: '', + addToAudience: vi.fn(), + sendEmail: vi.fn(), +})); +vi.mock('../../../../lib/loops', () => ({ + loopsSendEvent: vi.fn(), + loopsUpsertContact: vi.fn(), +})); +vi.mock('../../../lib/analytics/server', () => ({ + captureNewsletterConversion: vi.fn(), +})); + +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..334a2115c 100644 --- a/apps/website/src/app/api/newsletter/route.ts +++ b/apps/website/src/app/api/newsletter/route.ts @@ -1,49 +1,106 @@ -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 }); +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/whitepaper-signup/route.spec.ts b/apps/website/src/app/api/whitepaper-signup/route.spec.ts new file mode 100644 index 000000000..8cb798a3e --- /dev/null +++ b/apps/website/src/app/api/whitepaper-signup/route.spec.ts @@ -0,0 +1,378 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +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, + }), +})); + +// These no-op modules isolate the legacy handler during the required RED run. +vi.mock('fs', () => ({ + default: { appendFileSync: vi.fn(), mkdirSync: vi.fn() }, +})); +vi.mock('../../../../lib/resend', () => ({ + FROM: '', + addToAudience: vi.fn(), + sendEmail: vi.fn(), +})); +vi.mock('../../../../lib/loops', () => ({ + loopsSendEvent: vi.fn(), + loopsUpsertContact: vi.fn(), +})); +vi.mock('../../../../lib/drip', () => ({ + scheduleWhitepaperDrip: vi.fn(), +})); +vi.mock('../../../lib/analytics/server', () => ({ + captureWhitepaperConversion: vi.fn(), +})); + +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..52070d8e7 100644 --- a/apps/website/src/app/api/whitepaper-signup/route.ts +++ b/apps/website/src/app/api/whitepaper-signup/route.ts @@ -1,80 +1,123 @@ -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'; +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/lib/growth/hard-cutover-boundary.spec.ts b/apps/website/src/lib/growth/hard-cutover-boundary.spec.ts new file mode 100644 index 000000000..f679bd6e0 --- /dev/null +++ b/apps/website/src/lib/growth/hard-cutover-boundary.spec.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const ROUTES = [ + 'whitepaper-signup', + 'newsletter', + 'leads', +] as const; + +const FORBIDDEN: ReadonlyArray = [ + ['filesystem persistence', /from ['"](?:node:)?fs['"]/u], + ['Loops', /lib\/loops/u], + ['legacy Resend transport', /lib\/resend/u], + ['legacy drip scheduler', /lib\/drip/u], + ['whitepaper drip scheduling', /scheduleWhitepaperDrip/u], + ['Resend audience upsert', /addToAudience/u], + ['direct request-time sending', /sendEmail\(/u], + ['NDJSON lead storage', /\.ndjson/u], + ['legacy handler', /legacyPost/u], +]; + +function routeSource(route: string): string { + return readFileSync( + join(HERE, '..', '..', 'app', 'api', route, 'route.ts'), + 'utf8' + ); +} + +describe('hard-cutover form boundary', () => { + it.each(ROUTES)( + '/api/%s accepts submissions through Neon only', + (route) => { + const source = routeSource(route); + + for (const [label, pattern] of FORBIDDEN) { + expect( + pattern.test(source), + `/api/${route} must not reference ${label}` + ).toBe(false); + } + } + ); + + it.each(ROUTES)( + '/api/%s commits through the growth form route seam', + (route) => { + const source = routeSource(route); + + expect(source).toContain('lib/growth/form-route'); + expect(source).toContain('dependencies.accept('); + expect(source).toContain('dependencies.nudge('); + } + ); +}); From 01f608fc04abdad0e398ec2fb96bae3e52c16595 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 15:46:23 -0700 Subject: [PATCH 08/15] feat: submit growth approval envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rendered acquisition form now sends the immutable growth envelope — submission UUID, acquisition session, and policy version — and shows the server-owned disclosure beside its submit control. A 409 stops the flow with a refresh instruction rather than reporting success. Also carries the switch the pages now read at render time into the unit suite, the Playwright web server, and the CI website job, and fixes the module-boundary lint on the routes landed in the previous commit. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 + apps/website/e2e/website.spec.ts | 29 +- apps/website/playwright.config.ts | 3 + apps/website/src/app/ag-ui/page.spec.tsx | 2 + apps/website/src/app/ag-ui/page.tsx | 4 +- apps/website/src/app/api/leads/route.spec.ts | 2 + apps/website/src/app/api/leads/route.ts | 2 + .../src/app/api/newsletter/route.spec.ts | 2 + apps/website/src/app/api/newsletter/route.ts | 2 + .../app/api/whitepaper-signup/route.spec.ts | 2 + .../src/app/api/whitepaper-signup/route.ts | 2 + apps/website/src/app/chat/page.spec.tsx | 2 + apps/website/src/app/chat/page.tsx | 4 +- apps/website/src/app/contact/page.tsx | 4 +- apps/website/src/app/langgraph/page.spec.tsx | 2 + apps/website/src/app/langgraph/page.tsx | 4 +- apps/website/src/app/layout.tsx | 6 +- apps/website/src/app/page.tsx | 4 +- .../src/app/pilot-to-prod/page.spec.tsx | 2 + apps/website/src/app/pilot-to-prod/page.tsx | 4 +- apps/website/src/app/pricing/page.spec.tsx | 2 + apps/website/src/app/pricing/page.tsx | 4 +- apps/website/src/app/render/page.spec.tsx | 2 + apps/website/src/app/render/page.tsx | 4 +- .../src/app/solutions/[slug]/page.spec.tsx | 2 + .../website/src/app/solutions/[slug]/page.tsx | 4 +- apps/website/src/app/solutions/page.tsx | 4 +- .../components/contact/ContactForm.spec.tsx | 261 ++++++++++++++---- .../src/components/contact/ContactForm.tsx | 91 ++++-- .../landing/WhitePaperBlock.spec.tsx | 150 ++++++++-- .../components/landing/WhitePaperBlock.tsx | 60 +++- .../src/components/pricing/LeadForm.spec.tsx | 187 +++++++++++-- .../src/components/pricing/LeadForm.tsx | 65 ++++- .../shared/AnnouncementToast.spec.tsx | 108 +++++++- .../components/shared/AnnouncementToast.tsx | 65 ++++- .../src/components/shared/Footer.spec.tsx | 129 +++++++++ apps/website/src/components/shared/Footer.tsx | 59 +++- .../src/components/shared/SiteFooter.spec.tsx | 44 ++- .../src/components/shared/SiteFooter.tsx | 5 +- apps/website/src/lib/solutions-links.spec.ts | 4 +- apps/website/vite.config.mts | 3 + 41 files changed, 1161 insertions(+), 178 deletions(-) create mode 100644 apps/website/src/components/shared/Footer.spec.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d196c25b6..eb54a4521 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/playwright.config.ts b/apps/website/playwright.config.ts index 7e7d834b3..ebdea5145 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -34,6 +34,9 @@ export default defineConfig({ 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' }, }, { command: 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/leads/route.spec.ts b/apps/website/src/app/api/leads/route.spec.ts index 2fe5fbd68..5e394003c 100644 --- a/apps/website/src/app/api/leads/route.spec.ts +++ b/apps/website/src/app/api/leads/route.spec.ts @@ -1,5 +1,7 @@ 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, diff --git a/apps/website/src/app/api/leads/route.ts b/apps/website/src/app/api/leads/route.ts index 58861de0e..5f4666c5e 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -1,3 +1,5 @@ +// The website intentionally consumes the growth library through its internal boundary. +// eslint-disable-next-line @nx/enforce-module-boundaries import { normalizeRecipientEmail, type FormSubmission, diff --git a/apps/website/src/app/api/newsletter/route.spec.ts b/apps/website/src/app/api/newsletter/route.spec.ts index a5bcb7d65..694dae556 100644 --- a/apps/website/src/app/api/newsletter/route.spec.ts +++ b/apps/website/src/app/api/newsletter/route.spec.ts @@ -1,5 +1,7 @@ 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, diff --git a/apps/website/src/app/api/newsletter/route.ts b/apps/website/src/app/api/newsletter/route.ts index 334a2115c..52ffd4424 100644 --- a/apps/website/src/app/api/newsletter/route.ts +++ b/apps/website/src/app/api/newsletter/route.ts @@ -1,3 +1,5 @@ +// 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'; diff --git a/apps/website/src/app/api/whitepaper-signup/route.spec.ts b/apps/website/src/app/api/whitepaper-signup/route.spec.ts index 8cb798a3e..8056d0866 100644 --- a/apps/website/src/app/api/whitepaper-signup/route.spec.ts +++ b/apps/website/src/app/api/whitepaper-signup/route.spec.ts @@ -1,5 +1,7 @@ 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, diff --git a/apps/website/src/app/api/whitepaper-signup/route.ts b/apps/website/src/app/api/whitepaper-signup/route.ts index 52070d8e7..cedbad354 100644 --- a/apps/website/src/app/api/whitepaper-signup/route.ts +++ b/apps/website/src/app/api/whitepaper-signup/route.ts @@ -1,3 +1,5 @@ +// 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'; diff --git a/apps/website/src/app/chat/page.spec.tsx b/apps/website/src/app/chat/page.spec.tsx index 8aa4cc70e..90696ec80 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 d13b98c10..b739b6535 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 dd412c7a1..85373db72 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 4bcb284a7..f2329af9f 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 0fa5d9537..7df6165be 100644 --- a/apps/website/src/app/pilot-to-prod/page.tsx +++ b/apps/website/src/app/pilot-to-prod/page.tsx @@ -11,6 +11,7 @@ import { FinalCTA } from '../../components/landing/FinalCTA'; import { DiagramSection } from '../../components/landing/DiagramSection'; import { PilotJourney } from '../../components/docs/diagrams'; import { createPageMetadata } from '../../lib/site-metadata'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: 'Pilot to Production — Threadplane', @@ -20,6 +21,7 @@ export const metadata = createPageMetadata({ }); export default function PilotToProdPage() { + const formPolicy = getFormPolicy(); return ( <> {/* Hero */} @@ -181,7 +183,7 @@ export default function PilotToProdPage() {
- + {/* Contact anchor */} diff --git a/apps/website/src/app/pricing/page.spec.tsx b/apps/website/src/app/pricing/page.spec.tsx index fc3df48fc..d122d11df 100644 --- a/apps/website/src/app/pricing/page.spec.tsx +++ b/apps/website/src/app/pricing/page.spec.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import PricingPage, { metadata } from './page'; vi.mock('../../components/pricing/LeadForm', () => ({ LeadForm: () => null })); diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx index e1fa3131d..109aec799 100644 --- a/apps/website/src/app/pricing/page.tsx +++ b/apps/website/src/app/pricing/page.tsx @@ -8,6 +8,7 @@ import { LeadForm } from '../../components/pricing/LeadForm'; import { FinalCTA } from '../../components/landing/FinalCTA'; import { createPageMetadata } from '../../lib/site-metadata'; import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: 'Pricing — Threadplane', @@ -18,6 +19,7 @@ export const metadata = createPageMetadata({ }); export default function PricingPage() { + const formPolicy = getFormPolicy(); return ( <>
@@ -61,7 +63,7 @@ export default function PricingPage() { - + ); diff --git a/apps/website/src/app/render/page.spec.tsx b/apps/website/src/app/render/page.spec.tsx index ee92b3141..6884735f8 100644 --- a/apps/website/src/app/render/page.spec.tsx +++ b/apps/website/src/app/render/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import RenderPage from './page'; vi.mock('../../lib/analytics/client', () => ({ diff --git a/apps/website/src/app/render/page.tsx b/apps/website/src/app/render/page.tsx index a276ce52e..89002dd0d 100644 --- a/apps/website/src/app/render/page.tsx +++ b/apps/website/src/app/render/page.tsx @@ -13,6 +13,7 @@ import { RenderCodeShowcase } from '../../components/landing/render/RenderCodeSh import { createPageMetadata } from '../../lib/site-metadata'; import { SECTION_MEDIA } from '../../lib/section-media'; import { buildPanes } from '../../lib/build-panes'; +import { getFormPolicy } from '../../lib/growth/form-policy'; export const metadata = createPageMetadata({ title: '@threadplane/render — Generative UI for Angular', @@ -22,6 +23,7 @@ export const metadata = createPageMetadata({ }); export default async function RenderPage() { + const formPolicy = getFormPolicy(); const panes = await buildPanes(SECTION_MEDIA.libRender, SECTION_MEDIA.libRender.video?.url ?? ''); return ( @@ -91,7 +93,7 @@ export default async function RenderPage() { visual={} /> - + ); diff --git a/apps/website/src/app/solutions/[slug]/page.spec.tsx b/apps/website/src/app/solutions/[slug]/page.spec.tsx index ba9114766..c926ce8f8 100644 --- a/apps/website/src/app/solutions/[slug]/page.spec.tsx +++ b/apps/website/src/app/solutions/[slug]/page.spec.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import SolutionPage from './page'; import { getAllSolutionSlugs } from '../../../lib/solutions-data'; diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx index 508f45e00..e50e0ecf1 100644 --- a/apps/website/src/app/solutions/[slug]/page.tsx +++ b/apps/website/src/app/solutions/[slug]/page.tsx @@ -17,6 +17,7 @@ import { Pill } from '../../../components/ui/Pill'; import { Card } from '../../../components/ui/Card'; import { WhitePaperBlock } from '../../../components/landing/WhitePaperBlock'; import { FinalCTA } from '../../../components/landing/FinalCTA'; +import { getFormPolicy } from '../../../lib/growth/form-policy'; interface PageProps { params: Promise<{ slug: string }>; @@ -166,6 +167,7 @@ function Capabilities({ items }: { items: ProofPoint[] }) { } export default async function SolutionPage({ params }: PageProps) { + const formPolicy = getFormPolicy(); const { slug } = await params; const solution = getSolutionBySlug(slug); if (!solution) notFound(); @@ -200,7 +202,7 @@ export default async function SolutionPage({ params }: PageProps) { {solution.demo && } - + {/* Hero */} @@ -74,7 +76,7 @@ export default function SolutionsIndexPage() {
- + ); diff --git a/apps/website/src/components/contact/ContactForm.spec.tsx b/apps/website/src/components/contact/ContactForm.spec.tsx index bcdc82ca2..663993c18 100644 --- a/apps/website/src/components/contact/ContactForm.spec.tsx +++ b/apps/website/src/components/contact/ContactForm.spec.tsx @@ -1,86 +1,109 @@ // SPDX-License-Identifier: MIT // @vitest-environment jsdom import React from 'react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; const trackMock = vi.hoisted(() => vi.fn()); -const fetchMock = vi.hoisted(() => vi.fn()); vi.mock('../../lib/analytics/client', () => ({ track: trackMock })); -vi.mock('next/navigation', () => ({ - useSearchParams: () => new URLSearchParams('?source=home_hero&track=enterprise'), -})); -vi.mock('../ui/Button', () => ({ - Button: ({ - children, - type, - disabled, - onClick, - }: { - children: React.ReactNode; - type?: 'submit' | 'button' | 'reset'; - disabled?: boolean; - onClick?: () => void; - }) => ( - - ), -})); + +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { ContactForm } from './ContactForm'; + +const formPolicy: PublicFormPolicy = { + mode: 'growth_v1', + version: 'growth_v1.2026-09-01', + disclosures: { + contact: + 'By sending, you agree Brian may follow up by email about your request.', + newsletter: 'Newsletter disclosure', + whitepaper: 'Whitepaper disclosure', + }, +}; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function fill(fields: { + email: string; + name?: string; + company?: string; + message?: string; +}): void { + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: fields.email }, + }); + if (fields.name !== undefined) { + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: fields.name }, + }); + } + if (fields.company !== undefined) { + fireEvent.change(screen.getByLabelText(/company/i), { + target: { value: fields.company }, + }); + } + if (fields.message !== undefined) { + fireEvent.change(screen.getByLabelText(/message/i), { + target: { value: fields.message }, + }); + } +} + +function send(): void { + fireEvent.click(screen.getByRole('button', { name: /^send$/i })); +} + +function sentBody(fetchMock: ReturnType, call: number) { + return JSON.parse(fetchMock.mock.calls[call][1].body as string); +} beforeEach(() => { trackMock.mockClear(); - fetchMock.mockReset(); - vi.stubGlobal('fetch', fetchMock); - Object.defineProperty(document, 'referrer', { - value: 'https://threadplane.ai/pricing', - configurable: true, - }); + sessionStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe('ContactForm', () => { it('submits with email only and fires lead_form_submit + lead_form_success', async () => { - fetchMock.mockResolvedValue({ ok: true }); - const { ContactForm } = await import('./ContactForm'); - render(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); - fireEvent.change(screen.getByLabelText(/email/i), { - target: { value: 'jane@acme.com' }, - }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); + fill({ email: 'jane@acme.com' }); + send(); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.email).toBe('jane@acme.com'); - expect(body.source_page).toBe('home_hero'); - expect(body.track).toBe('enterprise'); - expect(body.referrer_host).toBe('threadplane.ai'); - + expect(sentBody(fetchMock, 0).email).toBe('jane@acme.com'); expect(trackMock).toHaveBeenCalledWith( 'marketing:lead_form_submit', - expect.objectContaining({ surface: 'contact' }), + expect.objectContaining({ surface: 'contact' }) ); expect(trackMock).toHaveBeenCalledWith( 'marketing:lead_form_success', - expect.objectContaining({ surface: 'contact' }), + expect.objectContaining({ surface: 'contact' }) ); }); it('submits with all optional fields populated', async () => { - fetchMock.mockResolvedValue({ ok: true }); - const { ContactForm } = await import('./ContactForm'); - render(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'jane@acme.com' } }); - fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Jane Smith' } }); - fireEvent.change(screen.getByLabelText(/company/i), { target: { value: 'Acme' } }); - fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hi' } }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); + fill({ + email: 'jane@acme.com', + name: 'Jane Smith', + company: 'Acme', + message: 'Hi', + }); + send(); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body).toMatchObject({ + expect(sentBody(fetchMock, 0)).toMatchObject({ email: 'jane@acme.com', name: 'Jane Smith', company: 'Acme', @@ -89,18 +112,136 @@ describe('ContactForm', () => { }); it('fires lead_form_fail on non-2xx', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 500 }); - const { ContactForm } = await import('./ContactForm'); - render(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 500 }) + ); + render(); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'jane@acme.com' } }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); + fill({ email: 'jane@acme.com' }); + send(); await waitFor(() => expect(trackMock).toHaveBeenCalledWith( 'marketing:lead_form_fail', - expect.objectContaining({ surface: 'contact' }), - ), + expect.objectContaining({ surface: 'contact' }) + ) + ); + }); +}); + +describe('ContactForm growth policy', () => { + it('renders the contact disclosure and describes the submit control', () => { + render(); + + const disclosure = screen.getByText(formPolicy.disclosures.contact); + const button = screen.getByRole('button', { name: /^send$/i }); + + expect(disclosure.id).toBeTruthy(); + expect(button.getAttribute('aria-describedby')).toBe(disclosure.id); + }); + + it('submits the immutable growth envelope declaring the contact form kind', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com', name: 'Reader', message: 'Hello' }); + send(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock.mock.calls[0][0]).toBe('/api/leads'); + const body = sentBody(fetchMock, 0); + expect(body.form_kind).toBe('contact'); + expect(body.email).toBe('reader@example.com'); + expect(body.policy_version).toBe(formPolicy.version); + expect(body.submission_id).toMatch(UUID_V4); + expect(body.acquisition_session_id).toMatch(UUID_V4); + }); + + it('omits blank optional fields rather than sending undefined facts', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + const keys = Object.keys(sentBody(fetchMock, 0)); + expect(keys).not.toContain('name'); + expect(keys).not.toContain('company'); + expect(keys).not.toContain('message'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('never sends legacy attribution facts the durable boundary ignores', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + const keys = Object.keys(sentBody(fetchMock, 0)); + for (const legacy of ['source_page', 'track', 'cta_id', 'referrer_host']) { + expect(keys).not.toContain(legacy); + } + }); + + it('reuses the submission UUID when an uncertain attempt is retried', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('mints a new submission UUID when the sender changes the facts', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fill({ email: 'reader@example.com' }); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + fill({ email: 'reader@example.com', message: 'One more thing' }); + send(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + expect(sentBody(fetchMock, 1).submission_id).not.toBe( + sentBody(fetchMock, 0).submission_id + ); + }); + + it('requires a page refresh after a policy mismatch and reports no success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 409 }) + ); + render(); + + fill({ email: 'reader@example.com' }); + send(); + + await waitFor(() => + expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy() ); + expect(screen.queryByText(/we'll be in touch/i)).toBeNull(); }); }); diff --git a/apps/website/src/components/contact/ContactForm.tsx b/apps/website/src/components/contact/ContactForm.tsx index f512ae1bd..bbf5687c8 100644 --- a/apps/website/src/components/contact/ContactForm.tsx +++ b/apps/website/src/components/contact/ContactForm.tsx @@ -1,35 +1,31 @@ // SPDX-License-Identifier: MIT 'use client'; -import React, { useState } from 'react'; -import { useSearchParams } from 'next/navigation'; +import React, { useRef, useState } from 'react'; import { Button } from '../ui/Button'; import { track } from '../../lib/analytics/client'; import { analyticsEvents } from '../../lib/analytics/events'; +import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { + FORM_POLICY_REFRESH_MESSAGE, + growthFormRequestSnapshot, + type GrowthFormRequestSnapshot, +} from '../../lib/growth/form-client'; -type Status = 'idle' | 'sending' | 'sent' | 'error'; +type Status = 'idle' | 'sending' | 'sent' | 'error' | 'stale'; -function sanitizeReferrerHost(): string | undefined { - if (typeof document === 'undefined' || !document.referrer) return undefined; - try { - return new URL(document.referrer).hostname; - } catch { - return undefined; - } -} - -export function ContactForm() { - const params = useSearchParams(); +export function ContactForm({ + formPolicy, +}: { + formPolicy: PublicFormPolicy; +}) { const [status, setStatus] = useState('idle'); const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [company, setCompany] = useState(''); const [message, setMessage] = useState(''); - - const sourcePage = params.get('source') ?? 'contact_direct'; - const trackParam = (params.get('track') ?? 'enterprise') as string; - const ctaId = params.get('cta_id') ?? undefined; - const paper = params.get('paper') ?? undefined; + const submissionSnapshot = useRef(null); + const disclosureId = 'contact-form-growth-disclosure'; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -40,22 +36,34 @@ export function ContactForm() { source_section: 'contact-form', }); try { + const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { + form_kind: 'contact', + email, + ...(name ? { name } : {}), + ...(company ? { company } : {}), + ...(message ? { message } : {}), + }); + submissionSnapshot.current = snapshot; const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - email, - name: name || undefined, - company: company || undefined, - message: message || undefined, - source_page: sourcePage, - track: trackParam, - cta_id: ctaId, - paper, - referrer_host: sanitizeReferrerHost(), + ...snapshot.facts, + acquisition_session_id: snapshot.acquisition_session_id, + submission_id: snapshot.submission_id, + policy_version: formPolicy.version, }), }); + if (res.status === 409) { + submissionSnapshot.current = null; + setStatus('stale'); + return; + } + if (res.status >= 400 && res.status < 500) { + submissionSnapshot.current = null; + } if (res.ok) { + submissionSnapshot.current = null; track(analyticsEvents.marketingLeadFormSuccess, { surface: 'contact', source_section: 'contact-form', @@ -79,6 +87,22 @@ export function ContactForm() { } } + if (status === 'stale') { + return ( +
+

{FORM_POLICY_REFRESH_MESSAGE}

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

{FORM_POLICY_REFRESH_MESSAGE}

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

+ {formPolicy.disclosures.whitepaper} +

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

+ {FORM_POLICY_REFRESH_MESSAGE} +

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

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

+ {formPolicy.disclosures.contact} +

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

+ {formPolicy.disclosures.whitepaper} +

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

{FORM_POLICY_REFRESH_MESSAGE}

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