refactor(leads): migrate /api/{contact,register-interest} → LeadsService (#3207)

New leads/v1 sebuf service with two POST RPCs:
  - SubmitContact    → /api/leads/v1/submit-contact
  - RegisterInterest → /api/leads/v1/register-interest

Handler logic ported 1:1 from api/contact.js + api/register-interest.js:
  - Turnstile verification (desktop sources bypass, preserved)
  - Honeypot (website field) silently accepts without upstream calls
  - Free-email-domain gate on SubmitContact (422 ApiError)
  - validateEmail (disposable/offensive/typo-TLD/MX) on RegisterInterest
  - Convex writes via ConvexHttpClient (contactMessages:submit, registerInterest:register)
  - Resend notification + confirmation emails (HTML templates unchanged)

Shared helpers moved to server/_shared/:
  - turnstile.ts (getClientIp + verifyTurnstile)
  - email-validation.ts (disposable/offensive/MX checks)

Rate limits preserved via ENDPOINT_RATE_POLICIES:
  - submit-contact:    3/hour per IP (was in-memory 3/hr)
  - register-interest: 5/hour per IP (was in-memory 5/hr; desktop
    sources previously capped at 2/hr via shared in-memory map —
    now 5/hr like everyone else, accepting the small regression in
    exchange for Upstash-backed global limiting)

Callers updated:
  - pro-test/src/App.tsx contact form → new submit-contact path
  - src-tauri/sidecar/local-api-server.mjs cloud-fallback rewrites
    /api/register-interest → /api/leads/v1/register-interest when
    proxying; keeps local path for older desktop builds
  - src/services/runtime.ts isKeyFreeApiTarget allows both old and
    new paths through the WORLDMONITOR_API_KEY-optional gate

Tests:
  - tests/contact-handler.test.mjs rewritten to call submitContact
    handler directly; asserts on ValidationError / ApiError
  - tests/email-validation.test.mjs + tests/turnstile.test.mjs
    point at the new server/_shared/ modules

Deleted: api/contact.js, api/register-interest.js, api/_ip-rate-limit.js,
api/_turnstile.js, api/_email-validation.js, api/_turnstile.test.mjs.
Manifest entries removed (58 → 56). Docs updated (api-platform,
api-commerce, usage-rate-limits).

Verified: npm run typecheck + typecheck:api + lint:api-contract
(88 files / 56 entries) + lint:boundaries pass; full test:data
(5852 tests) passes; make generate is zero-diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastien Melki
2026-04-21 02:16:52 +03:00
parent 96b079f862
commit 9ccd309dbc
26 changed files with 905 additions and 357 deletions

View File

@@ -1,20 +0,0 @@
export function createIpRateLimiter({ limit, windowMs }) {
const rateLimitMap = new Map();
function getEntry(ip) {
return rateLimitMap.get(ip) || null;
}
function isRateLimited(ip) {
const now = Date.now();
const entry = getEntry(ip);
if (!entry || now - entry.windowStart > windowMs) {
rateLimitMap.set(ip, { windowStart: now, count: 1 });
return false;
}
entry.count += 1;
return entry.count > limit;
}
return { isRateLimited, getEntry };
}

View File

@@ -316,20 +316,6 @@
"removal_issue": "TBD"
},
{
"path": "api/contact.js",
"category": "migration-pending",
"reason": "Migrating to leads/v1 service (SubmitContact RPC) in commit 4 of #3207.",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
},
{
"path": "api/register-interest.js",
"category": "migration-pending",
"reason": "Migrating to leads/v1 service (RegisterInterest RPC) in commit 4 of #3207.",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
},
{
"path": "api/eia/[[...path]].js",
"category": "migration-pending",

9
api/leads/v1/[rpc].ts Normal file
View File

@@ -0,0 +1,9 @@
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createLeadsServiceRoutes } from '../../../src/generated/server/worldmonitor/leads/v1/service_server';
import { leadsHandler } from '../../../server/worldmonitor/leads/v1/handler';
export default createDomainGateway(
createLeadsServiceRoutes(leadsHandler, serverOptions),
);

View File

@@ -112,6 +112,6 @@ No `referrals` count or `rewardMonths` is returned today — Dodo's `affonso_ref
## Waitlist
### `POST /api/register-interest`
### `POST /api/leads/v1/register-interest`
Captures an email into the Convex waitlist table. Turnstile-verified, rate-limited per IP. See [Platform endpoints](/api-platform) for the request shape.
Captures an email into the Convex waitlist table. Turnstile-verified (desktop sources bypass), rate-limited per IP. Part of `LeadsService`; see [Platform endpoints](/api-platform) for the request shape.

View File

