mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -316,20 +316,6 @@
|
||||
"removal_issue": "TBD"
|
||||
},
|
||||
|
||||
{
|
||||
"path": "api/contact.js",
|
||||
"category": "migration-pending",
|
||||
"reason": "Migrating to leads/v1 service (SubmitContact RPC) in commit 4 of #3207.",
|
||||
"owner": "@SebastienMelki",
|
||||
"removal_issue": "#3207"
|
||||
},
|
||||
{
|
||||
"path": "api/register-interest.js",
|
||||
"category": "migration-pending",
|
||||
"reason": "Migrating to leads/v1 service (RegisterInterest RPC) in commit 4 of #3207.",
|
||||
"owner": "@SebastienMelki",
|
||||
"removal_issue": "#3207"
|
||||
},
|
||||
{
|
||||
"path": "api/eia/[[...path]].js",
|
||||
"category": "migration-pending",
|
||||
|
||||
9
api/leads/v1/[rpc].ts
Normal file
9
api/leads/v1/[rpc].ts
Normal 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),
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
1
docs/api/LeadsService.openapi.json
Normal file
1
docs/api/LeadsService.openapi.json
Normal 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"]}}}}
|
||||
173
docs/api/LeadsService.openapi.yaml
Normal file
173
docs/api/LeadsService.openapi.yaml
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
proto/worldmonitor/leads/v1/register_interest.proto
Normal file
29
proto/worldmonitor/leads/v1/register_interest.proto
Normal 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;
|
||||
}
|
||||
22
proto/worldmonitor/leads/v1/service.proto
Normal file
22
proto/worldmonitor/leads/v1/service.proto
Normal 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};
|
||||
}
|
||||
}
|
||||
25
proto/worldmonitor/leads/v1/submit_contact.proto
Normal file
25
proto/worldmonitor/leads/v1/submit_contact.proto
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const DISPOSABLE_DOMAINS = new Set([
|
||||
const DISPOSABLE_DOMAINS = new Set<string>([
|
||||
'guerrillamail.com', 'guerrillamail.de', 'guerrillamail.net', 'guerrillamail.org',
|
||||
'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me',
|
||||
'tempmail.com', 'temp-mail.org', 'temp-mail.io',
|
||||
@@ -27,23 +27,25 @@ const DISPOSABLE_DOMAINS = new Set([
|
||||
|
||||
const OFFENSIVE_RE = /(nigger|faggot|fuckfaggot)/i;
|
||||
|
||||
const TYPO_TLDS = new Set(['con', 'coma', 'comhade', 'gmai', 'gmial']);
|
||||
const TYPO_TLDS = new Set<string>(['con', 'coma', 'comhade', 'gmai', 'gmial']);
|
||||
|
||||
async function hasMxRecords(domain) {
|
||||
export type EmailValidationResult = { valid: true } | { valid: false; reason: string };
|
||||
|
||||
async function hasMxRecords(domain: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`,
|
||||
{ headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) }
|
||||
{ headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) },
|
||||
);
|
||||
if (!res.ok) return true;
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as { Answer?: unknown[] };
|
||||
return Array.isArray(data.Answer) && data.Answer.length > 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateEmail(email) {
|
||||
export async function validateEmail(email: string): Promise<EmailValidationResult> {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const atIdx = normalized.indexOf('@');
|
||||
if (atIdx < 1) return { valid: false, reason: 'Invalid email format' };
|
||||
@@ -83,6 +83,11 @@ const ENDPOINT_RATE_POLICIES: Record<string, EndpointRatePolicy> = {
|
||||
// Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
|
||||
// that budget now that LookupSanctionEntity proxies OpenSanctions live.
|
||||
'/api/sanctions/v1/lookup-entity': { limit: 30, window: '60 s' },
|
||||
// Lead capture: preserve the 3/hr and 5/hr budgets from legacy api/contact.js
|
||||
// and api/register-interest.js. Lower limits than normal IP rate limit since
|
||||
// these hit Convex + Resend per request.
|
||||
'/api/leads/v1/submit-contact': { limit: 3, window: '1 h' },
|
||||
'/api/leads/v1/register-interest': { limit: 5, window: '1 h' },
|
||||
};
|
||||
|
||||
const endpointLimiters = new Map<string, Ratelimit>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
export function getClientIp(request) {
|
||||
// Prefer platform-populated IP headers before falling back to x-forwarded-for.
|
||||
export function getClientIp(request: Request): string {
|
||||
return (
|
||||
request.headers.get('x-real-ip') ||
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
@@ -10,12 +9,21 @@ export function getClientIp(request) {
|
||||
);
|
||||
}
|
||||
|
||||
export type TurnstileMissingSecretPolicy = 'allow' | 'allow-in-development' | 'deny';
|
||||
|
||||
export interface VerifyTurnstileArgs {
|
||||
token: string;
|
||||
ip: string;
|
||||
logPrefix?: string;
|
||||
missingSecretPolicy?: TurnstileMissingSecretPolicy;
|
||||
}
|
||||
|
||||
export async function verifyTurnstile({
|
||||
token,
|
||||
ip,
|
||||
logPrefix = '[turnstile]',
|
||||
missingSecretPolicy = 'allow',
|
||||
}) {
|
||||
}: VerifyTurnstileArgs): Promise<boolean> {
|
||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||
if (!secret) {
|
||||
if (missingSecretPolicy === 'allow') return true;
|
||||
@@ -33,7 +41,7 @@ export async function verifyTurnstile({
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data.success === true;
|
||||
} catch {
|
||||
return false;
|
||||
9
server/worldmonitor/leads/v1/handler.ts
Normal file
9
server/worldmonitor/leads/v1/handler.ts
Normal 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,
|
||||
};
|
||||
@@ -1,24 +1,36 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
/**
|
||||
* RPC: registerInterest -- Adds an email to the Pro waitlist and emails a confirmation.
|
||||
* Port from api/register-interest.js
|
||||
* Sources: Convex registerInterest:register mutation + Resend confirmation email
|
||||
*/
|
||||
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
import { getClientIp, verifyTurnstile } from './_turnstile.js';
|
||||
import { jsonResponse } from './_json-response.js';
|
||||
import { createIpRateLimiter } from './_ip-rate-limit.js';
|
||||
import { validateEmail } from './_email-validation.js';
|
||||
import type {
|
||||
ServerContext,
|
||||
RegisterInterestRequest,
|
||||
RegisterInterestResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||
import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||
import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile';
|
||||
import { validateEmail } from '../../../_shared/email-validation';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_EMAIL_LENGTH = 320;
|
||||
const MAX_META_LENGTH = 100;
|
||||
|
||||
const RATE_LIMIT = 5;
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
const DESKTOP_SOURCES = new Set<string>(['desktop-settings']);
|
||||
|
||||
const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS });
|
||||
interface ConvexRegisterResult {
|
||||
status: 'registered' | 'already_registered';
|
||||
referralCode: string;
|
||||
referralCount: number;
|
||||
position?: number;
|
||||
emailSuppressed?: boolean;
|
||||
}
|
||||
|
||||
async function sendConfirmationEmail(email, referralCode) {
|
||||
async function sendConfirmationEmail(email: string, referralCode: string): Promise<void> {
|
||||
const referralLink = `https://worldmonitor.app/pro?ref=${referralCode}`;
|
||||
const shareText = encodeURIComponent('I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:');
|
||||
const shareText = encodeURIComponent("I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:");
|
||||
const shareUrl = encodeURIComponent(referralLink);
|
||||
const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`;
|
||||
const linkedinShare = `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`;
|
||||
@@ -40,7 +52,7 @@ async function sendConfirmationEmail(email, referralCode) {
|
||||
body: JSON.stringify({
|
||||
from: 'World Monitor <noreply@worldmonitor.app>',
|
||||
to: [email],
|
||||
subject: 'You\u2019re on the World Monitor Pro waitlist',
|
||||
subject: "You\u2019re on the World Monitor Pro waitlist",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #e0e0e0;">
|
||||
<div style="background: #4ade80; height: 4px;"></div>
|
||||
@@ -168,105 +180,71 @@ async function sendConfirmationEmail(email, referralCode) {
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req) {
|
||||
if (isDisallowedOrigin(req)) {
|
||||
return jsonResponse({ error: 'Origin not allowed' }, 403);
|
||||
export async function registerInterest(
|
||||
ctx: ServerContext,
|
||||
req: RegisterInterestRequest,
|
||||
): Promise<RegisterInterestResponse> {
|
||||
// Honeypot — silently accept but do nothing.
|
||||
if (req.website) {
|
||||
return { status: 'registered', referralCode: '', referralCount: 0, position: 0, emailSuppressed: false };
|
||||
}
|
||||
|
||||
const cors = getCorsHeaders(req, 'POST, OPTIONS');
|
||||
const ip = getClientIp(ctx.request);
|
||||
const isDesktopSource = typeof req.source === 'string' && DESKTOP_SOURCES.has(req.source);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
if (rateLimiter.isRateLimited(ip)) {
|
||||
return jsonResponse({ error: 'Too many requests' }, 429, cors);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
|
||||
}
|
||||
|
||||
// Honeypot — bots auto-fill this hidden field; real users leave it empty
|
||||
if (body.website) {
|
||||
return jsonResponse({ status: 'registered' }, 200, cors);
|
||||
}
|
||||
|
||||
// Cloudflare Turnstile verification — skip for desktop app (no browser captcha available).
|
||||
// Desktop bypasses captcha, so enforce stricter rate limit (2/hr vs 5/hr).
|
||||
const DESKTOP_SOURCES = new Set(['desktop-settings']);
|
||||
const isDesktopSource = typeof body.source === 'string' && DESKTOP_SOURCES.has(body.source);
|
||||
if (isDesktopSource) {
|
||||
const entry = rateLimiter.getEntry(ip);
|
||||
if (entry && entry.count > 2) {
|
||||
return jsonResponse({ error: 'Rate limit exceeded' }, 429, cors);
|
||||
}
|
||||
} else {
|
||||
// Desktop sources bypass Turnstile (no browser captcha). Gateway-level per-IP
|
||||
// rate limit (5/h) already throttles abuse for both cases.
|
||||
if (!isDesktopSource) {
|
||||
const turnstileOk = await verifyTurnstile({
|
||||
token: body.turnstileToken || '',
|
||||
token: req.turnstileToken || '',
|
||||
ip,
|
||||
logPrefix: '[register-interest]',
|
||||
});
|
||||
if (!turnstileOk) {
|
||||
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
|
||||
throw new ApiError(403, 'Bot verification failed', '');
|
||||
}
|
||||
}
|
||||
|
||||
const { email, source, appVersion, referredBy } = body;
|
||||
if (!email || typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
|
||||
return jsonResponse({ error: 'Invalid email address' }, 400, cors);
|
||||
const { email, source, appVersion, referredBy } = req;
|
||||
if (!email || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
|
||||
throw new ValidationError([{ field: 'email', description: 'Invalid email address' }]);
|
||||
}
|
||||
|
||||
const emailCheck = await validateEmail(email);
|
||||
if (!emailCheck.valid) {
|
||||
return jsonResponse({ error: emailCheck.reason }, 400, cors);
|
||||
throw new ValidationError([{ field: 'email', description: emailCheck.reason }]);
|
||||
}
|
||||
|
||||
const safeSource = typeof source === 'string'
|
||||
? source.slice(0, MAX_META_LENGTH)
|
||||
: 'unknown';
|
||||
const safeAppVersion = typeof appVersion === 'string'
|
||||
? appVersion.slice(0, MAX_META_LENGTH)
|
||||
: 'unknown';
|
||||
const safeReferredBy = typeof referredBy === 'string'
|
||||
? referredBy.slice(0, 20)
|
||||
: undefined;
|
||||
const safeSource = source ? source.slice(0, MAX_META_LENGTH) : 'unknown';
|
||||
const safeAppVersion = appVersion ? appVersion.slice(0, MAX_META_LENGTH) : 'unknown';
|
||||
const safeReferredBy = referredBy ? referredBy.slice(0, 20) : undefined;
|
||||
|
||||
const convexUrl = process.env.CONVEX_URL;
|
||||
if (!convexUrl) {
|
||||
return jsonResponse({ error: 'Registration service unavailable' }, 503, cors);
|
||||
throw new ApiError(503, 'Registration service unavailable', '');
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
const result = await client.mutation('registerInterest:register', {
|
||||
email,
|
||||
source: safeSource,
|
||||
appVersion: safeAppVersion,
|
||||
referredBy: safeReferredBy,
|
||||
});
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
const result = (await client.mutation('registerInterest:register' as any, {
|
||||
email,
|
||||
source: safeSource,
|
||||
appVersion: safeAppVersion,
|
||||
referredBy: safeReferredBy,
|
||||
})) as ConvexRegisterResult;
|
||||
|
||||
// Send confirmation email for new registrations (awaited to avoid Edge isolate termination)
|
||||
// Skip if email is on the suppression list (previously bounced)
|
||||
if (result.status === 'registered' && result.referralCode) {
|
||||
if (!result.emailSuppressed) {
|
||||
await sendConfirmationEmail(email, result.referralCode);
|
||||
} else {
|
||||
console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
|
||||
}
|
||||
if (result.status === 'registered' && result.referralCode) {
|
||||
if (!result.emailSuppressed) {
|
||||
await sendConfirmationEmail(email, result.referralCode);
|
||||
} else {
|
||||
console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
|
||||
}
|
||||
|
||||
return jsonResponse(result, 200, cors);
|
||||
} catch (err) {
|
||||
console.error('[register-interest] Convex error:', err);
|
||||
return jsonResponse({ error: 'Registration failed' }, 500, cors);
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
referralCode: result.referralCode,
|
||||
referralCount: result.referralCount,
|
||||
position: result.position ?? 0,
|
||||
emailSuppressed: result.emailSuppressed ?? false,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
/**
|
||||
* RPC: submitContact -- Stores an enterprise contact submission and emails ops.
|
||||
* Port from api/contact.js
|
||||
* Sources: Convex contactMessages:submit mutation + Resend notification email
|
||||
*/
|
||||
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
import { getClientIp, verifyTurnstile } from './_turnstile.js';
|
||||
import { jsonResponse } from './_json-response.js';
|
||||
import { createIpRateLimiter } from './_ip-rate-limit.js';
|
||||
import type {
|
||||
ServerContext,
|
||||
SubmitContactRequest,
|
||||
SubmitContactResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||
import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||
import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
|
||||
const MAX_FIELD = 500;
|
||||
const MAX_MESSAGE = 2000;
|
||||
|
||||
const FREE_EMAIL_DOMAINS = new Set([
|
||||
const FREE_EMAIL_DOMAINS = new Set<string>([
|
||||
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp',
|
||||
'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr',
|
||||
'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
|
||||
@@ -24,12 +31,27 @@ const FREE_EMAIL_DOMAINS = new Set([
|
||||
't-online.de', 'libero.it', 'virgilio.it',
|
||||
]);
|
||||
|
||||
const RATE_LIMIT = 3;
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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<boolean> {
|
||||
const resendKey = process.env.RESEND_API_KEY;
|
||||
if (!resendKey) {
|
||||
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent');
|
||||
@@ -77,109 +99,80 @@ async function sendNotificationEmail(name, email, organization, phone, message,
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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<SubmitContactResponse> {
|
||||
// Honeypot — silently accept but do nothing (bots auto-fill hidden field).
|
||||
if (req.website) {
|
||||
return { status: 'sent', emailSent: false };
|
||||
}
|
||||
|
||||
const cors = getCorsHeaders(req, 'POST, OPTIONS');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const country = req.headers.get('cf-ipcountry') || req.headers.get('x-vercel-ip-country') || null;
|
||||
|
||||
if (rateLimiter.isRateLimited(ip)) {
|
||||
return jsonResponse({ error: 'Too many requests' }, 429, cors);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
|
||||
}
|
||||
|
||||
if (body.website) {
|
||||
return jsonResponse({ status: 'sent' }, 200, cors);
|
||||
}
|
||||
const ip = getClientIp(ctx.request);
|
||||
const country = ctx.request.headers.get('cf-ipcountry')
|
||||
|| ctx.request.headers.get('x-vercel-ip-country');
|
||||
|
||||
const turnstileOk = await verifyTurnstile({
|
||||
token: body.turnstileToken || '',
|
||||
token: req.turnstileToken || '',
|
||||
ip,
|
||||
logPrefix: '[contact]',
|
||||
missingSecretPolicy: 'allow-in-development',
|
||||
});
|
||||
if (!turnstileOk) {
|
||||
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
|
||||
throw new ApiError(403, 'Bot verification failed', '');
|
||||
}
|
||||
|
||||
const { email, name, organization, phone, message, source } = body;
|
||||
const { email, name, organization, phone, message, source } = req;
|
||||
|
||||
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
|
||||
return jsonResponse({ error: 'Invalid email' }, 400, cors);
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
throw new ValidationError([{ field: 'email', description: 'Invalid email' }]);
|
||||
}
|
||||
|
||||
const emailDomain = email.split('@')[1]?.toLowerCase();
|
||||
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
|
||||
return jsonResponse({ error: 'Please use your work email address' }, 422, cors);
|
||||
throw new ApiError(422, 'Please use your work email address', '');
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return jsonResponse({ error: 'Name is required' }, 400, cors);
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new ValidationError([{ field: 'name', description: 'Name is required' }]);
|
||||
}
|
||||
if (!organization || typeof organization !== 'string' || organization.trim().length === 0) {
|
||||
return jsonResponse({ error: 'Company is required' }, 400, cors);
|
||||
if (!organization || organization.trim().length === 0) {
|
||||
throw new ValidationError([{ field: 'organization', description: 'Company is required' }]);
|
||||
}
|
||||
if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) {
|
||||
return jsonResponse({ error: 'Valid phone number is required' }, 400, cors);
|
||||
if (!phone || !PHONE_RE.test(phone.trim())) {
|
||||
throw new ValidationError([{ field: 'phone', description: 'Valid phone number is required' }]);
|
||||
}
|
||||
|
||||
const safeName = name.slice(0, MAX_FIELD);
|
||||
const safeOrg = organization.slice(0, MAX_FIELD);
|
||||
const safePhone = phone.trim().slice(0, 30);
|
||||
const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined;
|
||||
const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact';
|
||||
const safeMsg = message ? message.slice(0, MAX_MESSAGE) : undefined;
|
||||
const safeSource = source ? source.slice(0, 100) : 'enterprise-contact';
|
||||
|
||||
const convexUrl = process.env.CONVEX_URL;
|
||||
if (!convexUrl) {
|
||||
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
|
||||
throw new ApiError(503, 'Service unavailable', '');
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
await client.mutation('contactMessages:submit', {
|
||||
name: safeName,
|
||||
email: email.trim(),
|
||||
organization: safeOrg,
|
||||
phone: safePhone,
|
||||
message: safeMsg,
|
||||
source: safeSource,
|
||||
});
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
await client.mutation('contactMessages:submit' as any, {
|
||||
name: safeName,
|
||||
email: email.trim(),
|
||||
organization: safeOrg,
|
||||
phone: safePhone,
|
||||
message: safeMsg,
|
||||
source: safeSource,
|
||||
});
|
||||
|
||||
const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg, ip, country);
|
||||
const emailSent = await sendNotificationEmail(
|
||||
safeName,
|
||||
email.trim(),
|
||||
safeOrg,
|
||||
safePhone,
|
||||
safeMsg,
|
||||
ip,
|
||||
country,
|
||||
);
|
||||
|
||||
return jsonResponse({ status: 'sent', emailSent }, 200, cors);
|
||||
} catch (err) {
|
||||
console.error('[contact] error:', err);
|
||||
return jsonResponse({ error: 'Failed to send message' }, 500, cors);
|
||||
}
|
||||
return { status: 'sent', emailSent };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
149
src/generated/client/worldmonitor/leads/v1/service_client.ts
Normal file
149
src/generated/client/worldmonitor/leads/v1/service_client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
180
src/generated/server/worldmonitor/leads/v1/service_server.ts
Normal file
180
src/generated/server/worldmonitor/leads/v1/service_server.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user