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" "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", "path": "api/eia/[[...path]].js",
"category": "migration-pending", "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 ## 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`). 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` | 10 | 60 s | Per user |
| `POST /api/scenario/v1/run` (queue depth) | 100 in-flight | — | Global | | `POST /api/scenario/v1/run` (queue depth) | 100 in-flight | — | Global |
| `POST /api/register-interest` | 5 | 60 min | Per IP + Turnstile | | `POST /api/leads/v1/register-interest` | 5 | 60 min | Per IP + Turnstile (desktop sources bypass Turnstile) |
| `POST /api/contact` | 3 | 60 min | Per IP + 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. 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 turnstileWidget = form.querySelector('.cf-turnstile') as HTMLElement | null;
const turnstileToken = turnstileWidget?.dataset.token || ''; const turnstileToken = turnstileWidget?.dataset.token || '';
try { try {
const res = await fetch(`${API_BASE}/contact`, { const res = await fetch(`${API_BASE}/leads/v1/submit-contact`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -1013,7 +1013,7 @@ const EnterprisePage = () => (
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (res.status === 422 && errorEl) { 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'); errorEl.classList.remove('hidden');
btn.textContent = origText; btn.textContent = origText;
btn.disabled = false; 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', 'guerrillamail.com', 'guerrillamail.de', 'guerrillamail.net', 'guerrillamail.org',
'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me', 'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me',
'tempmail.com', 'temp-mail.org', 'temp-mail.io', '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 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 { try {
const res = await fetch( const res = await fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`, `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; 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; return Array.isArray(data.Answer) && data.Answer.length > 0;
} catch { } catch {
return true; return true;
} }
} }
export async function validateEmail(email) { export async function validateEmail(email: string): Promise<EmailValidationResult> {
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
const atIdx = normalized.indexOf('@'); const atIdx = normalized.indexOf('@');
if (atIdx < 1) return { valid: false, reason: 'Invalid email format' }; 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 // Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
// that budget now that LookupSanctionEntity proxies OpenSanctions live. // that budget now that LookupSanctionEntity proxies OpenSanctions live.
'/api/sanctions/v1/lookup-entity': { limit: 30, window: '60 s' }, '/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>(); const endpointLimiters = new Map<string, Ratelimit>();

View File

@@ -1,7 +1,6 @@
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export function getClientIp(request) { export function getClientIp(request: Request): string {
// Prefer platform-populated IP headers before falling back to x-forwarded-for.
return ( return (
request.headers.get('x-real-ip') || request.headers.get('x-real-ip') ||
request.headers.get('cf-connecting-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({ export async function verifyTurnstile({
token, token,
ip, ip,
logPrefix = '[turnstile]', logPrefix = '[turnstile]',
missingSecretPolicy = 'allow', missingSecretPolicy = 'allow',
}) { }: VerifyTurnstileArgs): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY; const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) { if (!secret) {
if (missingSecretPolicy === 'allow') return true; if (missingSecretPolicy === 'allow') return true;
@@ -33,7 +41,7 @@ export async function verifyTurnstile({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret, response: token, remoteip: ip }), 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; return data.success === true;
} catch { } catch {
return false; 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 { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import type {
import { getClientIp, verifyTurnstile } from './_turnstile.js'; ServerContext,
import { jsonResponse } from './_json-response.js'; RegisterInterestRequest,
import { createIpRateLimiter } from './_ip-rate-limit.js'; RegisterInterestResponse,
import { validateEmail } from './_email-validation.js'; } 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 EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_EMAIL_LENGTH = 320; const MAX_EMAIL_LENGTH = 320;
const MAX_META_LENGTH = 100; const MAX_META_LENGTH = 100;
const RATE_LIMIT = 5; const DESKTOP_SOURCES = new Set<string>(['desktop-settings']);
const RATE_WINDOW_MS = 60 * 60 * 1000;
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 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 shareUrl = encodeURIComponent(referralLink);
const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`; const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`;
const linkedinShare = `https://www.linkedin.com/sharing/share-offsite/?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({ body: JSON.stringify({
from: 'World Monitor <noreply@worldmonitor.app>', from: 'World Monitor <noreply@worldmonitor.app>',
to: [email], to: [email],
subject: 'You\u2019re on the World Monitor Pro waitlist', subject: "You\u2019re on the World Monitor Pro waitlist",
html: ` html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #e0e0e0;"> <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> <div style="background: #4ade80; height: 4px;"></div>
@@ -168,105 +180,71 @@ async function sendConfirmationEmail(email, referralCode) {
} }
} }
export default async function handler(req) { export async function registerInterest(
if (isDisallowedOrigin(req)) { ctx: ServerContext,
return jsonResponse({ error: 'Origin not allowed' }, 403); 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') { // Desktop sources bypass Turnstile (no browser captcha). Gateway-level per-IP
return new Response(null, { status: 204, headers: cors }); // rate limit (5/h) already throttles abuse for both cases.
} if (!isDesktopSource) {
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 {
const turnstileOk = await verifyTurnstile({ const turnstileOk = await verifyTurnstile({
token: body.turnstileToken || '', token: req.turnstileToken || '',
ip, ip,
logPrefix: '[register-interest]', logPrefix: '[register-interest]',
}); });
if (!turnstileOk) { if (!turnstileOk) {
return jsonResponse({ error: 'Bot verification failed' }, 403, cors); throw new ApiError(403, 'Bot verification failed', '');
} }
} }
const { email, source, appVersion, referredBy } = body; const { email, source, appVersion, referredBy } = req;
if (!email || typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) { if (!email || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
return jsonResponse({ error: 'Invalid email address' }, 400, cors); throw new ValidationError([{ field: 'email', description: 'Invalid email address' }]);
} }
const emailCheck = await validateEmail(email); const emailCheck = await validateEmail(email);
if (!emailCheck.valid) { if (!emailCheck.valid) {
return jsonResponse({ error: emailCheck.reason }, 400, cors); throw new ValidationError([{ field: 'email', description: emailCheck.reason }]);
} }
const safeSource = typeof source === 'string' const safeSource = source ? source.slice(0, MAX_META_LENGTH) : 'unknown';
? source.slice(0, MAX_META_LENGTH) const safeAppVersion = appVersion ? appVersion.slice(0, MAX_META_LENGTH) : 'unknown';
: 'unknown'; const safeReferredBy = referredBy ? referredBy.slice(0, 20) : undefined;
const safeAppVersion = typeof appVersion === 'string'
? appVersion.slice(0, MAX_META_LENGTH)
: 'unknown';
const safeReferredBy = typeof referredBy === 'string'
? referredBy.slice(0, 20)
: undefined;
const convexUrl = process.env.CONVEX_URL; const convexUrl = process.env.CONVEX_URL;
if (!convexUrl) { if (!convexUrl) {
return jsonResponse({ error: 'Registration service unavailable' }, 503, cors); throw new ApiError(503, 'Registration service unavailable', '');
} }
try { const client = new ConvexHttpClient(convexUrl);
const client = new ConvexHttpClient(convexUrl); const result = (await client.mutation('registerInterest:register' as any, {
const result = await client.mutation('registerInterest:register', { email,
email, source: safeSource,
source: safeSource, appVersion: safeAppVersion,
appVersion: safeAppVersion, referredBy: safeReferredBy,
referredBy: safeReferredBy, })) as ConvexRegisterResult;
});
// Send confirmation email for new registrations (awaited to avoid Edge isolate termination) if (result.status === 'registered' && result.referralCode) {
// Skip if email is on the suppression list (previously bounced) if (!result.emailSuppressed) {
if (result.status === 'registered' && result.referralCode) { await sendConfirmationEmail(email, result.referralCode);
if (!result.emailSuppressed) { } else {
await sendConfirmationEmail(email, result.referralCode); console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
} 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 { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import type {
import { getClientIp, verifyTurnstile } from './_turnstile.js'; ServerContext,
import { jsonResponse } from './_json-response.js'; SubmitContactRequest,
import { createIpRateLimiter } from './_ip-rate-limit.js'; 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 EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/; const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
const MAX_FIELD = 500; const MAX_FIELD = 500;
const MAX_MESSAGE = 2000; 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', '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', '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', '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', 't-online.de', 'libero.it', 'virgilio.it',
]); ]);
const RATE_LIMIT = 3; function escapeHtml(str: string): string {
const RATE_WINDOW_MS = 60 * 60 * 1000; 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; const resendKey = process.env.RESEND_API_KEY;
if (!resendKey) { if (!resendKey) {
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent'); 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) { export async function submitContact(
return str ctx: ServerContext,
.replace(/&/g, '&amp;') req: SubmitContactRequest,
.replace(/</g, '&lt;') ): Promise<SubmitContactResponse> {
.replace(/>/g, '&gt;') // Honeypot — silently accept but do nothing (bots auto-fill hidden field).
.replace(/"/g, '&quot;'); if (req.website) {
} return { status: 'sent', emailSent: false };
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);
} }
const cors = getCorsHeaders(req, 'POST, OPTIONS'); const ip = getClientIp(ctx.request);
const country = ctx.request.headers.get('cf-ipcountry')
if (req.method === 'OPTIONS') { || ctx.request.headers.get('x-vercel-ip-country');
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 turnstileOk = await verifyTurnstile({ const turnstileOk = await verifyTurnstile({
token: body.turnstileToken || '', token: req.turnstileToken || '',
ip, ip,
logPrefix: '[contact]', logPrefix: '[contact]',
missingSecretPolicy: 'allow-in-development', missingSecretPolicy: 'allow-in-development',
}); });
if (!turnstileOk) { 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)) { if (!email || !EMAIL_RE.test(email)) {
return jsonResponse({ error: 'Invalid email' }, 400, cors); throw new ValidationError([{ field: 'email', description: 'Invalid email' }]);
} }
const emailDomain = email.split('@')[1]?.toLowerCase(); const emailDomain = email.split('@')[1]?.toLowerCase();
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) { 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) { if (!name || name.trim().length === 0) {
return jsonResponse({ error: 'Name is required' }, 400, cors); throw new ValidationError([{ field: 'name', description: 'Name is required' }]);
} }
if (!organization || typeof organization !== 'string' || organization.trim().length === 0) { if (!organization || organization.trim().length === 0) {
return jsonResponse({ error: 'Company is required' }, 400, cors); throw new ValidationError([{ field: 'organization', description: 'Company is required' }]);
} }
if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) { if (!phone || !PHONE_RE.test(phone.trim())) {
return jsonResponse({ error: 'Valid phone number is required' }, 400, cors); throw new ValidationError([{ field: 'phone', description: 'Valid phone number is required' }]);
} }
const safeName = name.slice(0, MAX_FIELD); const safeName = name.slice(0, MAX_FIELD);
const safeOrg = organization.slice(0, MAX_FIELD); const safeOrg = organization.slice(0, MAX_FIELD);
const safePhone = phone.trim().slice(0, 30); const safePhone = phone.trim().slice(0, 30);
const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined; const safeMsg = message ? message.slice(0, MAX_MESSAGE) : undefined;
const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact'; const safeSource = source ? source.slice(0, 100) : 'enterprise-contact';
const convexUrl = process.env.CONVEX_URL; const convexUrl = process.env.CONVEX_URL;
if (!convexUrl) { if (!convexUrl) {
return jsonResponse({ error: 'Service unavailable' }, 503, cors); throw new ApiError(503, 'Service unavailable', '');
} }
try { const client = new ConvexHttpClient(convexUrl);
const client = new ConvexHttpClient(convexUrl); await client.mutation('contactMessages:submit' as any, {
await client.mutation('contactMessages:submit', { name: safeName,
name: safeName, email: email.trim(),
email: email.trim(), organization: safeOrg,
organization: safeOrg, phone: safePhone,
phone: safePhone, message: safeMsg,
message: safeMsg, source: safeSource,
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); return { status: 'sent', emailSent };
} catch (err) {
console.error('[contact] error:', err);
return jsonResponse({ error: 'Failed to send message' }, 500, cors);
}
} }

View File

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

View File

@@ -14,7 +14,7 @@ function mockFetch(mxResponse) {
} }
// Import after fetch is available (module is Edge-compatible, no node: imports) // 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', () => { describe('validateEmail', () => {
beforeEach(() => { beforeEach(() => {

View File

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

View File

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