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"
|
"removal_issue": "TBD"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
"path": "api/contact.js",
|
|
||||||
"category": "migration-pending",
|
|
||||||
"reason": "Migrating to leads/v1 service (SubmitContact RPC) in commit 4 of #3207.",
|
|
||||||
"owner": "@SebastienMelki",
|
|
||||||
"removal_issue": "#3207"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "api/register-interest.js",
|
|
||||||
"category": "migration-pending",
|
|
||||||
"reason": "Migrating to leads/v1 service (RegisterInterest RPC) in commit 4 of #3207.",
|
|
||||||
"owner": "@SebastienMelki",
|
|
||||||
"removal_issue": "#3207"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "api/eia/[[...path]].js",
|
"path": "api/eia/[[...path]].js",
|
||||||
"category": "migration-pending",
|
"category": "migration-pending",
|
||||||
|
|||||||
9
api/leads/v1/[rpc].ts
Normal file
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
|
## 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`).
|
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` | 10 | 60 s | Per user |
|
||||||
| `POST /api/scenario/v1/run` (queue depth) | 100 in-flight | — | Global |
|
| `POST /api/scenario/v1/run` (queue depth) | 100 in-flight | — | Global |
|
||||||
| `POST /api/register-interest` | 5 | 60 min | Per IP + Turnstile |
|
| `POST /api/leads/v1/register-interest` | 5 | 60 min | Per IP + Turnstile (desktop sources bypass Turnstile) |
|
||||||
| `POST /api/contact` | 3 | 60 min | Per IP + Turnstile |
|
| `POST /api/leads/v1/submit-contact` | 3 | 60 min | Per IP + Turnstile |
|
||||||
|
|
||||||
Other write endpoints (`/api/brief/share-url`, `/api/notification-channels`, `/api/create-checkout`, `/api/customer-portal`, etc.) fall back to the default per-IP limit above.
|
Other write endpoints (`/api/brief/share-url`, `/api/notification-channels`, `/api/create-checkout`, `/api/customer-portal`, etc.) fall back to the default per-IP limit above.
|
||||||
|
|
||||||
|
|||||||
@@ -995,7 +995,7 @@ const EnterprisePage = () => (
|
|||||||
const turnstileWidget = form.querySelector('.cf-turnstile') as HTMLElement | null;
|
const turnstileWidget = form.querySelector('.cf-turnstile') as HTMLElement | null;
|
||||||
const turnstileToken = turnstileWidget?.dataset.token || '';
|
const turnstileToken = turnstileWidget?.dataset.token || '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/contact`, {
|
const res = await fetch(`${API_BASE}/leads/v1/submit-contact`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1013,7 +1013,7 @@ const EnterprisePage = () => (
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.status === 422 && errorEl) {
|
if (res.status === 422 && errorEl) {
|
||||||
errorEl.textContent = data.error || t('enterpriseShowcase.workEmailRequired');
|
errorEl.textContent = data.message || data.error || t('enterpriseShowcase.workEmailRequired');
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.classList.remove('hidden');
|
||||||
btn.textContent = origText;
|
btn.textContent = origText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
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',
|
'guerrillamail.com', 'guerrillamail.de', 'guerrillamail.net', 'guerrillamail.org',
|
||||||
'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me',
|
'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me',
|
||||||
'tempmail.com', 'temp-mail.org', 'temp-mail.io',
|
'tempmail.com', 'temp-mail.org', 'temp-mail.io',
|
||||||
@@ -27,23 +27,25 @@ const DISPOSABLE_DOMAINS = new Set([
|
|||||||
|
|
||||||
const OFFENSIVE_RE = /(nigger|faggot|fuckfaggot)/i;
|
const OFFENSIVE_RE = /(nigger|faggot|fuckfaggot)/i;
|
||||||
|
|
||||||
const TYPO_TLDS = new Set(['con', 'coma', 'comhade', 'gmai', 'gmial']);
|
const TYPO_TLDS = new Set<string>(['con', 'coma', 'comhade', 'gmai', 'gmial']);
|
||||||
|
|
||||||
async function hasMxRecords(domain) {
|
export type EmailValidationResult = { valid: true } | { valid: false; reason: string };
|
||||||
|
|
||||||
|
async function hasMxRecords(domain: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`,
|
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`,
|
||||||
{ headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) }
|
{ headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) },
|
||||||
);
|
);
|
||||||
if (!res.ok) return true;
|
if (!res.ok) return true;
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { Answer?: unknown[] };
|
||||||
return Array.isArray(data.Answer) && data.Answer.length > 0;
|
return Array.isArray(data.Answer) && data.Answer.length > 0;
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateEmail(email) {
|
export async function validateEmail(email: string): Promise<EmailValidationResult> {
|
||||||
const normalized = email.trim().toLowerCase();
|
const normalized = email.trim().toLowerCase();
|
||||||
const atIdx = normalized.indexOf('@');
|
const atIdx = normalized.indexOf('@');
|
||||||
if (atIdx < 1) return { valid: false, reason: 'Invalid email format' };
|
if (atIdx < 1) return { valid: false, reason: 'Invalid email format' };
|
||||||
@@ -83,6 +83,11 @@ const ENDPOINT_RATE_POLICIES: Record<string, EndpointRatePolicy> = {
|
|||||||
// Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
|
// Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
|
||||||
// that budget now that LookupSanctionEntity proxies OpenSanctions live.
|
// that budget now that LookupSanctionEntity proxies OpenSanctions live.
|
||||||
'/api/sanctions/v1/lookup-entity': { limit: 30, window: '60 s' },
|
'/api/sanctions/v1/lookup-entity': { limit: 30, window: '60 s' },
|
||||||
|
// Lead capture: preserve the 3/hr and 5/hr budgets from legacy api/contact.js
|
||||||
|
// and api/register-interest.js. Lower limits than normal IP rate limit since
|
||||||
|
// these hit Convex + Resend per request.
|
||||||
|
'/api/leads/v1/submit-contact': { limit: 3, window: '1 h' },
|
||||||
|
'/api/leads/v1/register-interest': { limit: 5, window: '1 h' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpointLimiters = new Map<string, Ratelimit>();
|
const endpointLimiters = new Map<string, Ratelimit>();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
export function getClientIp(request) {
|
export function getClientIp(request: Request): string {
|
||||||
// Prefer platform-populated IP headers before falling back to x-forwarded-for.
|
|
||||||
return (
|
return (
|
||||||
request.headers.get('x-real-ip') ||
|
request.headers.get('x-real-ip') ||
|
||||||
request.headers.get('cf-connecting-ip') ||
|
request.headers.get('cf-connecting-ip') ||
|
||||||
@@ -10,12 +9,21 @@ export function getClientIp(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TurnstileMissingSecretPolicy = 'allow' | 'allow-in-development' | 'deny';
|
||||||
|
|
||||||
|
export interface VerifyTurnstileArgs {
|
||||||
|
token: string;
|
||||||
|
ip: string;
|
||||||
|
logPrefix?: string;
|
||||||
|
missingSecretPolicy?: TurnstileMissingSecretPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyTurnstile({
|
export async function verifyTurnstile({
|
||||||
token,
|
token,
|
||||||
ip,
|
ip,
|
||||||
logPrefix = '[turnstile]',
|
logPrefix = '[turnstile]',
|
||||||
missingSecretPolicy = 'allow',
|
missingSecretPolicy = 'allow',
|
||||||
}) {
|
}: VerifyTurnstileArgs): Promise<boolean> {
|
||||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
if (missingSecretPolicy === 'allow') return true;
|
if (missingSecretPolicy === 'allow') return true;
|
||||||
@@ -33,7 +41,7 @@ export async function verifyTurnstile({
|
|||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
|
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { success?: boolean };
|
||||||
return data.success === true;
|
return data.success === true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
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 { ConvexHttpClient } from 'convex/browser';
|
||||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
import type {
|
||||||
import { getClientIp, verifyTurnstile } from './_turnstile.js';
|
ServerContext,
|
||||||
import { jsonResponse } from './_json-response.js';
|
RegisterInterestRequest,
|
||||||
import { createIpRateLimiter } from './_ip-rate-limit.js';
|
RegisterInterestResponse,
|
||||||
import { validateEmail } from './_email-validation.js';
|
} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||||
|
import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||||
|
import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile';
|
||||||
|
import { validateEmail } from '../../../_shared/email-validation';
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
const MAX_EMAIL_LENGTH = 320;
|
const MAX_EMAIL_LENGTH = 320;
|
||||||
const MAX_META_LENGTH = 100;
|
const MAX_META_LENGTH = 100;
|
||||||
|
|
||||||
const RATE_LIMIT = 5;
|
const DESKTOP_SOURCES = new Set<string>(['desktop-settings']);
|
||||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS });
|
interface ConvexRegisterResult {
|
||||||
|
status: 'registered' | 'already_registered';
|
||||||
|
referralCode: string;
|
||||||
|
referralCount: number;
|
||||||
|
position?: number;
|
||||||
|
emailSuppressed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendConfirmationEmail(email, referralCode) {
|
async function sendConfirmationEmail(email: string, referralCode: string): Promise<void> {
|
||||||
const referralLink = `https://worldmonitor.app/pro?ref=${referralCode}`;
|
const referralLink = `https://worldmonitor.app/pro?ref=${referralCode}`;
|
||||||
const shareText = encodeURIComponent('I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:');
|
const shareText = encodeURIComponent("I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:");
|
||||||
const shareUrl = encodeURIComponent(referralLink);
|
const shareUrl = encodeURIComponent(referralLink);
|
||||||
const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`;
|
const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`;
|
||||||
const linkedinShare = `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`;
|
const linkedinShare = `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`;
|
||||||
@@ -40,7 +52,7 @@ async function sendConfirmationEmail(email, referralCode) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: 'World Monitor <noreply@worldmonitor.app>',
|
from: 'World Monitor <noreply@worldmonitor.app>',
|
||||||
to: [email],
|
to: [email],
|
||||||
subject: 'You\u2019re on the World Monitor Pro waitlist',
|
subject: "You\u2019re on the World Monitor Pro waitlist",
|
||||||
html: `
|
html: `
|
||||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #e0e0e0;">
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #e0e0e0;">
|
||||||
<div style="background: #4ade80; height: 4px;"></div>
|
<div style="background: #4ade80; height: 4px;"></div>
|
||||||
@@ -168,105 +180,71 @@ async function sendConfirmationEmail(email, referralCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req) {
|
export async function registerInterest(
|
||||||
if (isDisallowedOrigin(req)) {
|
ctx: ServerContext,
|
||||||
return jsonResponse({ error: 'Origin not allowed' }, 403);
|
req: RegisterInterestRequest,
|
||||||
|
): Promise<RegisterInterestResponse> {
|
||||||
|
// Honeypot — silently accept but do nothing.
|
||||||
|
if (req.website) {
|
||||||
|
return { status: 'registered', referralCode: '', referralCount: 0, position: 0, emailSuppressed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cors = getCorsHeaders(req, 'POST, OPTIONS');
|
const ip = getClientIp(ctx.request);
|
||||||
|
const isDesktopSource = typeof req.source === 'string' && DESKTOP_SOURCES.has(req.source);
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
// Desktop sources bypass Turnstile (no browser captcha). Gateway-level per-IP
|
||||||
return new Response(null, { status: 204, headers: cors });
|
// rate limit (5/h) already throttles abuse for both cases.
|
||||||
}
|
if (!isDesktopSource) {
|
||||||
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
|
||||||
if (rateLimiter.isRateLimited(ip)) {
|
|
||||||
return jsonResponse({ error: 'Too many requests' }, 429, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await req.json();
|
|
||||||
} catch {
|
|
||||||
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Honeypot — bots auto-fill this hidden field; real users leave it empty
|
|
||||||
if (body.website) {
|
|
||||||
return jsonResponse({ status: 'registered' }, 200, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloudflare Turnstile verification — skip for desktop app (no browser captcha available).
|
|
||||||
// Desktop bypasses captcha, so enforce stricter rate limit (2/hr vs 5/hr).
|
|
||||||
const DESKTOP_SOURCES = new Set(['desktop-settings']);
|
|
||||||
const isDesktopSource = typeof body.source === 'string' && DESKTOP_SOURCES.has(body.source);
|
|
||||||
if (isDesktopSource) {
|
|
||||||
const entry = rateLimiter.getEntry(ip);
|
|
||||||
if (entry && entry.count > 2) {
|
|
||||||
return jsonResponse({ error: 'Rate limit exceeded' }, 429, cors);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const turnstileOk = await verifyTurnstile({
|
const turnstileOk = await verifyTurnstile({
|
||||||
token: body.turnstileToken || '',
|
token: req.turnstileToken || '',
|
||||||
ip,
|
ip,
|
||||||
logPrefix: '[register-interest]',
|
logPrefix: '[register-interest]',
|
||||||
});
|
});
|
||||||
if (!turnstileOk) {
|
if (!turnstileOk) {
|
||||||
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
|
throw new ApiError(403, 'Bot verification failed', '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, source, appVersion, referredBy } = body;
|
const { email, source, appVersion, referredBy } = req;
|
||||||
if (!email || typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
|
if (!email || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) {
|
||||||
return jsonResponse({ error: 'Invalid email address' }, 400, cors);
|
throw new ValidationError([{ field: 'email', description: 'Invalid email address' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailCheck = await validateEmail(email);
|
const emailCheck = await validateEmail(email);
|
||||||
if (!emailCheck.valid) {
|
if (!emailCheck.valid) {
|
||||||
return jsonResponse({ error: emailCheck.reason }, 400, cors);
|
throw new ValidationError([{ field: 'email', description: emailCheck.reason }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeSource = typeof source === 'string'
|
const safeSource = source ? source.slice(0, MAX_META_LENGTH) : 'unknown';
|
||||||
? source.slice(0, MAX_META_LENGTH)
|
const safeAppVersion = appVersion ? appVersion.slice(0, MAX_META_LENGTH) : 'unknown';
|
||||||
: 'unknown';
|
const safeReferredBy = referredBy ? referredBy.slice(0, 20) : undefined;
|
||||||
const safeAppVersion = typeof appVersion === 'string'
|
|
||||||
? appVersion.slice(0, MAX_META_LENGTH)
|
|
||||||
: 'unknown';
|
|
||||||
const safeReferredBy = typeof referredBy === 'string'
|
|
||||||
? referredBy.slice(0, 20)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const convexUrl = process.env.CONVEX_URL;
|
const convexUrl = process.env.CONVEX_URL;
|
||||||
if (!convexUrl) {
|
if (!convexUrl) {
|
||||||
return jsonResponse({ error: 'Registration service unavailable' }, 503, cors);
|
throw new ApiError(503, 'Registration service unavailable', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const client = new ConvexHttpClient(convexUrl);
|
||||||
const client = new ConvexHttpClient(convexUrl);
|
const result = (await client.mutation('registerInterest:register' as any, {
|
||||||
const result = await client.mutation('registerInterest:register', {
|
email,
|
||||||
email,
|
source: safeSource,
|
||||||
source: safeSource,
|
appVersion: safeAppVersion,
|
||||||
appVersion: safeAppVersion,
|
referredBy: safeReferredBy,
|
||||||
referredBy: safeReferredBy,
|
})) as ConvexRegisterResult;
|
||||||
});
|
|
||||||
|
|
||||||
// Send confirmation email for new registrations (awaited to avoid Edge isolate termination)
|
if (result.status === 'registered' && result.referralCode) {
|
||||||
// Skip if email is on the suppression list (previously bounced)
|
if (!result.emailSuppressed) {
|
||||||
if (result.status === 'registered' && result.referralCode) {
|
await sendConfirmationEmail(email, result.referralCode);
|
||||||
if (!result.emailSuppressed) {
|
} else {
|
||||||
await sendConfirmationEmail(email, result.referralCode);
|
console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
|
||||||
} else {
|
|
||||||
console.log(`[register-interest] Skipped email to suppressed address: ${email}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(result, 200, cors);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[register-interest] Convex error:', err);
|
|
||||||
return jsonResponse({ error: 'Registration failed' }, 500, cors);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
referralCode: result.referralCode,
|
||||||
|
referralCount: result.referralCount,
|
||||||
|
position: result.position ?? 0,
|
||||||
|
emailSuppressed: result.emailSuppressed ?? false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
export const config = { runtime: 'edge' };
|
/**
|
||||||
|
* RPC: submitContact -- Stores an enterprise contact submission and emails ops.
|
||||||
|
* Port from api/contact.js
|
||||||
|
* Sources: Convex contactMessages:submit mutation + Resend notification email
|
||||||
|
*/
|
||||||
|
|
||||||
import { ConvexHttpClient } from 'convex/browser';
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
import type {
|
||||||
import { getClientIp, verifyTurnstile } from './_turnstile.js';
|
ServerContext,
|
||||||
import { jsonResponse } from './_json-response.js';
|
SubmitContactRequest,
|
||||||
import { createIpRateLimiter } from './_ip-rate-limit.js';
|
SubmitContactResponse,
|
||||||
|
} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||||
|
import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server';
|
||||||
|
import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile';
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
|
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
|
||||||
const MAX_FIELD = 500;
|
const MAX_FIELD = 500;
|
||||||
const MAX_MESSAGE = 2000;
|
const MAX_MESSAGE = 2000;
|
||||||
|
|
||||||
const FREE_EMAIL_DOMAINS = new Set([
|
const FREE_EMAIL_DOMAINS = new Set<string>([
|
||||||
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp',
|
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp',
|
||||||
'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr',
|
'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr',
|
||||||
'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
|
'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
|
||||||
@@ -24,12 +31,27 @@ const FREE_EMAIL_DOMAINS = new Set([
|
|||||||
't-online.de', 'libero.it', 'virgilio.it',
|
't-online.de', 'libero.it', 'virgilio.it',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const RATE_LIMIT = 3;
|
function escapeHtml(str: string): string {
|
||||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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;
|
const resendKey = process.env.RESEND_API_KEY;
|
||||||
if (!resendKey) {
|
if (!resendKey) {
|
||||||
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent');
|
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent');
|
||||||
@@ -77,109 +99,80 @@ async function sendNotificationEmail(name, email, organization, phone, message,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
export async function submitContact(
|
||||||
return str
|
ctx: ServerContext,
|
||||||
.replace(/&/g, '&')
|
req: SubmitContactRequest,
|
||||||
.replace(/</g, '<')
|
): Promise<SubmitContactResponse> {
|
||||||
.replace(/>/g, '>')
|
// Honeypot — silently accept but do nothing (bots auto-fill hidden field).
|
||||||
.replace(/"/g, '"');
|
if (req.website) {
|
||||||
}
|
return { status: 'sent', emailSent: false };
|
||||||
|
|
||||||
function sanitizeForSubject(str, maxLen = 50) {
|
|
||||||
return str.replace(/[\r\n\0]/g, '').slice(0, maxLen);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req) {
|
|
||||||
if (isDisallowedOrigin(req)) {
|
|
||||||
return jsonResponse({ error: 'Origin not allowed' }, 403);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cors = getCorsHeaders(req, 'POST, OPTIONS');
|
const ip = getClientIp(ctx.request);
|
||||||
|
const country = ctx.request.headers.get('cf-ipcountry')
|
||||||
if (req.method === 'OPTIONS') {
|
|| ctx.request.headers.get('x-vercel-ip-country');
|
||||||
return new Response(null, { status: 204, headers: cors });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
|
||||||
const country = req.headers.get('cf-ipcountry') || req.headers.get('x-vercel-ip-country') || null;
|
|
||||||
|
|
||||||
if (rateLimiter.isRateLimited(ip)) {
|
|
||||||
return jsonResponse({ error: 'Too many requests' }, 429, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await req.json();
|
|
||||||
} catch {
|
|
||||||
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.website) {
|
|
||||||
return jsonResponse({ status: 'sent' }, 200, cors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const turnstileOk = await verifyTurnstile({
|
const turnstileOk = await verifyTurnstile({
|
||||||
token: body.turnstileToken || '',
|
token: req.turnstileToken || '',
|
||||||
ip,
|
ip,
|
||||||
logPrefix: '[contact]',
|
logPrefix: '[contact]',
|
||||||
missingSecretPolicy: 'allow-in-development',
|
missingSecretPolicy: 'allow-in-development',
|
||||||
});
|
});
|
||||||
if (!turnstileOk) {
|
if (!turnstileOk) {
|
||||||
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
|
throw new ApiError(403, 'Bot verification failed', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, name, organization, phone, message, source } = body;
|
const { email, name, organization, phone, message, source } = req;
|
||||||
|
|
||||||
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
|
if (!email || !EMAIL_RE.test(email)) {
|
||||||
return jsonResponse({ error: 'Invalid email' }, 400, cors);
|
throw new ValidationError([{ field: 'email', description: 'Invalid email' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailDomain = email.split('@')[1]?.toLowerCase();
|
const emailDomain = email.split('@')[1]?.toLowerCase();
|
||||||
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
|
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
|
||||||
return jsonResponse({ error: 'Please use your work email address' }, 422, cors);
|
throw new ApiError(422, 'Please use your work email address', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
return jsonResponse({ error: 'Name is required' }, 400, cors);
|
throw new ValidationError([{ field: 'name', description: 'Name is required' }]);
|
||||||
}
|
}
|
||||||
if (!organization || typeof organization !== 'string' || organization.trim().length === 0) {
|
if (!organization || organization.trim().length === 0) {
|
||||||
return jsonResponse({ error: 'Company is required' }, 400, cors);
|
throw new ValidationError([{ field: 'organization', description: 'Company is required' }]);
|
||||||
}
|
}
|
||||||
if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) {
|
if (!phone || !PHONE_RE.test(phone.trim())) {
|
||||||
return jsonResponse({ error: 'Valid phone number is required' }, 400, cors);
|
throw new ValidationError([{ field: 'phone', description: 'Valid phone number is required' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeName = name.slice(0, MAX_FIELD);
|
const safeName = name.slice(0, MAX_FIELD);
|
||||||
const safeOrg = organization.slice(0, MAX_FIELD);
|
const safeOrg = organization.slice(0, MAX_FIELD);
|
||||||
const safePhone = phone.trim().slice(0, 30);
|
const safePhone = phone.trim().slice(0, 30);
|
||||||
const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined;
|
const safeMsg = message ? message.slice(0, MAX_MESSAGE) : undefined;
|
||||||
const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact';
|
const safeSource = source ? source.slice(0, 100) : 'enterprise-contact';
|
||||||
|
|
||||||
const convexUrl = process.env.CONVEX_URL;
|
const convexUrl = process.env.CONVEX_URL;
|
||||||
if (!convexUrl) {
|
if (!convexUrl) {
|
||||||
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
|
throw new ApiError(503, 'Service unavailable', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const client = new ConvexHttpClient(convexUrl);
|
||||||
const client = new ConvexHttpClient(convexUrl);
|
await client.mutation('contactMessages:submit' as any, {
|
||||||
await client.mutation('contactMessages:submit', {
|
name: safeName,
|
||||||
name: safeName,
|
email: email.trim(),
|
||||||
email: email.trim(),
|
organization: safeOrg,
|
||||||
organization: safeOrg,
|
phone: safePhone,
|
||||||
phone: safePhone,
|
message: safeMsg,
|
||||||
message: safeMsg,
|
source: safeSource,
|
||||||
source: safeSource,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg, ip, country);
|
const emailSent = await sendNotificationEmail(
|
||||||
|
safeName,
|
||||||
|
email.trim(),
|
||||||
|
safeOrg,
|
||||||
|
safePhone,
|
||||||
|
safeMsg,
|
||||||
|
ip,
|
||||||
|
country,
|
||||||
|
);
|
||||||
|
|
||||||
return jsonResponse({ status: 'sent', emailSent }, 200, cors);
|
return { status: 'sent', emailSent };
|
||||||
} catch (err) {
|
|
||||||
console.error('[contact] error:', err);
|
|
||||||
return jsonResponse({ error: 'Failed to send message' }, 500, cors);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1212,10 +1212,14 @@ async function dispatch(requestUrl, req, routes, context) {
|
|||||||
}
|
}
|
||||||
// Registration — call Convex directly when CONVEX_URL is available (self-hosted),
|
// Registration — call Convex directly when CONVEX_URL is available (self-hosted),
|
||||||
// otherwise proxy to cloud (desktop sidecar never has CONVEX_URL).
|
// otherwise proxy to cloud (desktop sidecar never has CONVEX_URL).
|
||||||
|
// Keeps the legacy /api/register-interest local path so older desktop builds
|
||||||
|
// continue to work; cloud fallback rewrites to the new sebuf RPC path.
|
||||||
if (requestUrl.pathname === '/api/register-interest' && req.method === 'POST') {
|
if (requestUrl.pathname === '/api/register-interest' && req.method === 'POST') {
|
||||||
const convexUrl = process.env.CONVEX_URL;
|
const convexUrl = process.env.CONVEX_URL;
|
||||||
if (!convexUrl) {
|
if (!convexUrl) {
|
||||||
const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'no CONVEX_URL');
|
const cloudUrl = new URL(requestUrl);
|
||||||
|
cloudUrl.pathname = '/api/leads/v1/register-interest';
|
||||||
|
const cloudResponse = await tryCloudFallback(cloudUrl, req, context, 'no CONVEX_URL');
|
||||||
if (cloudResponse) return cloudResponse;
|
if (cloudResponse) return cloudResponse;
|
||||||
return json({ error: 'Registration service unavailable' }, 503);
|
return json({ error: 'Registration service unavailable' }, 503);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
function isKeyFreeApiTarget(target: string): boolean {
|
||||||
return target.startsWith('/api/register-interest') || target.startsWith('/api/version');
|
return target.startsWith('/api/register-interest')
|
||||||
|
|| target.startsWith('/api/leads/v1/register-interest')
|
||||||
|
|| target.startsWith('/api/leads/v1/submit-contact')
|
||||||
|
|| target.startsWith('/api/version');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLocalWithStartupRetry(
|
async function fetchLocalWithStartupRetry(
|
||||||
|
|||||||
@@ -1,192 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Functional tests for LeadsService.SubmitContact handler.
|
||||||
|
* Tests the typed handler directly (not the HTTP gateway).
|
||||||
|
*/
|
||||||
|
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
function makeRequest(body, opts = {}) {
|
function makeCtx(headers = {}) {
|
||||||
return new Request('https://worldmonitor.app/api/contact', {
|
const req = new Request('https://worldmonitor.app/api/leads/v1/submit-contact', {
|
||||||
method: opts.method || 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'origin': 'https://worldmonitor.app',
|
|
||||||
...(opts.headers || {}),
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
});
|
||||||
|
return { request: req, pathParams: {}, headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validBody(overrides = {}) {
|
function validReq(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
name: 'Test User',
|
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
organization: 'TestCorp',
|
organization: 'TestCorp',
|
||||||
phone: '+1 555 123 4567',
|
phone: '+1 555 123 4567',
|
||||||
message: 'Hello',
|
message: 'Hello',
|
||||||
source: 'enterprise-contact',
|
source: 'enterprise-contact',
|
||||||
|
website: '',
|
||||||
turnstileToken: 'valid-token',
|
turnstileToken: 'valid-token',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let handler;
|
let submitContact;
|
||||||
|
let ValidationError;
|
||||||
|
let ApiError;
|
||||||
|
|
||||||
describe('api/contact', () => {
|
describe('LeadsService.submitContact', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
process.env.CONVEX_URL = 'https://fake-convex.cloud';
|
process.env.CONVEX_URL = 'https://fake-convex.cloud';
|
||||||
process.env.TURNSTILE_SECRET_KEY = 'test-secret';
|
process.env.TURNSTILE_SECRET_KEY = 'test-secret';
|
||||||
process.env.RESEND_API_KEY = 'test-resend-key';
|
process.env.RESEND_API_KEY = 'test-resend-key';
|
||||||
process.env.VERCEL_ENV = 'production';
|
process.env.VERCEL_ENV = 'production';
|
||||||
|
|
||||||
// Re-import to get fresh module state (rate limiter)
|
// Handler + error classes share one module instance so `instanceof` works.
|
||||||
const mod = await import(`../api/contact.js?t=${Date.now()}`);
|
const mod = await import('../server/worldmonitor/leads/v1/submit-contact.ts');
|
||||||
handler = mod.default;
|
submitContact = mod.submitContact;
|
||||||
|
const gen = await import('../src/generated/server/worldmonitor/leads/v1/service_server.ts');
|
||||||
|
ValidationError = gen.ValidationError;
|
||||||
|
ApiError = gen.ApiError;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
Object.keys(process.env).forEach(k => {
|
Object.keys(process.env).forEach((k) => {
|
||||||
if (!(k in originalEnv)) delete process.env[k];
|
if (!(k in originalEnv)) delete process.env[k];
|
||||||
});
|
});
|
||||||
Object.assign(process.env, originalEnv);
|
Object.assign(process.env, originalEnv);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validation', () => {
|
describe('validation', () => {
|
||||||
it('rejects GET requests', async () => {
|
it('rejects missing email with ValidationError', async () => {
|
||||||
const res = await handler(new Request('https://worldmonitor.app/api/contact', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { origin: 'https://worldmonitor.app' },
|
|
||||||
}));
|
|
||||||
assert.equal(res.status, 405);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing email', async () => {
|
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ email: '' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 400);
|
() => submitContact(makeCtx(), validReq({ email: '' })),
|
||||||
const data = await res.json();
|
(err) => err instanceof ValidationError && err.violations[0].field === 'email',
|
||||||
assert.match(data.error, /email/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid email format', async () => {
|
it('rejects invalid email format', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ email: 'not-an-email' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 400);
|
() => submitContact(makeCtx(), validReq({ email: 'not-an-email' })),
|
||||||
|
(err) => err instanceof ValidationError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects missing name', async () => {
|
it('rejects missing name', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ name: '' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 400);
|
() => submitContact(makeCtx(), validReq({ name: '' })),
|
||||||
const data = await res.json();
|
(err) => err instanceof ValidationError && err.violations[0].field === 'name',
|
||||||
assert.match(data.error, /name/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects free email domains with 422', async () => {
|
it('rejects free email domains with 422 ApiError', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ email: 'test@gmail.com' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 422);
|
() => submitContact(makeCtx(), validReq({ email: 'test@gmail.com' })),
|
||||||
const data = await res.json();
|
(err) => err instanceof ApiError && err.statusCode === 422 && /work email/i.test(err.message),
|
||||||
assert.match(data.error, /work email/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects missing organization', async () => {
|
it('rejects missing organization', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ organization: '' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 400);
|
() => submitContact(makeCtx(), validReq({ organization: '' })),
|
||||||
const data = await res.json();
|
(err) => err instanceof ValidationError && err.violations[0].field === 'organization',
|
||||||
assert.match(data.error, /company/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects missing phone', async () => {
|
it('rejects missing phone', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ phone: '' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 400);
|
() => submitContact(makeCtx(), validReq({ phone: '' })),
|
||||||
const data = await res.json();
|
(err) => err instanceof ValidationError && err.violations[0].field === 'phone',
|
||||||
assert.match(data.error, /phone/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid phone format', async () => {
|
it('rejects invalid phone format', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody({ phone: '(((((' })));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 400);
|
() => submitContact(makeCtx(), validReq({ phone: '(((((' })),
|
||||||
|
(err) => err instanceof ValidationError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects disallowed origins', async () => {
|
it('silently accepts honeypot submissions without calling upstreams', async () => {
|
||||||
const req = new Request('https://worldmonitor.app/api/contact', {
|
let fetchCalled = false;
|
||||||
method: 'POST',
|
globalThis.fetch = async () => { fetchCalled = true; return new Response('{}'); };
|
||||||
headers: { 'Content-Type': 'application/json', origin: 'https://evil.com' },
|
const res = await submitContact(makeCtx(), validReq({ website: 'http://spam.com' }));
|
||||||
body: JSON.stringify(validBody()),
|
assert.equal(res.status, 'sent');
|
||||||
});
|
assert.equal(res.emailSent, false);
|
||||||
const res = await handler(req);
|
assert.equal(fetchCalled, false);
|
||||||
assert.equal(res.status, 403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('silently accepts honeypot submissions', async () => {
|
|
||||||
const res = await handler(makeRequest(validBody({ website: 'http://spam.com' })));
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
const data = await res.json();
|
|
||||||
assert.equal(data.status, 'sent');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Turnstile handling', () => {
|
describe('Turnstile handling', () => {
|
||||||
it('rejects when Turnstile verification fails', async () => {
|
it('rejects when Turnstile verification fails', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) {
|
if (typeof url === 'string' && url.includes('turnstile')) {
|
||||||
return new Response(JSON.stringify({ success: false }));
|
return new Response(JSON.stringify({ success: false }));
|
||||||
}
|
}
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 403);
|
() => submitContact(makeCtx(), validReq()),
|
||||||
const data = await res.json();
|
(err) => err instanceof ApiError && err.statusCode === 403 && /bot/i.test(err.message),
|
||||||
assert.match(data.error, /bot/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects in production when TURNSTILE_SECRET_KEY is unset', async () => {
|
it('rejects in production when TURNSTILE_SECRET_KEY is unset', async () => {
|
||||||
delete process.env.TURNSTILE_SECRET_KEY;
|
delete process.env.TURNSTILE_SECRET_KEY;
|
||||||
process.env.VERCEL_ENV = 'production';
|
process.env.VERCEL_ENV = 'production';
|
||||||
globalThis.fetch = async () => new Response('{}');
|
globalThis.fetch = async () => new Response('{}');
|
||||||
const res = await handler(makeRequest(validBody()));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 403);
|
() => submitContact(makeCtx(), validReq()),
|
||||||
|
(err) => err instanceof ApiError && err.statusCode === 403,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows in development when TURNSTILE_SECRET_KEY is unset', async () => {
|
it('allows in development when TURNSTILE_SECRET_KEY is unset', async () => {
|
||||||
delete process.env.TURNSTILE_SECRET_KEY;
|
delete process.env.TURNSTILE_SECRET_KEY;
|
||||||
process.env.VERCEL_ENV = 'development';
|
process.env.VERCEL_ENV = 'development';
|
||||||
let convexCalled = false;
|
globalThis.fetch = async (url) => {
|
||||||
globalThis.fetch = async (url, _opts) => {
|
if (typeof url === 'string' && url.includes('fake-convex')) {
|
||||||
if (url.includes('fake-convex')) {
|
|
||||||
convexCalled = true;
|
|
||||||
return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
||||||
}
|
}
|
||||||
if (url.includes('resend')) return new Response(JSON.stringify({ id: '1' }));
|
if (typeof url === 'string' && url.includes('resend')) return new Response(JSON.stringify({ id: '1' }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
const res = await submitContact(makeCtx(), validReq());
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 'sent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,79 +189,72 @@ describe('api/contact', () => {
|
|||||||
it('returns emailSent: false when RESEND_API_KEY is missing', async () => {
|
it('returns emailSent: false when RESEND_API_KEY is missing', async () => {
|
||||||
delete process.env.RESEND_API_KEY;
|
delete process.env.RESEND_API_KEY;
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
const res = await submitContact(makeCtx(), validReq());
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 'sent');
|
||||||
const data = await res.json();
|
assert.equal(res.emailSent, false);
|
||||||
assert.equal(data.status, 'sent');
|
|
||||||
assert.equal(data.emailSent, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns emailSent: false when Resend API returns error', async () => {
|
it('returns emailSent: false when Resend API returns error', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
||||||
if (url.includes('resend')) return new Response('Rate limited', { status: 429 });
|
if (typeof url === 'string' && url.includes('resend')) return new Response('Rate limited', { status: 429 });
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
const res = await submitContact(makeCtx(), validReq());
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 'sent');
|
||||||
const data = await res.json();
|
assert.equal(res.emailSent, false);
|
||||||
assert.equal(data.status, 'sent');
|
|
||||||
assert.equal(data.emailSent, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns emailSent: true on successful notification', async () => {
|
it('returns emailSent: true on successful notification', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
||||||
if (url.includes('resend')) return new Response(JSON.stringify({ id: 'msg_123' }));
|
if (typeof url === 'string' && url.includes('resend')) return new Response(JSON.stringify({ id: 'msg_123' }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
const res = await submitContact(makeCtx(), validReq());
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 'sent');
|
||||||
const data = await res.json();
|
assert.equal(res.emailSent, true);
|
||||||
assert.equal(data.status, 'sent');
|
|
||||||
assert.equal(data.emailSent, true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still succeeds (stores in Convex) even when email fails', async () => {
|
it('still succeeds (stores in Convex) even when email fails', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } }));
|
||||||
if (url.includes('resend')) throw new Error('Network failure');
|
if (typeof url === 'string' && url.includes('resend')) throw new Error('Network failure');
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
const res = await submitContact(makeCtx(), validReq());
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 'sent');
|
||||||
const data = await res.json();
|
assert.equal(res.emailSent, false);
|
||||||
assert.equal(data.status, 'sent');
|
|
||||||
assert.equal(data.emailSent, false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Convex storage', () => {
|
describe('Convex storage', () => {
|
||||||
it('returns 503 when CONVEX_URL is missing', async () => {
|
it('throws 503 ApiError when CONVEX_URL is missing', async () => {
|
||||||
delete process.env.CONVEX_URL;
|
delete process.env.CONVEX_URL;
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
await assert.rejects(
|
||||||
assert.equal(res.status, 503);
|
() => submitContact(makeCtx(), validReq()),
|
||||||
|
(err) => err instanceof ApiError && err.statusCode === 503,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 when Convex mutation fails', async () => {
|
it('propagates Convex failure', async () => {
|
||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
|
||||||
if (url.includes('fake-convex')) return new Response('Internal error', { status: 500 });
|
if (typeof url === 'string' && url.includes('fake-convex')) return new Response('Internal error', { status: 500 });
|
||||||
return new Response('{}');
|
return new Response('{}');
|
||||||
};
|
};
|
||||||
const res = await handler(makeRequest(validBody()));
|
await assert.rejects(() => submitContact(makeCtx(), validReq()));
|
||||||
assert.equal(res.status, 500);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function mockFetch(mxResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import after fetch is available (module is Edge-compatible, no node: imports)
|
// Import after fetch is available (module is Edge-compatible, no node: imports)
|
||||||
const { validateEmail } = await import('../api/_email-validation.js');
|
const { validateEmail } = await import('../server/_shared/email-validation.ts');
|
||||||
|
|
||||||
describe('validateEmail', () => {
|
describe('validateEmail', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getClientIp, verifyTurnstile } from './_turnstile.js';
|
import { getClientIp, verifyTurnstile } from '../server/_shared/turnstile.ts';
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
@@ -195,6 +195,7 @@ function sebufApiPlugin(): Plugin {
|
|||||||
supplyChainServerMod, supplyChainHandlerMod,
|
supplyChainServerMod, supplyChainHandlerMod,
|
||||||
naturalServerMod, naturalHandlerMod,
|
naturalServerMod, naturalHandlerMod,
|
||||||
resilienceServerMod, resilienceHandlerMod,
|
resilienceServerMod, resilienceHandlerMod,
|
||||||
|
leadsServerMod, leadsHandlerMod,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import('./server/router'),
|
import('./server/router'),
|
||||||
import('./server/cors'),
|
import('./server/cors'),
|
||||||
@@ -245,6 +246,8 @@ function sebufApiPlugin(): Plugin {
|
|||||||
import('./server/worldmonitor/natural/v1/handler'),
|
import('./server/worldmonitor/natural/v1/handler'),
|
||||||
import('./src/generated/server/worldmonitor/resilience/v1/service_server'),
|
import('./src/generated/server/worldmonitor/resilience/v1/service_server'),
|
||||||
import('./server/worldmonitor/resilience/v1/handler'),
|
import('./server/worldmonitor/resilience/v1/handler'),
|
||||||
|
import('./src/generated/server/worldmonitor/leads/v1/service_server'),
|
||||||
|
import('./server/worldmonitor/leads/v1/handler'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const serverOptions = { onError: errorMod.mapErrorToResponse };
|
const serverOptions = { onError: errorMod.mapErrorToResponse };
|
||||||
@@ -272,6 +275,7 @@ function sebufApiPlugin(): Plugin {
|
|||||||
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
||||||
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
||||||
...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions),
|
...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions),
|
||||||
|
...leadsServerMod.createLeadsServiceRoutes(leadsHandlerMod.leadsHandler, serverOptions),
|
||||||
];
|
];
|
||||||
cachedCorsMod = corsMod;
|
cachedCorsMod = corsMod;
|
||||||
return routerMod.createRouter(allRoutes);
|
return routerMod.createRouter(allRoutes);
|
||||||
|
|||||||
Reference in New Issue
Block a user