@@ -146,10 +146,10 @@ Redirects to the matching asset on the latest GitHub release of `koala73/worldmo
Caches the 302 for 5 minutes (`s-maxage=300`, `stale-while-revalidate=60`, `stale-if-error=600`).
### `POST /api/contact`
### `POST /api/leads/v1/submit-contact`
Public contact form. Turnstile-verified, rate-limited per IP.
Public enterprise contact form. Turnstile-verified, rate-limited per IP. Part of `LeadsService`.
### `POST /api/register-interest`
### `POST /api/leads/v1/register-interest`
Captures email for desktop-app early-access waitlist. Writes to Convex.
Captures email for Pro-waitlist signup. Writes to Convex and sends a confirmation email. Part of `LeadsService`.

View File

@@ -0,0 +1 @@
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"RegisterInterestRequest":{"description":"RegisterInterestRequest carries a Pro-waitlist signup.","properties":{"appVersion":{"type":"string"},"email":{"type":"string"},"referredBy":{"type":"string"},"source":{"type":"string"},"turnstileToken":{"description":"Cloudflare Turnstile token. Desktop sources bypass Turnstile; see handler.","type":"string"},"website":{"description":"Honeypot — bots auto-fill this hidden field; real submissions leave it empty.","type":"string"}},"type":"object"},"RegisterInterestResponse":{"description":"RegisterInterestResponse mirrors the Convex registerInterest:register return shape.","properties":{"emailSuppressed":{"description":"True when the email is on the suppression list (prior bounce) and no confirmation was sent.","type":"boolean"},"position":{"description":"Waitlist position at registration time. Present only when status == \"registered\".","format":"int32","type":"integer"},"referralCode":{"description":"Stable referral code for this email.","type":"string"},"referralCount":{"description":"Number of signups credited to this email.","format":"int32","type":"integer"},"status":{"description":"\"registered\" for a new signup; \"already_registered\" for a returning email.","type":"string"}},"type":"object"},"SubmitContactRequest":{"description":"SubmitContactRequest carries an enterprise contact form submission.","properties":{"email":{"type":"string"},"message":{"type":"string"},"name":{"type":"string"},"organization":{"type":"string"},"phone":{"type":"string"},"source":{"type":"string"},"turnstileToken":{"description":"Cloudflare Turnstile token proving the submitter is human.","type":"string"},"website":{"description":"Honeypot — bots auto-fill this hidden field; real submissions leave it empty.","type":"string"}},"type":"object"},"SubmitContactResponse":{"description":"SubmitContactResponse reports the outcome of storing the lead and notifying ops.","properties":{"emailSent":{"description":"True when the Resend notification to ops was delivered.","type":"boolean"},"status":{"description":"Always \"sent\" on success.","type":"string"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"LeadsService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/leads/v1/register-interest":{"post":{"description":"RegisterInterest adds an email to the Pro waitlist and sends a confirmation email.","operationId":"RegisterInterest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInterestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInterestResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"RegisterInterest","tags":["LeadsService"]}},"/api/leads/v1/submit-contact":{"post":{"description":"SubmitContact stores an enterprise contact submission in Convex and emails ops.","operationId":"SubmitContact","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitContactRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitContactResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"SubmitContact","tags":["LeadsService"]}}}}

View File

@@ -0,0 +1,173 @@
openapi: 3.1.0
info:
title: LeadsService API
version: 1.0.0
paths:
/api/leads/v1/submit-contact:
post:
tags:
- LeadsService
summary: SubmitContact
description: SubmitContact stores an enterprise contact submission in Convex and emails ops.
operationId: SubmitContact
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitContactRequest'
required: true
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitContactResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/leads/v1/register-interest:
post:
tags:
- LeadsService
summary: RegisterInterest
description: RegisterInterest adds an email to the Pro waitlist and sends a confirmation email.
operationId: RegisterInterest
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterInterestRequest'
required: true
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterInterestResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
SubmitContactRequest:
type: object
properties:
email:
type: string
name:
type: string
organization:
type: string
phone:
type: string
message:
type: string
source:
type: string
website:
type: string
description: Honeypot — bots auto-fill this hidden field; real submissions leave it empty.
turnstileToken:
type: string
description: Cloudflare Turnstile token proving the submitter is human.
description: SubmitContactRequest carries an enterprise contact form submission.
SubmitContactResponse:
type: object
properties:
status:
type: string
description: Always "sent" on success.
emailSent:
type: boolean
description: True when the Resend notification to ops was delivered.
description: SubmitContactResponse reports the outcome of storing the lead and notifying ops.
RegisterInterestRequest:
type: object
properties:
email:
type: string
source:
type: string
appVersion:
type: string
referredBy:
type: string
website:
type: string
description: Honeypot — bots auto-fill this hidden field; real submissions leave it empty.
turnstileToken:
type: string
description: Cloudflare Turnstile token. Desktop sources bypass Turnstile; see handler.
description: RegisterInterestRequest carries a Pro-waitlist signup.
RegisterInterestResponse:
type: object
properties:
status:
type: string
description: '"registered" for a new signup; "already_registered" for a returning email.'
referralCode:
type: string
description: Stable referral code for this email.
referralCount:
type: integer
format: int32
description: Number of signups credited to this email.
position:
type: integer
format: int32
description: Waitlist position at registration time. Present only when status == "registered".
emailSuppressed:
type: boolean
description: True when the email is on the suppression list (prior bounce) and no confirmation was sent.
description: RegisterInterestResponse mirrors the Convex registerInterest:register return shape.

View File

@@ -39,8 +39,8 @@ Exceeding any of these during the OAuth flow will cause the MCP client to fail t
|----------|-------|--------|-------|
| `POST /api/scenario/v1/run` | 10 | 60 s | Per user |
| `POST /api/scenario/v1/run` (queue depth) | 100 in-flight | — | Global |
| `POST /api/register-interest` | 5 | 60 min | Per IP + Turnstile |
| `POST /api/contact` | 3 | 60 min | Per IP + Turnstile |
| `POST /api/leads/v1/register-interest` | 5 | 60 min | Per IP + Turnstile (desktop sources bypass Turnstile) |
| `POST /api/leads/v1/submit-contact` | 3 | 60 min | Per IP + Turnstile |
Other write endpoints (`/api/brief/share-url`, `/api/notification-channels`, `/api/create-checkout`, `/api/customer-portal`, etc.) fall back to the default per-IP limit above.

View File

@@ -995,7 +995,7 @@ const EnterprisePage = () => (
const turnstileWidget = form.querySelector('.cf-turnstile') as HTMLElement | null;
const turnstileToken = turnstileWidget?.dataset.token || '';
try {
const res = await fetch(`${API_BASE}/contact`, {
const res = await fetch(`${API_BASE}/leads/v1/submit-contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1013,7 +1013,7 @@ const EnterprisePage = () => (
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (res.status === 422 && errorEl) {
errorEl.textContent = data.error || t('enterpriseShowcase.workEmailRequired');
errorEl.textContent = data.message || data.error || t('enterpriseShowcase.workEmailRequired');
errorEl.classList.remove('hidden');
btn.textContent = origText;
btn.disabled = false;

View File

@@ -0,0 +1,29 @@
syntax = "proto3";
package worldmonitor.leads.v1;
// RegisterInterestRequest carries a Pro-waitlist signup.
message RegisterInterestRequest {
string email = 1;
string source = 2;
string app_version = 3;
string referred_by = 4;
// Honeypot — bots auto-fill this hidden field; real submissions leave it empty.
string website = 5;
// Cloudflare Turnstile token. Desktop sources bypass Turnstile; see handler.
string turnstile_token = 6;
}
// RegisterInterestResponse mirrors the Convex registerInterest:register return shape.
message RegisterInterestResponse {
// "registered" for a new signup; "already_registered" for a returning email.
string status = 1;
// Stable referral code for this email.
string referral_code = 2;
// Number of signups credited to this email.
int32 referral_count = 3;
// Waitlist position at registration time. Present only when status == "registered".
int32 position = 4;
// True when the email is on the suppression list (prior bounce) and no confirmation was sent.
bool email_suppressed = 5;
}

View File

@@ -0,0 +1,22 @@
syntax = "proto3";
package worldmonitor.leads.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/leads/v1/register_interest.proto";
import "worldmonitor/leads/v1/submit_contact.proto";
// LeadsService handles public-facing lead capture: enterprise contact and Pro-waitlist signups.
service LeadsService {
option (sebuf.http.service_config) = {base_path: "/api/leads/v1"};
// SubmitContact stores an enterprise contact submission in Convex and emails ops.
rpc SubmitContact(SubmitContactRequest) returns (SubmitContactResponse) {
option (sebuf.http.config) = {path: "/submit-contact", method: HTTP_METHOD_POST};
}
// RegisterInterest adds an email to the Pro waitlist and sends a confirmation email.
rpc RegisterInterest(RegisterInterestRequest) returns (RegisterInterestResponse) {
option (sebuf.http.config) = {path: "/register-interest", method: HTTP_METHOD_POST};
}
}

View File

@@ -0,0 +1,25 @@
syntax = "proto3";
package worldmonitor.leads.v1;
// SubmitContactRequest carries an enterprise contact form submission.
message SubmitContactRequest {
string email = 1;
string name = 2;
string organization = 3;
string phone = 4;
string message = 5;
string source = 6;
// Honeypot — bots auto-fill this hidden field; real submissions leave it empty.
string website = 7;
// Cloudflare Turnstile token proving the submitter is human.
string turnstile_token = 8;
}
// SubmitContactResponse reports the outcome of storing the lead and notifying ops.
message SubmitContactResponse {
// Always "sent" on success.
string status = 1;
// True when the Resend notification to ops was delivered.
bool email_sent = 2;
}

View File

@@ -1,4 +1,4 @@
const DISPOSABLE_DOMAINS = new Set([
const DISPOSABLE_DOMAINS = new Set<string>([
'guerrillamail.com', 'guerrillamail.de', 'guerrillamail.net', 'guerrillamail.org',
'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me',
'tempmail.com', 'temp-mail.org', 'temp-mail.io',
@@ -27,23 +27,25 @@ const DISPOSABLE_DOMAINS = new Set([
const OFFENSIVE_RE = /(nigger|faggot|fuckfaggot)/i;
const TYPO_TLDS = new Set(['con', 'coma', 'comhade', 'gmai', 'gmial']);
const TYPO_TLDS = new Set<string>(['con', 'coma', 'comhade', 'gmai', 'gmial']);
async function hasMxRecords(domain) {
export type EmailValidationResult = { valid: true } | { valid: false; reason: string };
async function hasMxRecords(domain: string): Promise<boolean> {
try {
const res = await fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`,
{ headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) }
{ headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) },
);
if (!res.ok) return true;
const data = await res.json();
const data = (await res.json()) as { Answer?: unknown[] };
return Array.isArray(data.Answer) && data.Answer.length > 0;
} catch {
return true;
}
}
export async function validateEmail(email) {
export async function validateEmail(email: string): Promise<EmailValidationResult> {
const normalized = email.trim().toLowerCase();
const atIdx = normalized.indexOf('@');
if (atIdx < 1) return { valid: false, reason: 'Invalid email format' };

View File

@@ -83,6 +83,11 @@ const ENDPOINT_RATE_POLICIES: Record<string, EndpointRatePolicy> = {
// Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
// that budget now that LookupSanctionEntity proxies OpenSanctions live.
'/api/sanctions/v1/lookup-entity': { limit: 30, window: '60 s' },
// Lead capture: preserve the 3/hr and 5/hr budgets from legacy api/contact.js
// and api/register-interest.js. Lower limits than normal IP rate limit since
// these hit Convex + Resend per request.
'/api/leads/v1/submit-contact': { limit: 3, window: '1 h' },
'/api/leads/v1/register-interest': { limit: 5, window: '1 h' },
};
const endpointLimiters = new Map<string, Ratelimit>();

View File

@@ -1,7 +1,6 @@
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export function getClientIp(request) {
// Prefer platform-populated IP headers before falling back to x-forwarded-for.
export function getClientIp(request: Request): string {
return (
request.headers.get('x-real-ip') ||
request.headers.get('cf-connecting-ip') ||
@@ -10,12 +9,21 @@ export function getClientIp(request) {
);
}
export type TurnstileMissingSecretPolicy = 'allow' | 'allow-in-development' | 'deny';
export interface VerifyTurnstileArgs {
token: string;
ip: string;
logPrefix?: string;
missingSecretPolicy?: TurnstileMissingSecretPolicy;
}
export async function verifyTurnstile({
token,
ip,
logPrefix = '[turnstile]',
missingSecretPolicy = 'allow',
}) {
}: VerifyTurnstileArgs): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) {
if (missingSecretPolicy === 'allow') return true;
@@ -33,7 +41,7 @@ export async function verifyTurnstile({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
});
const data = await res.json();
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch {
return false;

View File

@@ -0,0 +1,9 @@
import type { LeadsServiceHandler } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
import { registerInterest } from './register-interest';
import { submitContact } from './submit-contact';
export const leadsHandler: LeadsServiceHandler = {
submitContact,
registerInterest,
};

View File

@@ -1,24 +1,36 @@
export const config = { runtime: 'edge' };
/**
* RPC: registerInterest -- Adds an email to the Pro waitlist and emails a confirmation.
* Port from api/register-interest.js
* Sources: Convex registerInterest:register mutation + Resend confirmation email
*/
import { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getClientIp, verifyTurnstile } from './_turnstile.js';
import { jsonResponse } from './_json-response.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
import { validateEmail } from './_email-validation.js';
import type {
ServerContext,
RegisterInterestRequest,
RegisterInterestResponse,
} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile';
import { validateEmail } from '../../../_shared/email-validation';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_EMAIL_LENGTH = 320;
const MAX_META_LENGTH = 100;
const RATE_LIMIT = 5;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const DESKTOP_SOURCES = new Set<string>(['desktop-settings']);
const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS });
interface ConvexRegisterResult {
status: 'registered' | 'already_registered';
referralCode: string;
referralCount: number;
position?: number;
emailSuppressed?: boolean;
}
async function sendConfirmationEmail(email, referralCode) {
async function sendConfirmationEmail(email: string, referralCode: string): Promise<void> {
const referralLink = `https://worldmonitor.app/pro?ref=${referralCode}`;
const shareText = encodeURIComponent('I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:');
const shareText = encodeURIComponent("I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:");
const shareUrl = encodeURIComponent(referralLink);
const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`;
const linkedinShare = `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`;
@@ -40,7 +52,7 @@ async function sendConfirmationEmail(email, referralCode) {
body: JSON.stringify({
from: 'World Monitor <noreply@worldmonitor.app>',
to: [email],
subject: 'You\u2019re on the World Monitor Pro waitlist',
subject: "You\u2019re on the World Monitor Pro waitlist",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #e0e0e0;">
<div style="background: #4ade80; height: 4px;"></div>
@@ -168,105 +180,71 @@ async function sendConfirmationEmail(email, referralCode) {
}
}
export default async function handler(req) {
if (isDisallowedOrigin(req)) {
return jsonResponse({ error: 'Origin not allowed' }, 403);
export async function registerInterest(
ctx: ServerContext,
req: RegisterInterestRequest,
): Promise<RegisterInterestResponse> {
// Honeypot — silently accept but do nothing.
if (req.website) {
return { status: 'registered', referralCode: '', referralCount: 0, position: 0, emailSuppressed: false };
}
const cors = getCorsHeaders(req, 'POST, OPTIONS');
const ip = getClientIp(ctx.request);
const isDesktopSource = typeof req.source === 'string' && DESKTOP_SOURCES.has(req.source);
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
}
const ip = getClientIp(req);
if (rateLimiter.isRateLimited(ip)) {
return jsonResponse({ error: 'Too many requests' }, 429, cors);
}
let body;
try {
body = await req.json();
} catch {
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
}
// Honeypot — bots auto-fill this hidden field; real users leave it empty
if (body.website) {
return jsonResponse({ status: 'registered' }, 200, cors);
}
// Cloudflare Turnstile verification — skip for desktop app (no browser captcha available).
// Desktop bypasses captcha, so enforce stricter rate limit (2/hr vs 5/hr).
const DESKTOP_SOURCES = new Set(['desktop-settings']);
const isDesktopSource = typeof body.source === 'string' && DESKTOP_SOURCES.has(body.source);
if (isDesktopSource) {
const entry = rateLimiter.getEntry(ip);
if (entry && entry.count > 2) {
return jsonResponse({ error: 'Rate limit exceeded' }, 429, cors);
}
} else {
// Desktop sources bypass Turnstile (no browser captcha). Gateway-level per-IP
// rate limit (5/h) already throttles abuse for both cases.
if (!isDesktopSource) {
const turnstileOk = await verifyTurnstile({
token: body.turnstileToken || '',
token: req.turnstileToken || '',
ip,
logPrefix: '[register-interest]',
});
if (!turnstileOk) {
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
throw new ApiError(403, 'Bot verification failed', '');
}
}
const { email, source, appVersion, referredBy } = body;
if (!email || typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
return jsonResponse({ error: 'Invalid email address' }, 400, cors);
const { email, source, appVersion, referredBy } = req;
if (!email || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
throw new ValidationError([{ field: 'email', description: 'Invalid email address' }]);
}
const emailCheck = await validateEmail(email);
if (!emailCheck.valid) {
return jsonResponse({ error: emailCheck.reason }, 400, cors);
throw new ValidationError([{ field: 'email', description: emailCheck.reason }]);
}
const safeSource = typeof source === 'string'
? source.slice(0, MAX_META_LENGTH)
: 'unknown';
const safeAppVersion = typeof appVersion === 'string'
? appVersion.slice(0, MAX_META_LENGTH)
: 'unknown';
const safeReferredBy = typeof referredBy === 'string'
? referredBy.slice(0, 20)
: undefined;
const safeSource = source ? source.slice(0, MAX_META_LENGTH) : 'unknown';
const safeAppVersion = appVersion ? appVersion.slice(0, MAX_META_LENGTH) : 'unknown';
const safeReferredBy = referredBy ? referredBy.slice(0, 20) : undefined;
const convexUrl = process.env.CONVEX_URL;
if (!convexUrl) {
return jsonResponse({ error: 'Registration service unavailable' }, 503, cors);
throw new ApiError(503, 'Registration service unavailable', '');
}
try {
const client = new ConvexHttpClient(convexUrl);
const result = await client.mutation('registerInterest:register', {
email,
source: safeSource,
appVersion: safeAppVersion,
referredBy: safeReferredBy,
});
const client = new ConvexHttpClient(convexUrl);
const result = (await client.mutation('registerInterest:register' as any, {
email,
source: safeSource,
appVersion: safeAppVersion,
referredBy: safeReferredBy,
})) as ConvexRegisterResult;
// Send confirmation email for new registrations (awaited to avoid Edge isolate termination)
// Skip if email is on the suppression list (previously bounced)
if (result.status === 'registered' && result.referralCode) {
if (!result.emailSuppressed) {
await sendConfirmationEmail(email, result.referralCode);
} else {
console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
}
if (result.status === 'registered' && result.referralCode) {
if (!result.emailSuppressed) {
await sendConfirmationEmail(email, result.referralCode);
} else {
console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
}
return jsonResponse(result, 200, cors);
} catch (err) {
console.error('[register-interest] Convex error:', err);
return jsonResponse({ error: 'Registration failed' }, 500, cors);
}
return {
status: result.status,
referralCode: result.referralCode,
referralCount: result.referralCount,
position: result.position ?? 0,
emailSuppressed: result.emailSuppressed ?? false,
};
}

View File

@@ -1,17 +1,24 @@
export const config = { runtime: 'edge' };
/**
* RPC: submitContact -- Stores an enterprise contact submission and emails ops.
* Port from api/contact.js
* Sources: Convex contactMessages:submit mutation + Resend notification email
*/
import { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getClientIp, verifyTurnstile } from './_turnstile.js';
import { jsonResponse } from './_json-response.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
import type {
ServerContext,
SubmitContactRequest,
SubmitContactResponse,
} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
const MAX_FIELD = 500;
const MAX_MESSAGE = 2000;
const FREE_EMAIL_DOMAINS = new Set([
const FREE_EMAIL_DOMAINS = new Set<string>([
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp',
'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr',
'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
@@ -24,12 +31,27 @@ const FREE_EMAIL_DOMAINS = new Set([
't-online.de', 'libero.it', 'virgilio.it',
]);
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 60 * 60 * 1000;
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS });
function sanitizeForSubject(str: string, maxLen = 50): string {
return str.replace(/[\r\n\0]/g, '').slice(0, maxLen);
}
async function sendNotificationEmail(name, email, organization, phone, message, ip, country) {
async function sendNotificationEmail(
name: string,
email: string,
organization: string,
phone: string,
message: string | undefined,
ip: string,
country: string | null,
): Promise<boolean> {
const resendKey = process.env.RESEND_API_KEY;
if (!resendKey) {
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent');
@@ -77,109 +99,80 @@ async function sendNotificationEmail(name, email, organization, phone, message,
}
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sanitizeForSubject(str, maxLen = 50) {
return str.replace(/[\r\n\0]/g, '').slice(0, maxLen);
}
export default async function handler(req) {
if (isDisallowedOrigin(req)) {
return jsonResponse({ error: 'Origin not allowed' }, 403);
export async function submitContact(
ctx: ServerContext,
req: SubmitContactRequest,
): Promise<SubmitContactResponse> {
// Honeypot — silently accept but do nothing (bots auto-fill hidden field).
if (req.website) {
return { status: 'sent', emailSent: false };
}
const cors = getCorsHeaders(req, 'POST, OPTIONS');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
}
const ip = getClientIp(req);
const country = req.headers.get('cf-ipcountry') || req.headers.get('x-vercel-ip-country') || null;
if (rateLimiter.isRateLimited(ip)) {
return jsonResponse({ error: 'Too many requests' }, 429, cors);
}
let body;
try {
body = await req.json();
} catch {
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
}
if (body.website) {
return jsonResponse({ status: 'sent' }, 200, cors);
}
const ip = getClientIp(ctx.request);
const country = ctx.request.headers.get('cf-ipcountry')
|| ctx.request.headers.get('x-vercel-ip-country');
const turnstileOk = await verifyTurnstile({
token: body.turnstileToken || '',
token: req.turnstileToken || '',
ip,
logPrefix: '[contact]',
missingSecretPolicy: 'allow-in-development',
});
if (!turnstileOk) {
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
throw new ApiError(403, 'Bot verification failed', '');
}
const { email, name, organization, phone, message, source } = body;
const { email, name, organization, phone, message, source } = req;
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
return jsonResponse({ error: 'Invalid email' }, 400, cors);
if (!email || !EMAIL_RE.test(email)) {
throw new ValidationError([{ field: 'email', description: 'Invalid email' }]);
}
const emailDomain = email.split('@')[1]?.toLowerCase();
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
return jsonResponse({ error: 'Please use your work email address' }, 422, cors);
throw new ApiError(422, 'Please use your work email address', '');
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return jsonResponse({ error: 'Name is required' }, 400, cors);
if (!name || name.trim().length === 0) {
throw new ValidationError([{ field: 'name', description: 'Name is required' }]);
}
if (!organization || typeof organization !== 'string' || organization.trim().length === 0) {
return jsonResponse({ error: 'Company is required' }, 400, cors);
if (!organization || organization.trim().length === 0) {
throw new ValidationError([{ field: 'organization', description: 'Company is required' }]);
}
if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) {
return jsonResponse({ error: 'Valid phone number is required' }, 400, cors);
if (!phone || !PHONE_RE.test(phone.trim())) {
throw new ValidationError([{ field: 'phone', description: 'Valid phone number is required' }]);
}
const safeName = name.slice(0, MAX_FIELD);
const safeOrg = organization.slice(0, MAX_FIELD);
const safePhone = phone.trim().slice(0, 30);
const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined;
const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact';
const safeMsg = message ? message.slice(0, MAX_MESSAGE) : undefined;
const safeSource = source ? source.slice(0, 100) : 'enterprise-contact';
const convexUrl = process.env.CONVEX_URL;
if (!convexUrl) {
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
throw new ApiError(503, 'Service unavailable', '');
}
try {
const client = new ConvexHttpClient(convexUrl);
await client.mutation('contactMessages:submit', {
name: safeName,
email: email.trim(),
organization: safeOrg,
phone: safePhone,
message: safeMsg,
source: safeSource,
});
const client = new ConvexHttpClient(convexUrl);
await client.mutation('contactMessages:submit' as any, {
name: safeName,
email: email.trim(),
organization: safeOrg,
phone: safePhone,
message: safeMsg,
source: safeSource,
});
const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg, ip, country);
const emailSent = await sendNotificationEmail(
safeName,
email.trim(),
safeOrg,
safePhone,
safeMsg,
ip,
country,
);
return jsonResponse({ status: 'sent', emailSent }, 200, cors);
} catch (err) {
console.error('[contact] error:', err);
return jsonResponse({ error: 'Failed to send message' }, 500, cors);
}
return { status: 'sent', emailSent };
}

View File

@@ -1212,10 +1212,14 @@ async function dispatch(requestUrl, req, routes, context) {
}
// Registration — call Convex directly when CONVEX_URL is available (self-hosted),
// otherwise proxy to cloud (desktop sidecar never has CONVEX_URL).
// Keeps the legacy /api/register-interest local path so older desktop builds
// continue to work; cloud fallback rewrites to the new sebuf RPC path.
if (requestUrl.pathname === '/api/register-interest' && req.method === 'POST') {
const convexUrl = process.env.CONVEX_URL;
if (!convexUrl) {
const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'no CONVEX_URL');
const cloudUrl = new URL(requestUrl);
cloudUrl.pathname = '/api/leads/v1/register-interest';
const cloudResponse = await tryCloudFallback(cloudUrl, req, context, 'no CONVEX_URL');
if (cloudResponse) return cloudResponse;
return json({ error: 'Registration service unavailable' }, 503);
}

View File

@@ -0,0 +1,149 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/leads/v1/service.proto
export interface SubmitContactRequest {
email: string;
name: string;
organization: string;
phone: string;
message: string;
source: string;
website: string;
turnstileToken: string;
}
export interface SubmitContactResponse {
status: string;
emailSent: boolean;
}
export interface RegisterInterestRequest {
email: string;
source: string;
appVersion: string;
referredBy: string;
website: string;
turnstileToken: string;
}
export interface RegisterInterestResponse {
status: string;
referralCode: string;
referralCount: number;
position: number;
emailSuppressed: boolean;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface LeadsServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface LeadsServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class LeadsServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: LeadsServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async submitContact(req: SubmitContactRequest, options?: LeadsServiceCallOptions): Promise<SubmitContactResponse> {
let path = "/api/leads/v1/submit-contact";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as SubmitContactResponse;
}
async registerInterest(req: RegisterInterestRequest, options?: LeadsServiceCallOptions): Promise<RegisterInterestResponse> {
let path = "/api/leads/v1/register-interest";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as RegisterInterestResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -0,0 +1,180 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/leads/v1/service.proto
export interface SubmitContactRequest {
email: string;
name: string;
organization: string;
phone: string;
message: string;
source: string;
website: string;
turnstileToken: string;
}
export interface SubmitContactResponse {
status: string;
emailSent: boolean;
}
export interface RegisterInterestRequest {
email: string;
source: string;
appVersion: string;
referredBy: string;
website: string;
turnstileToken: string;
}
export interface RegisterInterestResponse {
status: string;
referralCode: string;
referralCount: number;
position: number;
emailSuppressed: boolean;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface LeadsServiceHandler {
submitContact(ctx: ServerContext, req: SubmitContactRequest): Promise<SubmitContactResponse>;
registerInterest(ctx: ServerContext, req: RegisterInterestRequest): Promise<RegisterInterestResponse>;
}
export function createLeadsServiceRoutes(
handler: LeadsServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
{
method: "POST",
path: "/api/leads/v1/submit-contact",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as SubmitContactRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("submitContact", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.submitContact(ctx, body);
return new Response(JSON.stringify(result as SubmitContactResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
{
method: "POST",
path: "/api/leads/v1/register-interest",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as RegisterInterestRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("registerInterest", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.registerInterest(ctx, body);
return new Response(JSON.stringify(result as RegisterInterestResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -546,7 +546,10 @@ function isLocalOnlyApiTarget(target: string): boolean {
}
function isKeyFreeApiTarget(target: string): boolean {
return target.startsWith('/api/register-interest') || target.startsWith('/api/version');
return target.startsWith('/api/register-interest')
|| target.startsWith('/api/leads/v1/register-interest')
|| target.startsWith('/api/leads/v1/submit-contact')
|| target.startsWith('/api/version');
}
async function fetchLocalWithStartupRetry(

View File

@@ -1,192 +1,187 @@
/**
* Functional tests for LeadsService.SubmitContact handler.
* Tests the typed handler directly (not the HTTP gateway).
*/
import { strict as assert } from 'node:assert';
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import { describe, it, beforeEach, afterEach } from 'node:test';
const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };
function makeRequest(body, opts = {}) {
return new Request('https://worldmonitor.app/api/contact', {
method: opts.method || 'POST',
headers: {
'Content-Type': 'application/json',
'origin': 'https://worldmonitor.app',
...(opts.headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
function makeCtx(headers = {}) {
const req = new Request('https://worldmonitor.app/api/leads/v1/submit-contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
});
return { request: req, pathParams: {}, headers };
}
function validBody(overrides = {}) {
function validReq(overrides = {}) {
return {
name: 'Test User',
email: 'test@example.com',
name: 'Test User',
organization: 'TestCorp',
phone: '+1 555 123 4567',
message: 'Hello',
source: 'enterprise-contact',
website: '',
turnstileToken: 'valid-token',
...overrides,
};
}
let handler;
let submitContact;
let ValidationError;
let ApiError;
describe('api/contact', () => {
describe('LeadsService.submitContact', () => {
beforeEach(async () => {
process.env.CONVEX_URL = 'https://fake-convex.cloud';
process.env.TURNSTILE_SECRET_KEY = 'test-secret';
process.env.RESEND_API_KEY = 'test-resend-key';
process.env.VERCEL_ENV = 'production';
// Re-import to get fresh module state (rate limiter)
const mod = await import(`../api/contact.js?t=${Date.now()}`);
handler = mod.default;
// Handler + error classes share one module instance so `instanceof` works.
const mod = await import('../server/worldmonitor/leads/v1/submit-contact.ts');
submitContact = mod.submitContact;
const gen = await import('../src/generated/server/worldmonitor/leads/v1/service_server.ts');
ValidationError = gen.ValidationError;
ApiError = gen.ApiError;
});
afterEach(() => {
globalThis.fetch = originalFetch;
Object.keys(process.env).forEach(k => {
Object.keys(process.env).forEach((k) => {
if (!(k in originalEnv)) delete process.env[k];
});
Object.assign(process.env, originalEnv);
});
describe('validation', () => {
it('rejects GET requests', async () => {
const res = await handler(new Request('https://worldmonitor.app/api/contact', {
method: 'GET',
headers: { origin: 'https://worldmonitor.app' },
}));
assert.equal(res.status, 405);
});
it('rejects missing email', async () => {
it('rejects missing email with ValidationError', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ email: '' })));
assert.equal(res.status, 400);
const data = await res.json();
assert.match(data.error, /email/i);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ email: '' })),
(err) => err instanceof ValidationError && err.violations[0].field === 'email',
);
});
it('rejects invalid email format', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ email: 'not-an-email' })));
assert.equal(res.status, 400);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ email: 'not-an-email' })),
(err) => err instanceof ValidationError,
);
});
it('rejects missing name', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ name: '' })));
assert.equal(res.status, 400);
const data = await res.json();
assert.match(data.error, /name/i);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ name: '' })),
(err) => err instanceof ValidationError && err.violations[0].field === 'name',
);
});
it('rejects free email domains with 422', async () => {
it('rejects free email domains with 422 ApiError', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ email: 'test@gmail.com' })));
assert.equal(res.status, 422);
const data = await res.json();
assert.match(data.error, /work email/i);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ email: 'test@gmail.com' })),
(err) => err instanceof ApiError && err.statusCode === 422 && /work email/i.test(err.message),
);
});
it('rejects missing organization', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ organization: '' })));
assert.equal(res.status, 400);
const data = await res.json();
assert.match(data.error, /company/i);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ organization: '' })),
(err) => err instanceof ValidationError && err.violations[0].field === 'organization',
);
});
it('rejects missing phone', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ phone: '' })));
assert.equal(res.status, 400);
const data = await res.json();
assert.match(data.error, /phone/i);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ phone: '' })),
(err) => err instanceof ValidationError && err.violations[0].field === 'phone',
);
});
it('rejects invalid phone format', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ phone: '(((((' })));
assert.equal(res.status, 400);
await assert.rejects(
() => submitContact(makeCtx(), validReq({ phone: '(((((' })),
(err) => err instanceof ValidationError,
);
});
it('rejects disallowed origins', async () => {
const req = new Request('https://worldmonitor.app/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', origin: 'https://evil.com' },
body: JSON.stringify(validBody()),
});
const res = await handler(req);
assert.equal(res.status, 403);
});
it('silently accepts honeypot submissions', async () => {
const res = await handler(makeRequest(validBody({ website: 'http://spam.com' })));
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.status, 'sent');
it('silently accepts honeypot submissions without calling upstreams', async () => {
let fetchCalled = false;
globalThis.fetch = async () => { fetchCalled = true; return new Response('{}'); };
const res = await submitContact(makeCtx(), validReq({ website: 'http://spam.com' }));
assert.equal(res.status, 'sent');
assert.equal(res.emailSent, false);
assert.equal(fetchCalled, false);
});
});
describe('Turnstile handling', () => {
it('rejects when Turnstile verification fails', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) {
if (typeof url === 'string' && url.includes('turnstile')) {
return new Response(JSON.stringify({ success: false }));
}
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 403);
const data = await res.json();
assert.match(data.error, /bot/i);
await assert.rejects(
() => submitContact(makeCtx(), validReq()),
(err) => err instanceof ApiError && err.statusCode === 403 && /bot/i.test(err.message),
);
});
it('rejects in production when TURNSTILE_SECRET_KEY is unset', async () => {
delete process.env.TURNSTILE_SECRET_KEY;
process.env.VERCEL_ENV = 'production';
globalThis.fetch = async () => new Response('{}');
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 403);
await assert.rejects(
() => submitContact(makeCtx(), validReq()),
(err) => err instanceof ApiError && err.statusCode === 403,
);
});
it('allows in development when TURNSTILE_SECRET_KEY is unset', async () => {
delete process.env.TURNSTILE_SECRET_KEY;
process.env.VERCEL_ENV = 'development';
let convexCalled = false;
globalThis.fetch = async (url, _opts) => {
if (url.includes('fake-convex')) {
convexCalled = true;
globalThis.fetch = async (url) => {
if (typeof url === 'string' && url.includes('fake-convex')) {
return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
}
if (url.includes('resend')) return new Response(JSON.stringify({ id: '1' }));
if (typeof url === 'string' && url.includes('resend')) return new Response(JSON.stringify({ id: '1' }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 200);
const res = await submitContact(makeCtx(), validReq());
assert.equal(res.status, 'sent');
});
});
@@ -194,79 +189,72 @@ describe('api/contact', () => {
it('returns emailSent: false when RESEND_API_KEY is missing', async () => {
delete process.env.RESEND_API_KEY;
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.status, 'sent');
assert.equal(data.emailSent, false);
const res = await submitContact(makeCtx(), validReq());
assert.equal(res.status, 'sent');
assert.equal(res.emailSent, false);
});
it('returns emailSent: false when Resend API returns error', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (url.includes('resend')) return new Response('Rate limited', { status: 429 });
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (typeof url === 'string' && url.includes('resend')) return new Response('Rate limited', { status: 429 });
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.status, 'sent');
assert.equal(data.emailSent, false);
const res = await submitContact(makeCtx(), validReq());
assert.equal(res.status, 'sent');
assert.equal(res.emailSent, false);
});
it('returns emailSent: true on successful notification', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (url.includes('resend')) return new Response(JSON.stringify({ id: 'msg_123' }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (typeof url === 'string' && url.includes('resend')) return new Response(JSON.stringify({ id: 'msg_123' }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.status, 'sent');
assert.equal(data.emailSent, true);
const res = await submitContact(makeCtx(), validReq());
assert.equal(res.status, 'sent');
assert.equal(res.emailSent, true);
});
it('still succeeds (stores in Convex) even when email fails', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (url.includes('resend')) throw new Error('Network failure');
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
if (typeof url === 'string' && url.includes('resend')) throw new Error('Network failure');
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.status, 'sent');
assert.equal(data.emailSent, false);
const res = await submitContact(makeCtx(), validReq());
assert.equal(res.status, 'sent');
assert.equal(res.emailSent, false);
});
});
describe('Convex storage', () => {
it('returns 503 when CONVEX_URL is missing', async () => {
it('throws 503 ApiError when CONVEX_URL is missing', async () => {
delete process.env.CONVEX_URL;
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 503);
await assert.rejects(
() => submitContact(makeCtx(), validReq()),
(err) => err instanceof ApiError && err.statusCode === 503,
);
});
it('returns 500 when Convex mutation fails', async () => {
it('propagates Convex failure', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (url.includes('fake-convex')) return new Response('Internal error', { status: 500 });
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
if (typeof url === 'string' && url.includes('fake-convex')) return new Response('Internal error', { status: 500 });
return new Response('{}');
};
const res = await handler(makeRequest(validBody()));
assert.equal(res.status, 500);
await assert.rejects(() => submitContact(makeCtx(), validReq()));
});
});
});

View File

@@ -14,7 +14,7 @@ function mockFetch(mxResponse) {
}
// Import after fetch is available (module is Edge-compatible, no node: imports)
const { validateEmail } = await import('../api/_email-validation.js');
const { validateEmail } = await import('../server/_shared/email-validation.ts');
describe('validateEmail', () => {
beforeEach(() => {

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getClientIp, verifyTurnstile } from './_turnstile.js';
import { getClientIp, verifyTurnstile } from '../server/_shared/turnstile.ts';
const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };

View File

@@ -195,6 +195,7 @@ function sebufApiPlugin(): Plugin {
supplyChainServerMod, supplyChainHandlerMod,
naturalServerMod, naturalHandlerMod,
resilienceServerMod, resilienceHandlerMod,
leadsServerMod, leadsHandlerMod,
] = await Promise.all([
import('./server/router'),
import('./server/cors'),
@@ -245,6 +246,8 @@ function sebufApiPlugin(): Plugin {
import('./server/worldmonitor/natural/v1/handler'),
import('./src/generated/server/worldmonitor/resilience/v1/service_server'),
import('./server/worldmonitor/resilience/v1/handler'),
import('./src/generated/server/worldmonitor/leads/v1/service_server'),
import('./server/worldmonitor/leads/v1/handler'),
]);
const serverOptions = { onError: errorMod.mapErrorToResponse };
@@ -272,6 +275,7 @@ function sebufApiPlugin(): Plugin {
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions),
...leadsServerMod.createLeadsServiceRoutes(leadsHandlerMod.leadsHandler, serverOptions),
];
cachedCorsMod = corsMod;
return routerMod.createRouter(allRoutes);