From 9ccd309dbcb866a5626c97c9db0d5025829b8c16 Mon Sep 17 00:00:00 2001 From: Sebastien Melki Date: Tue, 21 Apr 2026 02:16:52 +0300 Subject: [PATCH] =?UTF-8?q?refactor(leads):=20migrate=20/api/{contact,regi?= =?UTF-8?q?ster-interest}=20=E2=86=92=20LeadsService=20(#3207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/_ip-rate-limit.js | 20 -- api/api-route-exceptions.json | 14 - api/leads/v1/[rpc].ts | 9 + docs/api-commerce.mdx | 4 +- docs/api-platform.mdx | 8 +- docs/api/LeadsService.openapi.json | 1 + docs/api/LeadsService.openapi.yaml | 173 +++++++++++++ docs/usage-rate-limits.mdx | 4 +- pro-test/src/App.tsx | 4 +- .../leads/v1/register_interest.proto | 29 +++ proto/worldmonitor/leads/v1/service.proto | 22 ++ .../leads/v1/submit_contact.proto | 25 ++ .../_shared/email-validation.ts | 14 +- server/_shared/rate-limit.ts | 5 + .../_shared/turnstile.ts | 16 +- server/worldmonitor/leads/v1/handler.ts | 9 + .../leads/v1/register-interest.ts | 154 +++++------ .../worldmonitor/leads/v1/submit-contact.ts | 159 ++++++------ src-tauri/sidecar/local-api-server.mjs | 6 +- .../worldmonitor/leads/v1/service_client.ts | 149 +++++++++++ .../worldmonitor/leads/v1/service_server.ts | 180 +++++++++++++ src/services/runtime.ts | 5 +- tests/contact-handler.test.mjs | 244 +++++++++--------- tests/email-validation.test.mjs | 2 +- .../turnstile.test.mjs | 2 +- vite.config.ts | 4 + 26 files changed, 905 insertions(+), 357 deletions(-) delete mode 100644 api/_ip-rate-limit.js create mode 100644 api/leads/v1/[rpc].ts create mode 100644 docs/api/LeadsService.openapi.json create mode 100644 docs/api/LeadsService.openapi.yaml create mode 100644 proto/worldmonitor/leads/v1/register_interest.proto create mode 100644 proto/worldmonitor/leads/v1/service.proto create mode 100644 proto/worldmonitor/leads/v1/submit_contact.proto rename api/_email-validation.js => server/_shared/email-validation.ts (83%) rename api/_turnstile.js => server/_shared/turnstile.ts (72%) create mode 100644 server/worldmonitor/leads/v1/handler.ts rename api/register-interest.js => server/worldmonitor/leads/v1/register-interest.ts (75%) rename api/contact.js => server/worldmonitor/leads/v1/submit-contact.ts (58%) create mode 100644 src/generated/client/worldmonitor/leads/v1/service_client.ts create mode 100644 src/generated/server/worldmonitor/leads/v1/service_server.ts rename api/_turnstile.test.mjs => tests/turnstile.test.mjs (96%) diff --git a/api/_ip-rate-limit.js b/api/_ip-rate-limit.js deleted file mode 100644 index 27f3e1a62..000000000 --- a/api/_ip-rate-limit.js +++ /dev/null @@ -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 }; -} diff --git a/api/api-route-exceptions.json b/api/api-route-exceptions.json index b1d07d1cf..e48a81da1 100644 --- a/api/api-route-exceptions.json +++ b/api/api-route-exceptions.json @@ -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", diff --git a/api/leads/v1/[rpc].ts b/api/leads/v1/[rpc].ts new file mode 100644 index 000000000..7d19b77b4 --- /dev/null +++ b/api/leads/v1/[rpc].ts @@ -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), +); diff --git a/docs/api-commerce.mdx b/docs/api-commerce.mdx index 531da8a0b..8dc4f422a 100644 --- a/docs/api-commerce.mdx +++ b/docs/api-commerce.mdx @@ -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. diff --git a/docs/api-platform.mdx b/docs/api-platform.mdx index fa2f6ddc0..7e8fcb0e3 100644 --- a/docs/api-platform.mdx +++ b/docs/api-platform.mdx @@ -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`. diff --git a/docs/api/LeadsService.openapi.json b/docs/api/LeadsService.openapi.json new file mode 100644 index 000000000..4b29246be --- /dev/null +++ b/docs/api/LeadsService.openapi.json @@ -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"]}}}} \ No newline at end of file diff --git a/docs/api/LeadsService.openapi.yaml b/docs/api/LeadsService.openapi.yaml new file mode 100644 index 000000000..c7d1126e1 --- /dev/null +++ b/docs/api/LeadsService.openapi.yaml @@ -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. diff --git a/docs/usage-rate-limits.mdx b/docs/usage-rate-limits.mdx index 0750255b5..3a1da0cd4 100644 --- a/docs/usage-rate-limits.mdx +++ b/docs/usage-rate-limits.mdx @@ -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. diff --git a/pro-test/src/App.tsx b/pro-test/src/App.tsx index d78d3c38c..67e4217d6 100644 --- a/pro-test/src/App.tsx +++ b/pro-test/src/App.tsx @@ -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; diff --git a/proto/worldmonitor/leads/v1/register_interest.proto b/proto/worldmonitor/leads/v1/register_interest.proto new file mode 100644 index 000000000..f1ed1690a --- /dev/null +++ b/proto/worldmonitor/leads/v1/register_interest.proto @@ -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; +} diff --git a/proto/worldmonitor/leads/v1/service.proto b/proto/worldmonitor/leads/v1/service.proto new file mode 100644 index 000000000..56b19c7bf --- /dev/null +++ b/proto/worldmonitor/leads/v1/service.proto @@ -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}; + } +} diff --git a/proto/worldmonitor/leads/v1/submit_contact.proto b/proto/worldmonitor/leads/v1/submit_contact.proto new file mode 100644 index 000000000..bd7a22655 --- /dev/null +++ b/proto/worldmonitor/leads/v1/submit_contact.proto @@ -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; +} diff --git a/api/_email-validation.js b/server/_shared/email-validation.ts similarity index 83% rename from api/_email-validation.js rename to server/_shared/email-validation.ts index 6bd69e917..51dffb5f1 100644 --- a/api/_email-validation.js +++ b/server/_shared/email-validation.ts @@ -1,4 +1,4 @@ -const DISPOSABLE_DOMAINS = new Set([ +const DISPOSABLE_DOMAINS = new Set([ '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(['con', 'coma', 'comhade', 'gmai', 'gmial']); -async function hasMxRecords(domain) { +export type EmailValidationResult = { valid: true } | { valid: false; reason: string }; + +async function hasMxRecords(domain: string): Promise { 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 { const normalized = email.trim().toLowerCase(); const atIdx = normalized.indexOf('@'); if (atIdx < 1) return { valid: false, reason: 'Invalid email format' }; diff --git a/server/_shared/rate-limit.ts b/server/_shared/rate-limit.ts index d92d22695..1a785f8d6 100644 --- a/server/_shared/rate-limit.ts +++ b/server/_shared/rate-limit.ts @@ -83,6 +83,11 @@ const ENDPOINT_RATE_POLICIES: Record = { // 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(); diff --git a/api/_turnstile.js b/server/_shared/turnstile.ts similarity index 72% rename from api/_turnstile.js rename to server/_shared/turnstile.ts index 605c76619..57777dd51 100644 --- a/api/_turnstile.js +++ b/server/_shared/turnstile.ts @@ -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 { 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; diff --git a/server/worldmonitor/leads/v1/handler.ts b/server/worldmonitor/leads/v1/handler.ts new file mode 100644 index 000000000..23d2df2ad --- /dev/null +++ b/server/worldmonitor/leads/v1/handler.ts @@ -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, +}; diff --git a/api/register-interest.js b/server/worldmonitor/leads/v1/register-interest.ts similarity index 75% rename from api/register-interest.js rename to server/worldmonitor/leads/v1/register-interest.ts index 1d284b1e2..9942cef47 100644 --- a/api/register-interest.js +++ b/server/worldmonitor/leads/v1/register-interest.ts @@ -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(['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 { 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 ', to: [email], - subject: 'You\u2019re on the World Monitor Pro waitlist', + subject: "You\u2019re on the World Monitor Pro waitlist", html: `
@@ -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 { + // 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, + }; } diff --git a/api/contact.js b/server/worldmonitor/leads/v1/submit-contact.ts similarity index 58% rename from api/contact.js rename to server/worldmonitor/leads/v1/submit-contact.ts index cc88e68bf..780cb9927 100644 --- a/api/contact.js +++ b/server/worldmonitor/leads/v1/submit-contact.ts @@ -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([ '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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} -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 { 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, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -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 { + // 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 }; } diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 07f82e7b3..bb89ddf7f 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -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); } diff --git a/src/generated/client/worldmonitor/leads/v1/service_client.ts b/src/generated/client/worldmonitor/leads/v1/service_client.ts new file mode 100644 index 000000000..e1f01259f --- /dev/null +++ b/src/generated/client/worldmonitor/leads/v1/service_client.ts @@ -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; +} + +export interface LeadsServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class LeadsServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + 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 { + let path = "/api/leads/v1/submit-contact"; + const url = this.baseURL + path; + + const headers: Record = { + "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 { + let path = "/api/leads/v1/register-interest"; + const url = this.baseURL + path; + + const headers: Record = { + "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 { + 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); + } +} + diff --git a/src/generated/server/worldmonitor/leads/v1/service_server.ts b/src/generated/server/worldmonitor/leads/v1/service_server.ts new file mode 100644 index 000000000..0daad2c2b --- /dev/null +++ b/src/generated/server/worldmonitor/leads/v1/service_server.ts @@ -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; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface LeadsServiceHandler { + submitContact(ctx: ServerContext, req: SubmitContactRequest): Promise; + registerInterest(ctx: ServerContext, req: RegisterInterestRequest): Promise; +} + +export function createLeadsServiceRoutes( + handler: LeadsServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/leads/v1/submit-contact", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + 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 => { + try { + const pathParams: Record = {}; + 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" }, + }); + } + }, + }, + ]; +} + diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 2e68b43d2..86c221ff1 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -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( diff --git a/tests/contact-handler.test.mjs b/tests/contact-handler.test.mjs index 11ac21de1..0541b3434 100644 --- a/tests/contact-handler.test.mjs +++ b/tests/contact-handler.test.mjs @@ -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())); }); }); }); diff --git a/tests/email-validation.test.mjs b/tests/email-validation.test.mjs index 830b2ef13..f075e6193 100644 --- a/tests/email-validation.test.mjs +++ b/tests/email-validation.test.mjs @@ -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(() => { diff --git a/api/_turnstile.test.mjs b/tests/turnstile.test.mjs similarity index 96% rename from api/_turnstile.test.mjs rename to tests/turnstile.test.mjs index 530b1aebd..20a4203c6 100644 --- a/api/_turnstile.test.mjs +++ b/tests/turnstile.test.mjs @@ -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 }; diff --git a/vite.config.ts b/vite.config.ts index ccc3604e0..ba0e605f9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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);