diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..f39c41652 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# Adding or modifying api-route-exceptions.json opts an endpoint out of the +# sebuf contract. Every change here needs review to prevent drift. +/api/api-route-exceptions.json @SebastienMelki + +# The enforcement script and its CI wiring are the teeth behind the manifest. +/scripts/enforce-sebuf-api-contract.mjs @SebastienMelki diff --git a/.github/workflows/lint-code.yml b/.github/workflows/lint-code.yml index 683b065c1..4e274bafe 100644 --- a/.github/workflows/lint-code.yml +++ b/.github/workflows/lint-code.yml @@ -43,6 +43,7 @@ jobs: - run: npm run lint:unicode - run: npm run lint - run: npm run lint:boundaries + - run: npm run lint:api-contract - name: Markdown lint run: npm run lint:md - name: Version sync check diff --git a/.husky/pre-push b/.husky/pre-push index 746c095f8..21dabaca7 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -65,6 +65,9 @@ node scripts/check-unicode-safety.mjs || exit 1 echo "Running architectural boundary check..." npm run lint:boundaries || exit 1 +echo "Running rate-limit policy coverage check..." +npm run lint:rate-limit-policies || exit 1 + echo "Running edge function bundle check..." while IFS= read -r f; do npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null 2>/dev/null || { diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d77316d5..4e2661188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,23 @@ All notable changes to World Monitor are documented here. - PortWatch, CorridorRisk, and transit seed loops on Railway relay (#1560) - R2 trace storage for forecast debugging with Cloudflare API upload (#1655) +### Changed + +- **Sebuf API migration (#3207)** — scenario + supply-chain endpoints migrated to the typed sebuf contract. RPC URLs now derive from method names; the five renamed v1 URLs remain live as thin aliases so existing integrations keep working: + - `/api/scenario/v1/run` → `/api/scenario/v1/run-scenario` + - `/api/scenario/v1/status` → `/api/scenario/v1/get-scenario-status` + - `/api/scenario/v1/templates` → `/api/scenario/v1/list-scenario-templates` + - `/api/supply-chain/v1/country-products` → `/api/supply-chain/v1/get-country-products` + - `/api/supply-chain/v1/multi-sector-cost-shock` → `/api/supply-chain/v1/get-multi-sector-cost-shock` + + Aliases will retire at the next v1→v2 break ([#3282](https://github.com/koala73/worldmonitor/issues/3282)). + +- `POST /api/scenario/v1/run-scenario` now returns `200 OK` instead of the pre-migration `202 Accepted` on successful enqueue. sebuf's HTTP annotations don't support per-RPC status codes. Branch on response body `status === "pending"` instead of `response.status === 202`. `statusUrl` is preserved. + ### Security - CDN-Cache-Control header now only set for trusted origins (worldmonitor.app, Vercel previews, Tauri); no-origin server-side requests always reach the edge function so `validateApiKey` can run, closing a potential cache-bypass path for external scrapers +- **Shipping v2 webhook tenant isolation (#3242)** — `POST /api/v2/shipping/webhooks` (register) and `GET /api/v2/shipping/webhooks` (list) now enforce `validateApiKey(req, { forceKey: true })`, matching the sibling `[subscriberId]{,/[action]}` routes and the documented contract in `docs/api-shipping-v2.mdx`. Without this gate, a Clerk-authenticated pro user with no API key would fall through `callerFingerprint()` to the shared `'anon'` bucket and see/overwrite webhooks owned by other `'anon'`-bucket tenants. ### Fixed diff --git a/api/_ip-rate-limit.js b/api/_ip-rate-limit.js deleted file mode 100644 index 27f3e1a62..000000000 --- a/api/_ip-rate-limit.js +++ /dev/null @@ -1,20 +0,0 @@ -export function createIpRateLimiter({ limit, windowMs }) { - const rateLimitMap = new Map(); - - function getEntry(ip) { - return rateLimitMap.get(ip) || null; - } - - function isRateLimited(ip) { - const now = Date.now(); - const entry = getEntry(ip); - if (!entry || now - entry.windowStart > windowMs) { - rateLimitMap.set(ip, { windowStart: now, count: 1 }); - return false; - } - entry.count += 1; - return entry.count > limit; - } - - return { isRateLimited, getEntry }; -} diff --git a/api/ais-snapshot.js b/api/ais-snapshot.js deleted file mode 100644 index 09c96acb9..000000000 --- a/api/ais-snapshot.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createRelayHandler } from './_relay.js'; - -export const config = { runtime: 'edge' }; - -export default createRelayHandler({ - relayPath: '/ais/snapshot', - timeout: 12000, - requireApiKey: true, - requireRateLimit: true, - cacheHeaders: (ok) => ({ - 'Cache-Control': ok - ? 'public, max-age=60, s-maxage=300, stale-while-revalidate=600, stale-if-error=900' - : 'public, max-age=10, s-maxage=30, stale-while-revalidate=120', - ...(ok && { 'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600, stale-if-error=900' }), - }), -}); diff --git a/api/api-route-exceptions.json b/api/api-route-exceptions.json new file mode 100644 index 000000000..13e178012 --- /dev/null +++ b/api/api-route-exceptions.json @@ -0,0 +1,398 @@ +{ + "$comment": "Single source of truth for non-proto /api/ endpoints. All new JSON data APIs MUST use sebuf (proto → buf generate → handler). This manifest is the only escape hatch, and every entry is reviewed by @SebastienMelki (see .github/CODEOWNERS). Categories: external-protocol (MCP / OAuth — shape dictated by external spec), non-json (binary/HTML/image responses), upstream-proxy (raw pass-through of an external feed), ops-admin (health/cron/version — operator plumbing, not a product API), internal-helper (dashboard-internal bundle, not user-facing), deferred (should migrate eventually — must have a removal_issue), migration-pending (actively being migrated in an open PR — removed as its commit lands). See docs/adding-endpoints.mdx.", + "schema_version": 1, + "exceptions": [ + { + "path": "api/mcp.ts", + "category": "external-protocol", + "reason": "MCP streamable-HTTP transport — JSON-RPC 2.0 envelope dictated by the Model Context Protocol spec, not something we can or should redefine as proto.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/mcp-proxy.js", + "category": "external-protocol", + "reason": "Proxy for the MCP transport — same shape constraint as api/mcp.ts.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/oauth/authorize.js", + "category": "external-protocol", + "reason": "OAuth 2.0 authorization endpoint. Response is an HTML consent page and 302 redirect; request/response shape is dictated by RFC 6749.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/oauth/register.js", + "category": "external-protocol", + "reason": "OAuth 2.0 dynamic client registration (RFC 7591). Shape fixed by the spec.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/oauth/token.js", + "category": "external-protocol", + "reason": "OAuth 2.0 token endpoint (RFC 6749). application/x-www-form-urlencoded request body; shape fixed by the spec.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/discord/oauth/callback.ts", + "category": "external-protocol", + "reason": "Discord OAuth redirect target — response is an HTML popup-closer page, query-param shape fixed by Discord.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/discord/oauth/start.ts", + "category": "external-protocol", + "reason": "Discord OAuth initiator — issues 302 to Discord's authorize URL. Not a JSON API.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/slack/oauth/callback.ts", + "category": "external-protocol", + "reason": "Slack OAuth redirect target — HTML response with postMessage + window.close, query-param shape fixed by Slack.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/slack/oauth/start.ts", + "category": "external-protocol", + "reason": "Slack OAuth initiator — 302 to Slack's authorize URL. Not a JSON API.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + + { + "path": "api/download.js", + "category": "non-json", + "reason": "Binary file download (zip/csv/xlsx). Content-Type is not application/json.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/og-story.js", + "category": "non-json", + "reason": "Open Graph preview image (PNG via @vercel/og). Binary response.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/story.js", + "category": "non-json", + "reason": "Rendered HTML story page for social embeds — response is text/html, not JSON.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/youtube/embed.js", + "category": "non-json", + "reason": "YouTube oEmbed passthrough — shape dictated by YouTube's oEmbed response, served as-is.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/youtube/live.js", + "category": "non-json", + "reason": "Streams YouTube live metadata — chunked text response, not a typed JSON payload.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/brief/carousel/[userId]/[issueDate]/[page].ts", + "category": "non-json", + "reason": "Rendered carousel page image for brief social posts. Binary image response, dynamic path segments.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + + { + "path": "api/opensky.js", + "category": "upstream-proxy", + "reason": "Transparent proxy to OpenSky Network API. Shape is OpenSky's; we don't remodel it.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/polymarket.js", + "category": "upstream-proxy", + "reason": "Transparent proxy to Polymarket gamma API. Shape is Polymarket's.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/gpsjam.js", + "category": "upstream-proxy", + "reason": "Transparent proxy to gpsjam.org tile/feed. Shape is upstream's.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/oref-alerts.js", + "category": "upstream-proxy", + "reason": "Transparent proxy to Pikud HaOref (IDF Home Front Command) alert feed. Shape is upstream's.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/rss-proxy.js", + "category": "upstream-proxy", + "reason": "Generic RSS/Atom XML proxy for CORS-blocked feeds. Response is XML, not JSON.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/telegram-feed.js", + "category": "upstream-proxy", + "reason": "Telegram channel feed proxy — passes upstream MTProto-derived shape through.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/supply-chain/hormuz-tracker.js", + "category": "upstream-proxy", + "reason": "Transparent proxy to Hormuz strait AIS tracker feed. Shape is upstream's.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + + { + "path": "api/health.js", + "category": "ops-admin", + "reason": "Liveness probe hit by uptime monitor and load balancer. Not a product API; plain-text OK response.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/seed-health.js", + "category": "ops-admin", + "reason": "Cron-triggered data-freshness check for feed seeds. Operator tool, not a user-facing API.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/version.js", + "category": "ops-admin", + "reason": "Build-version probe for the desktop auto-updater. Tiny plain-JSON operator plumbing.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/cache-purge.js", + "category": "ops-admin", + "reason": "Admin-gated cache invalidation endpoint. Internal operator action, not a product API.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/seed-contract-probe.ts", + "category": "ops-admin", + "reason": "Cron probe that verifies seed contract shapes against upstreams. Operator telemetry.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/invalidate-user-api-key-cache.ts", + "category": "ops-admin", + "reason": "Admin-gated cache bust for user API key lookups. Internal operator action.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/fwdstart.js", + "category": "ops-admin", + "reason": "Tauri desktop updater bootstrap — starts the sidecar forwarding flow. Operator plumbing, not JSON.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/notify.ts", + "category": "ops-admin", + "reason": "Outbound notification dispatch (Slack/Discord/email) driven by cron. Internal, not a typed user API.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + + { + "path": "api/bootstrap.js", + "category": "internal-helper", + "reason": "Dashboard-internal config bundle assembled at request time. Exposing as a user-facing API would implicitly commit us to its shape; keep it deliberately unversioned and out of the API surface.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/geo.js", + "category": "internal-helper", + "reason": "Lightweight IP-to-geo lookup wrapping Vercel's request.geo. Dashboard-internal helper; not worth a service of its own.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/reverse-geocode.js", + "category": "internal-helper", + "reason": "Reverse-geocode helper used only by the map layer for label rendering. Wraps an upstream provider; shape tracks upstream, not a versioned product contract.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/internal/brief-why-matters.ts", + "category": "internal-helper", + "reason": "Internal brief-pipeline helper — auth'd by RELAY_SHARED_SECRET (Railway cron only), not a user-facing API. Generated on merge of #3248 from main without a manifest entry; filed here to keep the lint green.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + { + "path": "api/data/city-coords.ts", + "category": "internal-helper", + "reason": "Static city-coordinates payload served from the deploy artifact. Returns a fixed reference table, not a queryable service.", + "owner": "@SebastienMelki", + "removal_issue": null + }, + + { + "path": "api/latest-brief.ts", + "category": "deferred", + "reason": "Returns the current user's latest brief. Auth-gated and Clerk-coupled; migrating requires modeling Brief in proto and auth context in handler. Deferred to brief/v1 service.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/brief/share-url.ts", + "category": "deferred", + "reason": "Creates a shareable public URL for a brief. Part of the brief/v1 service work.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/brief/public/[hash].ts", + "category": "deferred", + "reason": "Resolves a share hash to public-safe brief JSON. Part of the brief/v1 service work; dynamic path segment needs proto path-param modeling.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/brief/[userId]/[issueDate].ts", + "category": "deferred", + "reason": "Fetches a specific brief by user + issue date. Part of the brief/v1 service work.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/notification-channels.ts", + "category": "deferred", + "reason": "Lists / configures user notification channels. Auth-gated; migrating requires user/v1 or notifications/v1 service. Deferred until Clerk migration settles.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/user-prefs.ts", + "category": "deferred", + "reason": "Reads / writes user dashboard preferences. Auth-gated; part of user/v1 service work pending Clerk migration.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/create-checkout.ts", + "category": "deferred", + "reason": "Creates a Dodo Payments checkout session. Payments domain is still stabilizing (Clerk + Dodo integration); migrate once shape is frozen.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/customer-portal.ts", + "category": "deferred", + "reason": "Issues a Dodo customer-portal redirect URL. Paired with create-checkout.ts; migrate together.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/product-catalog.js", + "category": "deferred", + "reason": "Returns Dodo product catalog (pricing tiers). Migrate alongside the rest of the payments surface.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + { + "path": "api/referral/me.ts", + "category": "deferred", + "reason": "Returns the signed-in user's referral state. Auth-gated; part of user/v1 service.", + "owner": "@SebastienMelki", + "removal_issue": "TBD" + }, + + { + "path": "api/scenario/v1/run.ts", + "category": "deferred", + "reason": "URL-compat alias for POST /api/scenario/v1/run-scenario. Thin gateway wrapper that rewrites the documented pre-#3207 v1 URL to the canonical sebuf RPC path. Not a new endpoint — preserves the partner-documented wire contract. Retires at the next v1→v2 break.", + "owner": "@SebastienMelki", + "removal_issue": "#3282" + }, + { + "path": "api/scenario/v1/status.ts", + "category": "deferred", + "reason": "URL-compat alias for GET /api/scenario/v1/get-scenario-status. See api/scenario/v1/run.ts for the same rationale.", + "owner": "@SebastienMelki", + "removal_issue": "#3282" + }, + { + "path": "api/scenario/v1/templates.ts", + "category": "deferred", + "reason": "URL-compat alias for GET /api/scenario/v1/list-scenario-templates. See api/scenario/v1/run.ts for the same rationale.", + "owner": "@SebastienMelki", + "removal_issue": "#3282" + }, + { + "path": "api/supply-chain/v1/country-products.ts", + "category": "deferred", + "reason": "URL-compat alias for GET /api/supply-chain/v1/get-country-products. See api/scenario/v1/run.ts for the same rationale.", + "owner": "@SebastienMelki", + "removal_issue": "#3282" + }, + { + "path": "api/supply-chain/v1/multi-sector-cost-shock.ts", + "category": "deferred", + "reason": "URL-compat alias for GET /api/supply-chain/v1/get-multi-sector-cost-shock. See api/scenario/v1/run.ts for the same rationale.", + "owner": "@SebastienMelki", + "removal_issue": "#3282" + }, + + { + "path": "api/v2/shipping/webhooks/[subscriberId].ts", + "category": "migration-pending", + "reason": "Partner-facing path-parameter endpoint (GET status by subscriber id). Cannot migrate to sebuf yet — no path-param support in the annotation layer. Paired with the typed ShippingV2Service on the same base URL; tracked for eventual migration once sebuf path params are available.", + "owner": "@SebastienMelki", + "removal_issue": "#3207" + }, + { + "path": "api/v2/shipping/webhooks/[subscriberId]/[action].ts", + "category": "migration-pending", + "reason": "Partner-facing path-parameter endpoints (POST rotate-secret, POST reactivate). Cannot migrate to sebuf yet — no path-param support. Paired with the typed ShippingV2Service; tracked for eventual migration once sebuf path params are available.", + "owner": "@SebastienMelki", + "removal_issue": "#3207" + }, + { + "path": "api/chat-analyst.ts", + "category": "migration-pending", + "reason": "SSE streaming endpoint. Migrating to analyst/v1.ChatAnalyst (streaming RPC) in commit 9 of #3207. Blocked on sebuf#150 (TS-server SSE codegen).", + "owner": "@SebastienMelki", + "removal_issue": "#3207" + }, + { + "path": "api/widget-agent.ts", + "category": "migration-pending", + "reason": "Migrating to analyst/v1.WidgetComplete in commit 9 of #3207. Blocked on sebuf#150.", + "owner": "@SebastienMelki", + "removal_issue": "#3207" + }, + { + "path": "api/skills/fetch-agentskills.ts", + "category": "migration-pending", + "reason": "Migrating to analyst/v1.ListAgentSkills in commit 9 of #3207. Blocked on sebuf#150.", + "owner": "@SebastienMelki", + "removal_issue": "#3207" + } + ] +} diff --git a/api/eia/[[...path]].js b/api/eia/[[...path]].js deleted file mode 100644 index e66e96673..000000000 --- a/api/eia/[[...path]].js +++ /dev/null @@ -1,61 +0,0 @@ -// EIA (Energy Information Administration) passthrough. -// Redis-only reader. Railway seeder `seed-eia-petroleum.mjs` (bundled in -// `seed-bundle-energy-sources`) writes `energy:eia-petroleum:v1`; this -// endpoint reads from Redis and never hits api.eia.gov at request time. -// Gold standard per feedback_vercel_reads_only.md. - -import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js'; -import { readJsonFromUpstash } from '../_upstash-json.js'; - -export const config = { runtime: 'edge' }; - -const CANONICAL_KEY = 'energy:eia-petroleum:v1'; - -export default async function handler(req) { - const cors = getCorsHeaders(req); - if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors }); - } - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - if (req.method !== 'GET') { - return Response.json({ error: 'Method not allowed' }, { status: 405, headers: cors }); - } - - const url = new URL(req.url); - const path = url.pathname.replace('/api/eia', ''); - - if (path === '/health' || path === '') { - return Response.json({ configured: true }, { headers: cors }); - } - - if (path === '/petroleum') { - let data; - try { - data = await readJsonFromUpstash(CANONICAL_KEY, 3_000); - } catch { - data = null; - } - - if (!data) { - return Response.json( - { error: 'Data not yet seeded', hint: 'Retry in a few minutes' }, - { - status: 503, - headers: { ...cors, 'Cache-Control': 'no-store', 'Retry-After': '300' }, - }, - ); - } - - return Response.json(data, { - headers: { - ...cors, - 'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=86400', - }, - }); - } - - return Response.json({ error: 'Not found' }, { status: 404, headers: cors }); -} diff --git a/api/enrichment/_domain.js b/api/enrichment/_domain.js deleted file mode 100644 index f5e374576..000000000 --- a/api/enrichment/_domain.js +++ /dev/null @@ -1,19 +0,0 @@ -const DOMAIN_SUFFIX_RE = /\.(com|io|co|org|net|ai|dev|app)$/; - -export function toOrgSlugFromDomain(domain) { - return (domain || '') - .trim() - .toLowerCase() - .replace(DOMAIN_SUFFIX_RE, '') - .split('.') - .pop() || ''; -} - -export function inferCompanyNameFromDomain(domain) { - const orgSlug = toOrgSlugFromDomain(domain); - if (!orgSlug) return domain || ''; - - return orgSlug - .replace(/-/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} diff --git a/api/enrichment/company.js b/api/enrichment/company.js deleted file mode 100644 index 5d1bc6236..000000000 --- a/api/enrichment/company.js +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Company Enrichment API — Vercel Edge Function - * Aggregates company data from multiple public sources: - * - GitHub org data - * - Hacker News mentions - * - SEC EDGAR filings (public US companies) - * - Tech stack inference from GitHub repos - * - * GET /api/enrichment/company?domain=example.com - * GET /api/enrichment/company?name=Stripe - */ - -import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js'; -import { checkRateLimit } from '../_rate-limit.js'; -import { inferCompanyNameFromDomain, toOrgSlugFromDomain } from './_domain.js'; - -export const config = { runtime: 'edge' }; - -const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; -const CACHE_TTL_SECONDS = 3600; -const GITHUB_API_HEADERS = Object.freeze({ Accept: 'application/vnd.github.v3+json', 'User-Agent': UA }); - -async function fetchGitHubOrg(name) { - try { - const res = await fetch(`https://api.github.com/orgs/${encodeURIComponent(name)}`, { - headers: GITHUB_API_HEADERS, - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) return null; - const data = await res.json(); - return { - name: data.name || data.login, - description: data.description, - blog: data.blog, - location: data.location, - publicRepos: data.public_repos, - followers: data.followers, - avatarUrl: data.avatar_url, - createdAt: data.created_at, - }; - } catch { - return null; - } -} - -async function fetchGitHubTechStack(orgName) { - try { - const res = await fetch( - `https://api.github.com/orgs/${encodeURIComponent(orgName)}/repos?sort=stars&per_page=10`, - { - headers: GITHUB_API_HEADERS, - signal: AbortSignal.timeout(5000), - }, - ); - if (!res.ok) return []; - const repos = await res.json(); - const languages = new Map(); - for (const repo of repos) { - if (repo.language) { - languages.set(repo.language, (languages.get(repo.language) || 0) + repo.stargazers_count + 1); - } - } - return Array.from(languages.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([lang, score]) => ({ name: lang, category: 'Programming Language', confidence: Math.min(1, score / 100) })); - } catch { - return []; - } -} - -async function fetchSECData(companyName) { - try { - const res = await fetch( - `https://efts.sec.gov/LATEST/search-index?q=${encodeURIComponent(companyName)}&dateRange=custom&startdt=${getDateMonthsAgo(6)}&enddt=${getTodayISO()}&forms=10-K,10-Q,8-K&from=0&size=5`, - { - headers: { 'User-Agent': 'WorldMonitor research@worldmonitor.app', 'Accept': 'application/json' }, - signal: AbortSignal.timeout(8000), - }, - ); - if (!res.ok) return null; - const data = await res.json(); - if (!data.hits || !data.hits.hits || data.hits.hits.length === 0) return null; - return { - totalFilings: data.hits.total?.value || 0, - recentFilings: data.hits.hits.slice(0, 5).map((h) => ({ - form: h._source?.form_type || h._source?.file_type, - date: h._source?.file_date || h._source?.period_of_report, - description: h._source?.display_names?.[0] || companyName, - })), - }; - } catch { - return null; - } -} - -async function fetchHackerNewsMentions(companyName) { - try { - const res = await fetch( - `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(companyName)}&tags=story&hitsPerPage=5`, - { - headers: { 'User-Agent': UA }, - signal: AbortSignal.timeout(5000), - }, - ); - if (!res.ok) return []; - const data = await res.json(); - return (data.hits || []).map((h) => ({ - title: h.title, - url: h.url, - points: h.points, - comments: h.num_comments, - date: h.created_at, - })); - } catch { - return []; - } -} - -function getTodayISO() { - return toISODate(new Date()); -} - -function getDateMonthsAgo(months) { - const d = new Date(); - d.setMonth(d.getMonth() - months); - return toISODate(d); -} - -function toISODate(date) { - return date.toISOString().split('T')[0]; -} - -export default async function handler(req) { - const cors = getCorsHeaders(req, 'GET, OPTIONS'); - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - - if (isDisallowedOrigin(req)) { - return new Response('Forbidden', { status: 403, headers: cors }); - } - - const rateLimitResult = await checkRateLimit(req, 'enrichment', 30, '60s'); - if (rateLimitResult) return rateLimitResult; - - const url = new URL(req.url); - const domain = url.searchParams.get('domain')?.trim().toLowerCase(); - const name = url.searchParams.get('name')?.trim(); - - if (!domain && !name) { - return new Response(JSON.stringify({ error: 'Provide ?domain= or ?name= parameter' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const companyName = name || (domain ? inferCompanyNameFromDomain(domain) : 'Unknown'); - const searchName = domain ? toOrgSlugFromDomain(domain) : companyName.toLowerCase().replace(/\s+/g, ''); - - const [githubOrg, techStack, secData, hnMentions] = await Promise.all([ - fetchGitHubOrg(searchName), - fetchGitHubTechStack(searchName), - fetchSECData(companyName), - fetchHackerNewsMentions(companyName), - ]); - - const enrichedData = { - company: { - name: githubOrg?.name || companyName, - domain: domain || githubOrg?.blog?.replace(/^https?:\/\//, '').replace(/\/$/, '') || null, - description: githubOrg?.description || null, - location: githubOrg?.location || null, - website: githubOrg?.blog || (domain ? `https://${domain}` : null), - founded: githubOrg?.createdAt ? new Date(githubOrg.createdAt).getFullYear() : null, - }, - github: githubOrg ? { - publicRepos: githubOrg.publicRepos, - followers: githubOrg.followers, - avatarUrl: githubOrg.avatarUrl, - } : null, - techStack: techStack.length > 0 ? techStack : null, - secFilings: secData, - hackerNewsMentions: hnMentions.length > 0 ? hnMentions : null, - enrichedAt: new Date().toISOString(), - sources: [ - githubOrg ? 'github' : null, - techStack.length > 0 ? 'github_repos' : null, - secData ? 'sec_edgar' : null, - hnMentions.length > 0 ? 'hacker_news' : null, - ].filter(Boolean), - }; - - return new Response(JSON.stringify(enrichedData), { - status: 200, - headers: { - ...cors, - 'Content-Type': 'application/json', - 'Cache-Control': `public, s-maxage=${CACHE_TTL_SECONDS}, stale-while-revalidate=${CACHE_TTL_SECONDS * 2}`, - }, - }); -} diff --git a/api/enrichment/signals.js b/api/enrichment/signals.js deleted file mode 100644 index 74e504220..000000000 --- a/api/enrichment/signals.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Signal Discovery API — Vercel Edge Function - * Discovers activity signals for a company from public sources: - * - News mentions (Hacker News) - * - GitHub activity spikes - * - Job posting signals (HN hiring threads) - * - * GET /api/enrichment/signals?company=Stripe&domain=stripe.com - */ - -import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js'; -import { checkRateLimit } from '../_rate-limit.js'; -import { toOrgSlugFromDomain } from './_domain.js'; - -export const config = { runtime: 'edge' }; - -const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; -const UPSTREAM_TIMEOUT_MS = 5000; -const DEFAULT_HEADERS = Object.freeze({ 'User-Agent': UA }); -const GITHUB_HEADERS = Object.freeze({ Accept: 'application/vnd.github.v3+json', ...DEFAULT_HEADERS }); - -const SIGNAL_KEYWORDS = { - hiring_surge: ['hiring', 'we\'re hiring', 'join our team', 'open positions', 'new roles', 'growing team'], - funding_event: ['raised', 'funding', 'series', 'investment', 'valuation', 'backed by'], - expansion_signal: ['expansion', 'new office', 'opening', 'entering market', 'new region', 'international'], - technology_adoption: ['migrating to', 'adopting', 'implementing', 'rolling out', 'tech stack', 'infrastructure'], - executive_movement: ['appointed', 'joins as', 'new ceo', 'new cto', 'new vp', 'leadership change', 'promoted to'], - financial_trigger: ['revenue', 'ipo', 'acquisition', 'merger', 'quarterly results', 'earnings'], -}; - -function classifySignal(text) { - const lower = text.toLowerCase(); - for (const [type, keywords] of Object.entries(SIGNAL_KEYWORDS)) { - for (const kw of keywords) { - if (lower.includes(kw)) return type; - } - } - return 'press_release'; -} - -function scoreSignalStrength(points, comments, recencyDays) { - let score = 0; - if (points > 100) score += 3; - else if (points > 30) score += 2; - else score += 1; - - if (comments > 50) score += 2; - else if (comments > 10) score += 1; - - if (recencyDays <= 3) score += 3; - else if (recencyDays <= 7) score += 2; - else if (recencyDays <= 14) score += 1; - - if (score >= 7) return 'critical'; - if (score >= 5) return 'high'; - if (score >= 3) return 'medium'; - return 'low'; -} - -async function fetchHNSignals(companyName) { - try { - const res = await fetch( - `https://hn.algolia.com/api/v1/search_by_date?query=${encodeURIComponent(companyName)}&tags=story&hitsPerPage=20&numericFilters=created_at_i>${Math.floor(Date.now() / 1000) - 30 * 86400}`, - { - headers: DEFAULT_HEADERS, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }, - ); - if (!res.ok) return []; - const data = await res.json(); - const now = Date.now(); - - return (data.hits || []).map((h) => { - const recencyDays = (now - new Date(h.created_at).getTime()) / 86400000; - return { - type: classifySignal(h.title), - title: h.title, - url: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`, - source: 'Hacker News', - sourceTier: 2, - timestamp: h.created_at, - strength: scoreSignalStrength(h.points || 0, h.num_comments || 0, recencyDays), - engagement: { points: h.points, comments: h.num_comments }, - }; - }); - } catch { - return []; - } -} - -async function fetchGitHubSignals(orgName) { - try { - const res = await fetch( - `https://api.github.com/orgs/${encodeURIComponent(orgName)}/repos?sort=created&per_page=10`, - { - headers: GITHUB_HEADERS, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }, - ); - if (!res.ok) return []; - const repos = await res.json(); - const now = Date.now(); - const thirtyDaysAgo = now - 30 * 86400000; - - return repos - .filter((r) => new Date(r.created_at).getTime() > thirtyDaysAgo) - .map((r) => ({ - type: 'technology_adoption', - title: `New repository: ${r.full_name} — ${r.description || 'No description'}`, - url: r.html_url, - source: 'GitHub', - sourceTier: 2, - timestamp: r.created_at, - strength: r.stargazers_count > 50 ? 'high' : r.stargazers_count > 10 ? 'medium' : 'low', - engagement: { stars: r.stargazers_count, forks: r.forks_count }, - })); - } catch { - return []; - } -} - -async function fetchJobSignals(companyName) { - try { - const res = await fetch( - `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(companyName)}&tags=comment,ask_hn&hitsPerPage=10&numericFilters=created_at_i>${Math.floor(Date.now() / 1000) - 60 * 86400}`, - { - headers: DEFAULT_HEADERS, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }, - ); - if (!res.ok) return []; - const data = await res.json(); - - const hiringComments = (data.hits || []).filter((h) => { - const text = (h.comment_text || '').toLowerCase(); - return text.includes('hiring') || text.includes('job') || text.includes('apply'); - }); - - if (hiringComments.length === 0) return []; - - return [{ - type: 'hiring_surge', - title: `${companyName} hiring activity (${hiringComments.length} mentions in HN hiring threads)`, - url: `https://news.ycombinator.com/item?id=${hiringComments[0].story_id}`, - source: 'HN Hiring Threads', - sourceTier: 3, - timestamp: hiringComments[0].created_at, - strength: hiringComments.length >= 3 ? 'high' : 'medium', - engagement: { mentions: hiringComments.length }, - }]; - } catch { - return []; - } -} - -export default async function handler(req) { - const cors = getCorsHeaders(req, 'GET, OPTIONS'); - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - - if (isDisallowedOrigin(req)) { - return new Response('Forbidden', { status: 403, headers: cors }); - } - - const rateLimitResult = await checkRateLimit(req, 'signals', 20, '60s'); - if (rateLimitResult) return rateLimitResult; - - const url = new URL(req.url); - const company = url.searchParams.get('company')?.trim(); - const domain = url.searchParams.get('domain')?.trim().toLowerCase(); - - if (!company) { - return new Response(JSON.stringify({ error: 'Provide ?company= parameter' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const orgName = toOrgSlugFromDomain(domain) || company.toLowerCase().replace(/\s+/g, ''); - - const [hnSignals, githubSignals, jobSignals] = await Promise.all([ - fetchHNSignals(company), - fetchGitHubSignals(orgName), - fetchJobSignals(company), - ]); - - const allSignals = [...hnSignals, ...githubSignals, ...jobSignals] - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - - const signalTypeCounts = {}; - for (const s of allSignals) { - signalTypeCounts[s.type] = (signalTypeCounts[s.type] || 0) + 1; - } - - const result = { - company, - domain: domain || null, - signals: allSignals, - summary: { - totalSignals: allSignals.length, - byType: signalTypeCounts, - strongestSignal: allSignals[0] || null, - signalDiversity: Object.keys(signalTypeCounts).length, - }, - discoveredAt: new Date().toISOString(), - }; - - return new Response(JSON.stringify(result), { - status: 200, - headers: { - ...cors, - 'Content-Type': 'application/json', - 'Cache-Control': 'public, s-maxage=1800, stale-while-revalidate=3600', - }, - }); -} diff --git a/api/leads/v1/[rpc].ts b/api/leads/v1/[rpc].ts new file mode 100644 index 000000000..7d19b77b4 --- /dev/null +++ b/api/leads/v1/[rpc].ts @@ -0,0 +1,9 @@ +export const config = { runtime: 'edge' }; + +import { createDomainGateway, serverOptions } from '../../../server/gateway'; +import { createLeadsServiceRoutes } from '../../../src/generated/server/worldmonitor/leads/v1/service_server'; +import { leadsHandler } from '../../../server/worldmonitor/leads/v1/handler'; + +export default createDomainGateway( + createLeadsServiceRoutes(leadsHandler, serverOptions), +); diff --git a/api/military-flights.js b/api/military-flights.js deleted file mode 100644 index ad5e29e4c..000000000 --- a/api/military-flights.js +++ /dev/null @@ -1,68 +0,0 @@ -import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; -import { jsonResponse } from './_json-response.js'; -import { readJsonFromUpstash } from './_upstash-json.js'; - -export const config = { runtime: 'edge' }; - -const REDIS_KEY = 'military:flights:v1'; -const STALE_KEY = 'military:flights:stale:v1'; - -let cached = null; -let cachedAt = 0; -const CACHE_TTL = 120_000; - -let negUntil = 0; -const NEG_TTL = 30_000; - -async function fetchMilitaryFlightsData() { - const now = Date.now(); - if (cached && now - cachedAt < CACHE_TTL) return cached; - if (now < negUntil) return null; - - let data; - try { data = await readJsonFromUpstash(REDIS_KEY); } catch { data = null; } - - if (!data) { - try { data = await readJsonFromUpstash(STALE_KEY); } catch { data = null; } - } - - if (!data) { - negUntil = now + NEG_TTL; - return null; - } - - cached = data; - cachedAt = now; - return data; -} - -export default async function handler(req) { - const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: corsHeaders }); - } - - if (isDisallowedOrigin(req)) { - return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); - } - - const data = await fetchMilitaryFlightsData(); - - if (!data) { - return jsonResponse( - { error: 'Military flight data temporarily unavailable' }, - 503, - { 'Cache-Control': 'no-cache, no-store', ...corsHeaders }, - ); - } - - return jsonResponse( - data, - 200, - { - 'Cache-Control': 's-maxage=120, stale-while-revalidate=60, stale-if-error=300', - ...corsHeaders, - }, - ); -} diff --git a/api/sanctions-entity-search.js b/api/sanctions-entity-search.js deleted file mode 100644 index ca2641366..000000000 --- a/api/sanctions-entity-search.js +++ /dev/null @@ -1,88 +0,0 @@ -// Edge function: on-demand OpenSanctions entity search (Phase 2 — issue #2042). -// Proxies to https://api.opensanctions.org — no auth required for basic search. -// Merges results with OFAC via the RPC lookup endpoint for a unified response. - -export const config = { runtime: 'edge' }; - -import { createIpRateLimiter } from './_ip-rate-limit.js'; -import { jsonResponse } from './_json-response.js'; -import { getClientIp } from './_turnstile.js'; - -const OPENSANCTIONS_BASE = 'https://api.opensanctions.org'; -const OPENSANCTIONS_TIMEOUT_MS = 8_000; -const MAX_RESULTS = 20; - -const rateLimiter = createIpRateLimiter({ limit: 30, windowMs: 60_000 }); - -function normalizeEntity(hit) { - const props = hit.properties ?? {}; - const name = (props.name ?? [hit.caption]).filter(Boolean)[0] ?? ''; - const countries = props.country ?? props.nationality ?? []; - const programs = props.program ?? props.sanctions ?? []; - const schema = hit.schema ?? ''; - - let entityType = 'entity'; - if (schema === 'Vessel') entityType = 'vessel'; - else if (schema === 'Aircraft') entityType = 'aircraft'; - else if (schema === 'Person') entityType = 'individual'; - - return { - id: `opensanctions:${hit.id}`, - name, - entityType, - countryCodes: countries.slice(0, 3), - programs: programs.slice(0, 3), - datasets: hit.datasets ?? [], - score: hit.score ?? 0, - }; -} - -export default async function handler(req) { - const ip = getClientIp(req); - if (rateLimiter.isRateLimited(ip)) { - return jsonResponse({ error: 'Too many requests' }, 429); - } - - const { searchParams } = new URL(req.url); - const q = (searchParams.get('q') ?? '').trim(); - - if (!q || q.length < 2) { - return jsonResponse({ error: 'q must be at least 2 characters' }, 400); - } - if (q.length > 200) { - return jsonResponse({ error: 'q must be at most 200 characters' }, 400); - } - - const limitRaw = Number(searchParams.get('limit') ?? '10'); - const limit = Math.min(Number.isFinite(limitRaw) && limitRaw > 0 ? Math.trunc(limitRaw) : 10, MAX_RESULTS); - - try { - const url = new URL(`${OPENSANCTIONS_BASE}/search/default`); - url.searchParams.set('q', q); - url.searchParams.set('limit', String(limit)); - - const resp = await fetch(url.toString(), { - headers: { - 'User-Agent': 'WorldMonitor/1.0 sanctions-search', - Accept: 'application/json', - }, - signal: AbortSignal.timeout(OPENSANCTIONS_TIMEOUT_MS), - }); - - if (!resp.ok) { - return jsonResponse({ results: [], total: 0, source: 'opensanctions', error: `upstream HTTP ${resp.status}` }, 200); - } - - const data = await resp.json(); - const results = (data.results ?? []).map(normalizeEntity); - - return jsonResponse({ - results, - total: data.total?.value ?? results.length, - source: 'opensanctions', - }, 200, { 'Cache-Control': 'no-store' }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ results: [], total: 0, source: 'opensanctions', error: message }, 200); - } -} diff --git a/api/satellites.js b/api/satellites.js deleted file mode 100644 index 23d967953..000000000 --- a/api/satellites.js +++ /dev/null @@ -1,49 +0,0 @@ -import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; -import { jsonResponse } from './_json-response.js'; -import { readJsonFromUpstash } from './_upstash-json.js'; - -export const config = { runtime: 'edge' }; - -const REDIS_KEY = 'intelligence:satellites:tle:v1'; - -let cached = null; -let cachedAt = 0; -const CACHE_TTL = 600_000; - -let negUntil = 0; -const NEG_TTL = 60_000; - -async function fetchSatelliteData() { - const now = Date.now(); - if (cached && now - cachedAt < CACHE_TTL) return cached; - if (now < negUntil) return null; - let data; - try { data = await readJsonFromUpstash(REDIS_KEY); } catch { data = null; } - if (!data) { - negUntil = now + NEG_TTL; - return null; - } - cached = data; - cachedAt = now; - return data; -} - -export default async function handler(req) { - const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: corsHeaders }); - } - if (isDisallowedOrigin(req)) { - return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); - } - const data = await fetchSatelliteData(); - if (!data) { - return jsonResponse({ error: 'Satellite data temporarily unavailable' }, 503, { - 'Cache-Control': 'no-cache, no-store', ...corsHeaders, - }); - } - return jsonResponse(data, 200, { - 'Cache-Control': 's-maxage=3600, stale-while-revalidate=1800, stale-if-error=3600', - ...corsHeaders, - }); -} diff --git a/api/scenario/v1/[rpc].ts b/api/scenario/v1/[rpc].ts new file mode 100644 index 000000000..60c6f8bfe --- /dev/null +++ b/api/scenario/v1/[rpc].ts @@ -0,0 +1,9 @@ +export const config = { runtime: 'edge' }; + +import { createDomainGateway, serverOptions } from '../../../server/gateway'; +import { createScenarioServiceRoutes } from '../../../src/generated/server/worldmonitor/scenario/v1/service_server'; +import { scenarioHandler } from '../../../server/worldmonitor/scenario/v1/handler'; + +export default createDomainGateway( + createScenarioServiceRoutes(scenarioHandler, serverOptions), +); diff --git a/api/scenario/v1/run.ts b/api/scenario/v1/run.ts index b7bae33d9..f3890f15a 100644 --- a/api/scenario/v1/run.ts +++ b/api/scenario/v1/run.ts @@ -1,163 +1,8 @@ export const config = { runtime: 'edge' }; -import { isCallerPremium } from '../../../server/_shared/premium-check'; -import { getScenarioTemplate } from '../../../server/worldmonitor/supply-chain/v1/scenario-templates'; +import gateway from './[rpc]'; +import { rewriteToSebuf } from '../../../server/alias-rewrite'; -const JOB_ID_CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789'; - -function generateJobId(): string { - const ts = Date.now(); - let suffix = ''; - const array = new Uint8Array(8); - crypto.getRandomValues(array); - for (const byte of array) suffix += JOB_ID_CHARSET[byte % JOB_ID_CHARSET.length]; - return `scenario:${ts}:${suffix}`; -} - -function getClientIp(req: Request): string { - return ( - req.headers.get('cf-connecting-ip') || - req.headers.get('x-real-ip') || - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - '0.0.0.0' - ); -} - -export default async function handler(req: Request): Promise { - if (req.method !== 'POST') { - return new Response('', { status: 405 }); - } - - const isPro = await isCallerPremium(req); - if (!isPro) { - return new Response(JSON.stringify({ error: 'PRO subscription required' }), { - status: 403, - headers: { 'Content-Type': 'application/json' }, - }); - } - - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - if (!url || !token) { - return new Response(JSON.stringify({ error: 'Service temporarily unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Per-user rate limit: 10 scenario jobs per user per minute (sliding window via INCR+EXPIRE). - const identifier = getClientIp(req); - const minute = Math.floor(Date.now() / 60_000); - const rateLimitKey = `rate:scenario:${identifier}:${minute}`; - - const rlResp = await fetch(`${url}/pipeline`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify([ - ['INCR', rateLimitKey], - ['EXPIRE', rateLimitKey, 60], - ['LLEN', 'scenario-queue:pending'], - ]), - signal: AbortSignal.timeout(5_000), - }).catch(() => null); - - if (rlResp?.ok) { - const rlResults = (await rlResp.json()) as Array<{ result: number }>; - const count = rlResults[0]?.result ?? 0; - const queueDepth = rlResults[2]?.result ?? 0; - - if (count > 10) { - return new Response(JSON.stringify({ error: 'Rate limit exceeded: 10 scenario jobs per minute' }), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '60', - }, - }); - } - - if (queueDepth > 100) { - return new Response(JSON.stringify({ error: 'Scenario queue is at capacity, please try again later' }), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '30', - }, - }); - } - } - - let body: Record; - try { - body = await req.json(); - } catch { - return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - const { scenarioId, iso2 } = body as { scenarioId?: string; iso2?: string }; - - if (!scenarioId || typeof scenarioId !== 'string') { - return new Response(JSON.stringify({ error: 'scenarioId is required' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - if (!getScenarioTemplate(scenarioId)) { - return new Response(JSON.stringify({ error: `Unknown scenario: ${scenarioId}` }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - if (iso2 !== undefined && iso2 !== null && (typeof iso2 !== 'string' || !/^[A-Z]{2}$/.test(iso2))) { - return new Response(JSON.stringify({ error: 'iso2 must be a 2-letter uppercase country code' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - const jobId = generateJobId(); - const payload = JSON.stringify({ - jobId, - scenarioId, - iso2: iso2 ?? null, - enqueuedAt: Date.now(), - }); - - // Upstash REST command format: POST base URL with body `[CMD, ...args]`. - // The previous `/rpush/{key}` + `body: [payload]` form caused Upstash to - // store the literal array-string (`["{jobId:...}"]`) as the list value, - // which broke the scenario-worker's JSON.parse → destructure flow and - // made every job fail field validation (Railway log 2026-04-18: repeated - // `[scenario-worker] Job failed field validation, discarding: ["{...`). - // This command format matches `upstashLpush` in scripts/ais-relay.cjs. - const redisResp = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(['RPUSH', 'scenario-queue:pending', payload]), - signal: AbortSignal.timeout(5_000), - }); - - if (!redisResp.ok) { - console.error('[scenario/run] Redis enqueue failed:', redisResp.status); - return new Response(JSON.stringify({ error: 'Failed to enqueue scenario job' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }); - } - - return new Response( - JSON.stringify({ jobId, status: 'pending', statusUrl: `/api/scenario/v1/status?jobId=${jobId}` }), - { - status: 202, - headers: { 'Content-Type': 'application/json' }, - }, - ); -} +// Alias for documented v1 URL. See server/alias-rewrite.ts. +export default (req: Request) => + rewriteToSebuf(req, '/api/scenario/v1/run-scenario', gateway); diff --git a/api/scenario/v1/status.ts b/api/scenario/v1/status.ts index 6493d61bf..bcaac7420 100644 --- a/api/scenario/v1/status.ts +++ b/api/scenario/v1/status.ts @@ -1,77 +1,8 @@ export const config = { runtime: 'edge' }; -import { isCallerPremium } from '../../../server/_shared/premium-check'; +import gateway from './[rpc]'; +import { rewriteToSebuf } from '../../../server/alias-rewrite'; -/** Matches jobIds produced by run.ts: "scenario:{timestamp}:{8-char-suffix}" */ -const JOB_ID_RE = /^scenario:\d{13}:[a-z0-9]{8}$/; - -export default async function handler(req: Request): Promise { - if (req.method !== 'GET') { - return new Response('', { status: 405 }); - } - - const isPro = await isCallerPremium(req); - if (!isPro) { - return new Response(JSON.stringify({ error: 'PRO subscription required' }), { - status: 403, - headers: { 'Content-Type': 'application/json' }, - }); - } - - const { searchParams } = new URL(req.url); - const jobId = searchParams.get('jobId'); - - if (!jobId || !JOB_ID_RE.test(jobId)) { - return new Response( - JSON.stringify({ error: 'Invalid or missing jobId' }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ); - } - - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - if (!url || !token) { - return new Response( - JSON.stringify({ error: 'Service temporarily unavailable' }), - { status: 503, headers: { 'Content-Type': 'application/json' } }, - ); - } - - const resultKey = `scenario-result:${jobId}`; - const redisResp = await fetch(`${url}/get/${encodeURIComponent(resultKey)}`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(5_000), - }); - - if (!redisResp.ok) { - console.error('[scenario/status] Redis get failed:', redisResp.status); - return new Response( - JSON.stringify({ error: 'Failed to fetch job status' }), - { status: 502, headers: { 'Content-Type': 'application/json' } }, - ); - } - - const data = (await redisResp.json()) as { result?: string | null }; - - if (!data.result) { - return new Response( - JSON.stringify({ jobId, status: 'pending' }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - - let parsed: unknown; - try { - parsed = JSON.parse(data.result); - } catch { - return new Response( - JSON.stringify({ error: 'Corrupted job result' }), - { status: 500, headers: { 'Content-Type': 'application/json' } }, - ); - } - - return new Response( - JSON.stringify(parsed), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); -} +// Alias for documented v1 URL. See server/alias-rewrite.ts. +export default (req: Request) => + rewriteToSebuf(req, '/api/scenario/v1/get-scenario-status', gateway); diff --git a/api/scenario/v1/templates.ts b/api/scenario/v1/templates.ts index 7aed2069e..68af275a0 100644 --- a/api/scenario/v1/templates.ts +++ b/api/scenario/v1/templates.ts @@ -1,27 +1,8 @@ export const config = { runtime: 'edge' }; -import { SCENARIO_TEMPLATES } from '../../../server/worldmonitor/supply-chain/v1/scenario-templates'; +import gateway from './[rpc]'; +import { rewriteToSebuf } from '../../../server/alias-rewrite'; -export default async function handler(req: Request): Promise { - if (req.method !== 'GET') { - return new Response('', { status: 405 }); - } - - const templates = SCENARIO_TEMPLATES.map(t => ({ - id: t.id, - name: t.name, - affectedChokepointIds: t.affectedChokepointIds, - disruptionPct: t.disruptionPct, - durationDays: t.durationDays, - affectedHs2: t.affectedHs2, - costShockMultiplier: t.costShockMultiplier, - })); - - return new Response(JSON.stringify({ templates }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=3600', - }, - }); -} +// Alias for documented v1 URL. See server/alias-rewrite.ts. +export default (req: Request) => + rewriteToSebuf(req, '/api/scenario/v1/list-scenario-templates', gateway); diff --git a/api/supply-chain/v1/country-products.ts b/api/supply-chain/v1/country-products.ts index 30af1529e..a9e57e79e 100644 --- a/api/supply-chain/v1/country-products.ts +++ b/api/supply-chain/v1/country-products.ts @@ -1,56 +1,8 @@ export const config = { runtime: 'edge' }; -import { isCallerPremium } from '../../../server/_shared/premium-check'; -// @ts-expect-error — JS module, no declaration file -import { readJsonFromUpstash } from '../../_upstash-json.js'; +import gateway from './[rpc]'; +import { rewriteToSebuf } from '../../../server/alias-rewrite'; -export default async function handler(req: Request): Promise { - if (req.method !== 'GET') { - return new Response('', { status: 405 }); - } - - const isPro = await isCallerPremium(req); - if (!isPro) { - return new Response( - JSON.stringify({ error: 'PRO subscription required' }), - { status: 403, headers: { 'Content-Type': 'application/json' } }, - ); - } - - const { searchParams } = new URL(req.url); - const iso2 = searchParams.get('iso2')?.toUpperCase(); - if (!iso2 || !/^[A-Z]{2}$/.test(iso2)) { - return new Response( - JSON.stringify({ error: 'Invalid or missing iso2 parameter' }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ); - } - - const key = `comtrade:bilateral-hs4:${iso2}:v1`; - - try { - const data = await readJsonFromUpstash(key, 5_000); - if (!data) { - return new Response( - JSON.stringify({ iso2, products: [], fetchedAt: '' }), - { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }, - ); - } - return new Response( - JSON.stringify(data), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'private, max-age=3600', - 'Vary': 'Authorization, Cookie', - }, - }, - ); - } catch { - return new Response( - JSON.stringify({ error: 'Failed to fetch product data' }), - { status: 502, headers: { 'Content-Type': 'application/json' } }, - ); - } -} +// Alias for documented v1 URL. See server/alias-rewrite.ts. +export default (req: Request) => + rewriteToSebuf(req, '/api/supply-chain/v1/get-country-products', gateway); diff --git a/api/supply-chain/v1/multi-sector-cost-shock.ts b/api/supply-chain/v1/multi-sector-cost-shock.ts index 6cdb34d14..badf0878f 100644 --- a/api/supply-chain/v1/multi-sector-cost-shock.ts +++ b/api/supply-chain/v1/multi-sector-cost-shock.ts @@ -1,158 +1,8 @@ export const config = { runtime: 'edge' }; -import { isCallerPremium } from '../../../server/_shared/premium-check'; -import { CHOKEPOINT_REGISTRY } from '../../../server/_shared/chokepoint-registry'; -import { CHOKEPOINT_STATUS_KEY } from '../../../server/_shared/cache-keys'; -import { - aggregateAnnualImportsByHs2, - clampClosureDays, - computeMultiSectorShocks, - MULTI_SECTOR_HS2_LABELS, - SEEDED_HS2_CODES, - type MultiSectorCostShock, - type SeededProduct, -} from '../../../server/worldmonitor/supply-chain/v1/_multi-sector-shock'; -// @ts-expect-error — JS module, no declaration file -import { readJsonFromUpstash } from '../../_upstash-json.js'; +import gateway from './[rpc]'; +import { rewriteToSebuf } from '../../../server/alias-rewrite'; -interface ChokepointStatusCache { - chokepoints?: Array<{ id: string; warRiskTier?: string }>; -} - -interface CountryProductsCache { - iso2: string; - products?: SeededProduct[]; - fetchedAt?: string; -} - -export interface MultiSectorCostShockResponse { - iso2: string; - chokepointId: string; - closureDays: number; - warRiskTier: string; - sectors: MultiSectorCostShock[]; - totalAddedCost: number; - fetchedAt: string; - unavailableReason: string; -} - -function emptyResponse( - iso2: string, - chokepointId: string, - closureDays: number, - reason = '', -): MultiSectorCostShockResponse { - return { - iso2, - chokepointId, - closureDays, - warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', - sectors: [], - totalAddedCost: 0, - fetchedAt: new Date().toISOString(), - unavailableReason: reason, - }; -} - -export default async function handler(req: Request): Promise { - if (req.method !== 'GET') { - return new Response('', { status: 405 }); - } - - const { searchParams } = new URL(req.url); - const iso2 = (searchParams.get('iso2') ?? '').toUpperCase(); - const chokepointId = (searchParams.get('chokepointId') ?? '').trim().toLowerCase(); - const rawDays = Number(searchParams.get('closureDays') ?? '30'); - const closureDays = clampClosureDays(rawDays); - - if (!/^[A-Z]{2}$/.test(iso2)) { - return new Response( - JSON.stringify({ error: 'Invalid or missing iso2 parameter' }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ); - } - if (!chokepointId) { - return new Response( - JSON.stringify({ error: 'Invalid or missing chokepointId parameter' }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ); - } - if (!CHOKEPOINT_REGISTRY.some(c => c.id === chokepointId)) { - return new Response( - JSON.stringify({ error: `Unknown chokepointId: ${chokepointId}` }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ); - } - - const isPro = await isCallerPremium(req); - if (!isPro) { - return new Response( - JSON.stringify({ error: 'PRO subscription required' }), - { status: 403, headers: { 'Content-Type': 'application/json' } }, - ); - } - - // Parallel Redis reads: country products + chokepoint status (for war risk tier). - const productsKey = `comtrade:bilateral-hs4:${iso2}:v1`; - const [productsCache, statusCache] = await Promise.all([ - readJsonFromUpstash(productsKey, 5_000).catch(() => null) as Promise, - readJsonFromUpstash(CHOKEPOINT_STATUS_KEY, 5_000).catch(() => null) as Promise, - ]); - - const products = Array.isArray(productsCache?.products) ? productsCache.products : []; - const importsByHs2 = aggregateAnnualImportsByHs2(products); - const hasAnyImports = Object.values(importsByHs2).some(v => v > 0); - const warRiskTier = statusCache?.chokepoints?.find(c => c.id === chokepointId)?.warRiskTier - ?? 'WAR_RISK_TIER_NORMAL'; - - if (!hasAnyImports) { - return new Response( - JSON.stringify({ - ...emptyResponse(iso2, chokepointId, closureDays, 'No seeded import data available for this country'), - // Still emit the empty sector skeleton so the UI can render rows at 0. - sectors: SEEDED_HS2_CODES.map(hs2 => ({ - hs2, - hs2Label: MULTI_SECTOR_HS2_LABELS[hs2] ?? `HS ${hs2}`, - importValueAnnual: 0, - freightAddedPctPerTon: 0, - warRiskPremiumBps: 0, - addedTransitDays: 0, - totalCostShockPerDay: 0, - totalCostShock30Days: 0, - totalCostShock90Days: 0, - totalCostShock: 0, - closureDays, - })), - warRiskTier, - } satisfies MultiSectorCostShockResponse), - { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }, - ); - } - - const sectors = computeMultiSectorShocks(importsByHs2, chokepointId, warRiskTier, closureDays); - const totalAddedCost = sectors.reduce((sum, s) => sum + s.totalCostShock, 0); - - const response: MultiSectorCostShockResponse = { - iso2, - chokepointId, - closureDays, - warRiskTier, - sectors, - totalAddedCost, - fetchedAt: new Date().toISOString(), - unavailableReason: '', - }; - - return new Response( - JSON.stringify(response), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - // Closure duration is user-controlled, so cache is private + short. - 'Cache-Control': 'private, max-age=60', - 'Vary': 'Authorization, Cookie, X-WorldMonitor-Key', - }, - }, - ); -} +// Alias for documented v1 URL. See server/alias-rewrite.ts. +export default (req: Request) => + rewriteToSebuf(req, '/api/supply-chain/v1/get-multi-sector-cost-shock', gateway); diff --git a/api/v2/shipping/[rpc].ts b/api/v2/shipping/[rpc].ts new file mode 100644 index 000000000..035b08dae --- /dev/null +++ b/api/v2/shipping/[rpc].ts @@ -0,0 +1,9 @@ +export const config = { runtime: 'edge' }; + +import { createDomainGateway, serverOptions } from '../../../server/gateway'; +import { createShippingV2ServiceRoutes } from '../../../src/generated/server/worldmonitor/shipping/v2/service_server'; +import { shippingV2Handler } from '../../../server/worldmonitor/shipping/v2/handler'; + +export default createDomainGateway( + createShippingV2ServiceRoutes(shippingV2Handler, serverOptions), +); diff --git a/api/v2/shipping/route-intelligence.ts b/api/v2/shipping/route-intelligence.ts deleted file mode 100644 index 3d7bd685b..000000000 --- a/api/v2/shipping/route-intelligence.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * GET /api/v2/shipping/route-intelligence - * - * Vendor-facing route intelligence API. Returns the primary trade route, chokepoint - * exposures, bypass options, war risk tier, and disruption score for a given - * country pair + cargo type. - * - * Authentication: X-WorldMonitor-Key required (forceKey: true). Browser origins - * are NOT exempt — this endpoint is designed for server-to-server integration. - */ - -export const config = { runtime: 'edge' }; - -// @ts-expect-error — JS module, no declaration file -import { validateApiKey } from '../../_api-key.js'; -// @ts-expect-error — JS module, no declaration file -import { getCorsHeaders } from '../../_cors.js'; -import { isCallerPremium } from '../../../server/_shared/premium-check'; -import { getCachedJson } from '../../../server/_shared/redis'; -import { CHOKEPOINT_STATUS_KEY } from '../../../server/_shared/cache-keys'; -import { BYPASS_CORRIDORS_BY_CHOKEPOINT } from '../../../server/_shared/bypass-corridors'; -import { CHOKEPOINT_REGISTRY } from '../../../server/_shared/chokepoint-registry'; -import COUNTRY_PORT_CLUSTERS from '../../../scripts/shared/country-port-clusters.json'; - -interface PortClusterEntry { - nearestRouteIds: string[]; - coastSide: string; -} - -interface ChokepointStatus { - id: string; - name?: string; - disruptionScore?: number; - warRiskTier?: string; -} - -interface ChokepointStatusResponse { - chokepoints?: ChokepointStatus[]; -} - -export default async function handler(req: Request): Promise { - const cors = getCorsHeaders(req); - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - - if (req.method !== 'GET') { - return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - - let apiKeyResult = validateApiKey(req, { forceKey: true }); - // Fallback: wm_ user keys are validated async via Convex, not in the static key list - const wmKey = - req.headers.get('X-WorldMonitor-Key') ?? - req.headers.get('X-Api-Key') ?? - ''; - if (apiKeyResult.required && !apiKeyResult.valid && wmKey.startsWith('wm_')) { - const { validateUserApiKey } = await import('../../../server/_shared/user-api-key'); - const userKeyResult = await validateUserApiKey(wmKey); - if (userKeyResult) { - apiKeyResult = { valid: true, required: true }; - } - } - if (apiKeyResult.required && !apiKeyResult.valid) { - return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), { - status: 401, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const isPro = await isCallerPremium(req); - if (!isPro) { - return new Response(JSON.stringify({ error: 'PRO subscription required' }), { - status: 403, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const { searchParams } = new URL(req.url); - const fromIso2 = searchParams.get('fromIso2')?.trim().toUpperCase() ?? ''; - const toIso2 = searchParams.get('toIso2')?.trim().toUpperCase() ?? ''; - const cargoType = (searchParams.get('cargoType')?.trim().toLowerCase() ?? 'container') as 'container' | 'tanker' | 'bulk' | 'roro'; - const hs2 = searchParams.get('hs2')?.trim().replace(/\D/g, '') || '27'; - - if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) { - return new Response(JSON.stringify({ error: 'fromIso2 and toIso2 must be valid 2-letter ISO country codes' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const clusters = COUNTRY_PORT_CLUSTERS as unknown as Record; - const fromCluster = clusters[fromIso2]; - const toCluster = clusters[toIso2]; - - const fromRoutes = new Set(fromCluster?.nearestRouteIds ?? []); - const toRoutes = new Set(toCluster?.nearestRouteIds ?? []); - const sharedRoutes = [...fromRoutes].filter(r => toRoutes.has(r)); - const primaryRouteId = sharedRoutes[0] ?? fromCluster?.nearestRouteIds[0] ?? ''; - - // Load live chokepoint data - const statusRaw = await getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null) as ChokepointStatusResponse | null; - const statusMap = new Map( - (statusRaw?.chokepoints ?? []).map(cp => [cp.id, cp]) - ); - - // Find chokepoints on the primary route and shared routes - const relevantRouteSet = new Set(sharedRoutes.length ? sharedRoutes : (fromCluster?.nearestRouteIds ?? [])); - const chokepointExposures = CHOKEPOINT_REGISTRY - .filter(cp => cp.routeIds.some(r => relevantRouteSet.has(r))) - .map(cp => { - const overlap = cp.routeIds.filter(r => relevantRouteSet.has(r)).length; - const exposurePct = Math.round((overlap / Math.max(cp.routeIds.length, 1)) * 100); - return { chokepointId: cp.id, chokepointName: cp.displayName, exposurePct }; - }) - .filter(e => e.exposurePct > 0) - .sort((a, b) => b.exposurePct - a.exposurePct); - - const primaryChokepoint = chokepointExposures[0]; - const primaryCpStatus = primaryChokepoint ? statusMap.get(primaryChokepoint.chokepointId) : null; - - const disruptionScore = primaryCpStatus?.disruptionScore ?? 0; - const warRiskTier = primaryCpStatus?.warRiskTier ?? 'WAR_RISK_TIER_NORMAL'; - - // Bypass options for the primary chokepoint - const corridors = primaryChokepoint - ? (BYPASS_CORRIDORS_BY_CHOKEPOINT[primaryChokepoint.chokepointId] ?? []) - .filter(c => c.suitableCargoTypes.length === 0 || c.suitableCargoTypes.includes(cargoType)) - .slice(0, 5) - .map(c => ({ - id: c.id, - name: c.name, - type: c.type, - addedTransitDays: c.addedTransitDays, - addedCostMultiplier: c.addedCostMultiplier, - activationThreshold: c.activationThreshold, - })) - : []; - - const body = { - fromIso2, - toIso2, - cargoType, - hs2, - primaryRouteId, - chokepointExposures, - bypassOptions: corridors, - warRiskTier, - disruptionScore, - fetchedAt: new Date().toISOString(), - }; - - return new Response(JSON.stringify(body), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60, stale-while-revalidate=120' }, - }); -} diff --git a/api/v2/shipping/webhooks.ts b/api/v2/shipping/webhooks.ts deleted file mode 100644 index 3ef6f5416..000000000 --- a/api/v2/shipping/webhooks.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * POST /api/v2/shipping/webhooks — Register a webhook for chokepoint disruption alerts. - * GET /api/v2/shipping/webhooks — List webhooks for the authenticated caller. - * - * Payload: { callbackUrl, chokepointIds[], alertThreshold } - * Response: { subscriberId, secret } - * - * Security: - * - X-WorldMonitor-Key required (forceKey: true) - * - SSRF prevention: callbackUrl hostname is validated against private IP ranges. - * LIMITATION: DNS rebinding is not mitigated in the edge runtime (no DNS resolution - * at registration time). The delivery worker MUST resolve the URL before sending and - * re-check it against PRIVATE_HOSTNAME_PATTERNS. HTTPS-only is required to limit - * exposure (TLS certs cannot be issued for private IPs via public CAs). - * - HMAC signatures: webhook deliveries include X-WM-Signature: sha256= - * - Ownership: SHA-256 of the caller's API key is stored as ownerTag; an owner index (Redis Set) - * enables list queries without a full scan. - */ - -export const config = { runtime: 'edge' }; - -// @ts-expect-error — JS module, no declaration file -import { validateApiKey } from '../../_api-key.js'; -// @ts-expect-error — JS module, no declaration file -import { getCorsHeaders } from '../../_cors.js'; -import { isCallerPremium } from '../../../server/_shared/premium-check'; -import { getCachedJson, setCachedJson, runRedisPipeline } from '../../../server/_shared/redis'; -import { CHOKEPOINT_REGISTRY } from '../../../server/_shared/chokepoint-registry'; - -const WEBHOOK_TTL = 86400 * 30; // 30 days -const VALID_CHOKEPOINT_IDS = new Set(CHOKEPOINT_REGISTRY.map(c => c.id)); - -// Private IP ranges + known cloud metadata hostnames blocked at registration. -// NOTE: DNS rebinding bypass is not mitigated here (no DNS resolution in edge runtime). -// The delivery worker must re-validate the resolved IP before sending. -const PRIVATE_HOSTNAME_PATTERNS = [ - /^localhost$/i, - /^127\.\d+\.\d+\.\d+$/, - /^10\.\d+\.\d+\.\d+$/, - /^192\.168\.\d+\.\d+$/, - /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, - /^169\.254\.\d+\.\d+$/, // link-local + AWS/GCP/Azure IMDS - /^fd[0-9a-f]{2}:/i, // IPv6 ULA (fd00::/8) - /^fe80:/i, // IPv6 link-local - /^::1$/, // IPv6 loopback - /^0\.0\.0\.0$/, - /^0\.\d+\.\d+\.\d+$/, // RFC 1122 "this network" - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/, // RFC 6598 shared address -]; - -// Known cloud metadata endpoints that must be blocked explicitly even if the -// IP regex above misses a future alias or IPv6 variant. -const BLOCKED_METADATA_HOSTNAMES = new Set([ - '169.254.169.254', // AWS/Azure/GCP IMDS (IPv4) - 'metadata.google.internal', // GCP metadata server - 'metadata.internal', // GCP alternative alias - 'instance-data', // OpenStack metadata - 'metadata', // generic cloud metadata alias - 'computemetadata', // GCP legacy - 'link-local.s3.amazonaws.com', -]); - -function isBlockedCallbackUrl(rawUrl: string): string | null { - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - return 'callbackUrl is not a valid URL'; - } - - // HTTPS is required — TLS certs cannot be issued for private IPs via public CAs, - // which prevents the most common DNS-rebinding variant in practice. - if (parsed.protocol !== 'https:') { - return 'callbackUrl must use https'; - } - - const hostname = parsed.hostname.toLowerCase(); - - if (BLOCKED_METADATA_HOSTNAMES.has(hostname)) { - return 'callbackUrl hostname is a blocked metadata endpoint'; - } - - for (const pattern of PRIVATE_HOSTNAME_PATTERNS) { - if (pattern.test(hostname)) { - return `callbackUrl resolves to a private/reserved address: ${hostname}`; - } - } - - return null; -} - -async function generateSecret(): Promise { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); -} - -function generateSubscriberId(): string { - const bytes = new Uint8Array(12); - crypto.getRandomValues(bytes); - return 'wh_' + [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); -} - -function webhookKey(subscriberId: string): string { - return `webhook:sub:${subscriberId}:v1`; -} - -function ownerIndexKey(ownerHash: string): string { - return `webhook:owner:${ownerHash}:v1`; -} - -/** SHA-256 hash of the caller's API key — used as ownerTag and owner index key. Never secret. */ -async function callerFingerprint(req: Request): Promise { - const key = - req.headers.get('X-WorldMonitor-Key') ?? - req.headers.get('X-Api-Key') ?? - ''; - if (!key) return 'anon'; - const encoded = new TextEncoder().encode(key); - const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); - return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -interface WebhookRecord { - subscriberId: string; - ownerTag: string; // SHA-256 hash of the registrant's API key for ownership checks - callbackUrl: string; - chokepointIds: string[]; - alertThreshold: number; - createdAt: string; - active: boolean; - // secret is persisted so delivery workers can sign payloads via HMAC-SHA256. - // Stored in trusted Redis; rotated via /rotate-secret. - secret: string; -} - -export default async function handler(req: Request): Promise { - const cors = getCorsHeaders(req); - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - - const apiKeyResult = validateApiKey(req, { forceKey: true }); - if (apiKeyResult.required && !apiKeyResult.valid) { - return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), { - status: 401, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const isPro = await isCallerPremium(req); - if (!isPro) { - return new Response(JSON.stringify({ error: 'PRO subscription required' }), { - status: 403, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const url = new URL(req.url); - const pathParts = url.pathname.replace(/\/+$/, '').split('/'); - - // Find the wh_* segment anywhere in the path (handles /webhooks/wh_xxx/action) - const whIndex = pathParts.findIndex(p => p.startsWith('wh_')); - const subscriberId = whIndex !== -1 ? pathParts[whIndex] : null; - // Action is the segment after the wh_* segment, if present - const action = whIndex !== -1 ? (pathParts[whIndex + 1] ?? null) : null; - - // POST /api/v2/shipping/webhooks — Register new webhook - if (req.method === 'POST' && !subscriberId) { - let body: { callbackUrl?: string; chokepointIds?: string[]; alertThreshold?: number }; - try { - body = await req.json() as typeof body; - } catch { - return new Response(JSON.stringify({ error: 'Request body must be valid JSON' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const { callbackUrl, chokepointIds = [], alertThreshold = 50 } = body; - - if (!callbackUrl) { - return new Response(JSON.stringify({ error: 'callbackUrl is required' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const ssrfError = isBlockedCallbackUrl(callbackUrl); - if (ssrfError) { - return new Response(JSON.stringify({ error: ssrfError }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const invalidCp = chokepointIds.find(id => !VALID_CHOKEPOINT_IDS.has(id)); - if (invalidCp) { - return new Response(JSON.stringify({ error: `Unknown chokepoint ID: ${invalidCp}` }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - if (typeof alertThreshold !== 'number' || alertThreshold < 0 || alertThreshold > 100) { - return new Response(JSON.stringify({ error: 'alertThreshold must be a number between 0 and 100' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const ownerTag = await callerFingerprint(req); - const newSubscriberId = generateSubscriberId(); - const secret = await generateSecret(); - - const record: WebhookRecord = { - subscriberId: newSubscriberId, - ownerTag, - callbackUrl, - chokepointIds: chokepointIds.length ? chokepointIds : [...VALID_CHOKEPOINT_IDS], - alertThreshold, - createdAt: new Date().toISOString(), - active: true, - secret, // persisted so delivery workers can compute HMAC signatures - }; - - // Persist record + update owner index (Redis Set) atomically via pipeline. - // raw = false so all keys are prefixed consistently with getCachedJson reads. - await runRedisPipeline([ - ['SET', webhookKey(newSubscriberId), JSON.stringify(record), 'EX', String(WEBHOOK_TTL)], - ['SADD', ownerIndexKey(ownerTag), newSubscriberId], - ['EXPIRE', ownerIndexKey(ownerTag), String(WEBHOOK_TTL)], - ]); - - return new Response(JSON.stringify({ subscriberId: newSubscriberId, secret }), { - status: 201, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - // Helper: load record + verify ownership in one place - async function loadOwned(subId: string): Promise { - const record = await getCachedJson(webhookKey(subId)).catch(() => null) as WebhookRecord | null; - if (!record) return 'not_found'; - const ownerHash = await callerFingerprint(req); - if (record.ownerTag !== ownerHash) return 'forbidden'; - return record; - } - - // GET /api/v2/shipping/webhooks — List caller's webhooks - if (req.method === 'GET' && !subscriberId) { - const ownerHash = await callerFingerprint(req); - const smembersResult = await runRedisPipeline([['SMEMBERS', ownerIndexKey(ownerHash)]]); - const memberIds = (smembersResult[0]?.result as string[] | null) ?? []; - - if (memberIds.length === 0) { - return new Response(JSON.stringify({ webhooks: [] }), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - const getResults = await runRedisPipeline(memberIds.map(id => ['GET', webhookKey(id)])); - const webhooks = getResults - .map((r) => { - if (!r.result || typeof r.result !== 'string') return null; - try { - const record = JSON.parse(r.result) as WebhookRecord; - if (record.ownerTag !== ownerHash) return null; // defensive ownership check - return { - subscriberId: record.subscriberId, - callbackUrl: record.callbackUrl, - chokepointIds: record.chokepointIds, - alertThreshold: record.alertThreshold, - createdAt: record.createdAt, - active: record.active, - }; - } catch { - return null; - } - }) - .filter(Boolean); - - return new Response(JSON.stringify({ webhooks }), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - // GET /api/v2/shipping/webhooks/{subscriberId} — Status check - if (req.method === 'GET' && subscriberId && !action) { - const result = await loadOwned(subscriberId); - if (result === 'not_found') { - return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - if (result === 'forbidden') { - return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - - return new Response(JSON.stringify({ - subscriberId: result.subscriberId, - callbackUrl: result.callbackUrl, - chokepointIds: result.chokepointIds, - alertThreshold: result.alertThreshold, - createdAt: result.createdAt, - active: result.active, - // secret is intentionally omitted from status responses - }), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - // POST /api/v2/shipping/webhooks/{subscriberId}/rotate-secret - if (req.method === 'POST' && subscriberId && action === 'rotate-secret') { - const result = await loadOwned(subscriberId); - if (result === 'not_found') { - return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - if (result === 'forbidden') { - return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - - const newSecret = await generateSecret(); - await setCachedJson(webhookKey(subscriberId), { ...result, secret: newSecret }, WEBHOOK_TTL); - - return new Response(JSON.stringify({ subscriberId, secret: newSecret, rotatedAt: new Date().toISOString() }), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - // POST /api/v2/shipping/webhooks/{subscriberId}/reactivate - if (req.method === 'POST' && subscriberId && action === 'reactivate') { - const result = await loadOwned(subscriberId); - if (result === 'not_found') { - return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - if (result === 'forbidden') { - return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } }); - } - - await setCachedJson(webhookKey(subscriberId), { ...result, active: true }, WEBHOOK_TTL); - - return new Response(JSON.stringify({ subscriberId, active: true }), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); - } - - return new Response(JSON.stringify({ error: 'Not found' }), { - status: 404, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); -} diff --git a/api/v2/shipping/webhooks/[subscriberId].ts b/api/v2/shipping/webhooks/[subscriberId].ts new file mode 100644 index 000000000..65a1027b4 --- /dev/null +++ b/api/v2/shipping/webhooks/[subscriberId].ts @@ -0,0 +1,89 @@ +/** + * GET /api/v2/shipping/webhooks/{subscriberId} — Status read for a single + * webhook. Preserved on the legacy path-param URL shape because sebuf does + * not currently support path-parameter RPC paths; tracked for eventual + * migration under #3207. + */ + +export const config = { runtime: 'edge' }; + +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../../_api-key.js'; +// @ts-expect-error — JS module, no declaration file +import { getCorsHeaders } from '../../../_cors.js'; +import { isCallerPremium } from '../../../../server/_shared/premium-check'; +import { getCachedJson } from '../../../../server/_shared/redis'; +import { + webhookKey, + callerFingerprint, + type WebhookRecord, +} from '../../../../server/worldmonitor/shipping/v2/webhook-shared'; + +export default async function handler(req: Request): Promise { + const cors = getCorsHeaders(req); + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + if (req.method !== 'GET') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const apiKeyResult = validateApiKey(req, { forceKey: true }); + if (apiKeyResult.required && !apiKeyResult.valid) { + return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), { + status: 401, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const isPro = await isCallerPremium(req); + if (!isPro) { + return new Response(JSON.stringify({ error: 'PRO subscription required' }), { + status: 403, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(req.url); + const parts = url.pathname.replace(/\/+$/, '').split('/'); + const subscriberId = parts[parts.length - 1]; + if (!subscriberId || !subscriberId.startsWith('wh_')) { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { + status: 404, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const record = (await getCachedJson(webhookKey(subscriberId)).catch(() => null)) as WebhookRecord | null; + if (!record) { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { + status: 404, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const ownerHash = await callerFingerprint(req); + if (record.ownerTag !== ownerHash) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + return new Response( + JSON.stringify({ + subscriberId: record.subscriberId, + callbackUrl: record.callbackUrl, + chokepointIds: record.chokepointIds, + alertThreshold: record.alertThreshold, + createdAt: record.createdAt, + active: record.active, + }), + { status: 200, headers: { ...cors, 'Content-Type': 'application/json' } }, + ); +} diff --git a/api/v2/shipping/webhooks/[subscriberId]/[action].ts b/api/v2/shipping/webhooks/[subscriberId]/[action].ts new file mode 100644 index 000000000..447ca30c6 --- /dev/null +++ b/api/v2/shipping/webhooks/[subscriberId]/[action].ts @@ -0,0 +1,105 @@ +/** + * POST /api/v2/shipping/webhooks/{subscriberId}/rotate-secret + * POST /api/v2/shipping/webhooks/{subscriberId}/reactivate + * + * Preserved on the legacy path-param URL shape because sebuf does not + * currently support path-parameter RPC paths; tracked for eventual + * migration under #3207. + */ + +export const config = { runtime: 'edge' }; + +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../../../_api-key.js'; +// @ts-expect-error — JS module, no declaration file +import { getCorsHeaders } from '../../../../_cors.js'; +import { isCallerPremium } from '../../../../../server/_shared/premium-check'; +import { getCachedJson, setCachedJson } from '../../../../../server/_shared/redis'; +import { + WEBHOOK_TTL, + webhookKey, + callerFingerprint, + generateSecret, + type WebhookRecord, +} from '../../../../../server/worldmonitor/shipping/v2/webhook-shared'; + +export default async function handler(req: Request): Promise { + const cors = getCorsHeaders(req); + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const apiKeyResult = validateApiKey(req, { forceKey: true }); + if (apiKeyResult.required && !apiKeyResult.valid) { + return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), { + status: 401, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const isPro = await isCallerPremium(req); + if (!isPro) { + return new Response(JSON.stringify({ error: 'PRO subscription required' }), { + status: 403, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(req.url); + const parts = url.pathname.replace(/\/+$/, '').split('/'); + const action = parts[parts.length - 1]; + const subscriberId = parts[parts.length - 2]; + + if (!subscriberId || !subscriberId.startsWith('wh_')) { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { + status: 404, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + if (action !== 'rotate-secret' && action !== 'reactivate') { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const record = (await getCachedJson(webhookKey(subscriberId)).catch(() => null)) as WebhookRecord | null; + if (!record) { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { + status: 404, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const ownerHash = await callerFingerprint(req); + if (record.ownerTag !== ownerHash) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + if (action === 'rotate-secret') { + const newSecret = await generateSecret(); + await setCachedJson(webhookKey(subscriberId), { ...record, secret: newSecret }, WEBHOOK_TTL); + return new Response( + JSON.stringify({ subscriberId, secret: newSecret, rotatedAt: new Date().toISOString() }), + { status: 200, headers: { ...cors, 'Content-Type': 'application/json' } }, + ); + } + + // action === 'reactivate' + await setCachedJson(webhookKey(subscriberId), { ...record, active: true }, WEBHOOK_TTL); + return new Response(JSON.stringify({ subscriberId, active: true }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); +} diff --git a/docs/adding-endpoints.mdx b/docs/adding-endpoints.mdx index 7b90fa1a7..bea060689 100644 --- a/docs/adding-endpoints.mdx +++ b/docs/adding-endpoints.mdx @@ -2,11 +2,13 @@ title: "Adding API Endpoints" description: "All JSON API endpoints in World Monitor must use sebuf. This guide walks through adding a new RPC to an existing service and adding an entirely new service." --- -All JSON API endpoints in World Monitor **must** use sebuf. Do not create standalone `api/*.js` files — the legacy pattern is deprecated and being removed. +All JSON API endpoints in World Monitor **must** use sebuf. Do not create standalone `api/*.js` or `api/*.ts` files for new data APIs — the legacy pattern is deprecated and being removed. This guide walks through adding a new RPC to an existing service and adding an entirely new service. -> **Important:** After modifying any `.proto` file, you **must** run `make generate` before building or pushing. The generated TypeScript files in `src/generated/` are checked into the repo and must stay in sync with the proto definitions. CI does not run generation yet — this is your responsibility until we add it to the pipeline (see [#200](https://github.com/koala73/worldmonitor/issues/200)). +> **Enforcement:** `npm run lint:api-contract` runs in CI (see `.github/workflows/lint-code.yml`). It walks every file under `api/`, pairs each sebuf gateway (`api//v/[rpc].ts`) with a generated service under `src/generated/server/worldmonitor/`, and rejects any file that is neither a gateway nor listed in `api/api-route-exceptions.json`. The manifest is the only escape hatch for endpoints that genuinely cannot be proto — OAuth callbacks, binary responses, upstream proxies, operator plumbing — and every entry is pinned to @SebastienMelki via `.github/CODEOWNERS`. Expect reviewer pushback on new entries. +> +> **Generation freshness:** After modifying any `.proto` file, run `make generate` before pushing. The generated TypeScript in `src/generated/` is checked in and must stay in sync; `.github/workflows/proto-check.yml` fails the PR if it drifts. ## Prerequisites diff --git a/docs/api-commerce.mdx b/docs/api-commerce.mdx index 531da8a0b..8dc4f422a 100644 --- a/docs/api-commerce.mdx +++ b/docs/api-commerce.mdx @@ -112,6 +112,6 @@ No `referrals` count or `rewardMonths` is returned today — Dodo's `affonso_ref ## Waitlist -### `POST /api/register-interest` +### `POST /api/leads/v1/register-interest` -Captures an email into the Convex waitlist table. Turnstile-verified, rate-limited per IP. See [Platform endpoints](/api-platform) for the request shape. +Captures an email into the Convex waitlist table. Turnstile-verified (desktop sources bypass), rate-limited per IP. Part of `LeadsService`; see [Platform endpoints](/api-platform) for the request shape. diff --git a/docs/api-platform.mdx b/docs/api-platform.mdx index fa2f6ddc0..7e8fcb0e3 100644 --- a/docs/api-platform.mdx +++ b/docs/api-platform.mdx @@ -146,10 +146,10 @@ Redirects to the matching asset on the latest GitHub release of `koala73/worldmo Caches the 302 for 5 minutes (`s-maxage=300`, `stale-while-revalidate=60`, `stale-if-error=600`). -### `POST /api/contact` +### `POST /api/leads/v1/submit-contact` -Public contact form. Turnstile-verified, rate-limited per IP. +Public enterprise contact form. Turnstile-verified, rate-limited per IP. Part of `LeadsService`. -### `POST /api/register-interest` +### `POST /api/leads/v1/register-interest` -Captures email for desktop-app early-access waitlist. Writes to Convex. +Captures email for Pro-waitlist signup. Writes to Convex and sends a confirmation email. Part of `LeadsService`. diff --git a/docs/api-proxies.mdx b/docs/api-proxies.mdx index 8ebe895f2..87f49a408 100644 --- a/docs/api-proxies.mdx +++ b/docs/api-proxies.mdx @@ -18,14 +18,9 @@ Most proxies are intended for **our own dashboard** and are lightly gated. They | Endpoint | Upstream | Purpose | |----------|----------|---------| -| `GET /api/eia/[...path]` | EIA v2 (energy.gov) | Catch-all proxy for US Energy Information Administration series. Path segments are appended to `https://api.eia.gov/v2/...`. | | `GET /api/opensky` | OpenSky Network | Live ADS-B state vectors. Used by the flights layer. | | `GET /api/polymarket` | Polymarket gamma-api | Active event contracts. | -| `GET /api/satellites` | CelesTrak | LEO/GEO satellite orbital elements. | -| `GET /api/military-flights` | ADS-B Exchange / adsb.lol | Identified military aircraft. | -| `GET /api/ais-snapshot` | Internal AIS seed | Snapshot of latest vessel positions for the currently-loaded bbox. | | `GET /api/gpsjam` | gpsjam.org | GPS interference hotspot reports. | -| `GET /api/sanctions-entity-search?q=...` | OFAC SDN | Fuzzy-match sanctions entity search. | | `GET /api/oref-alerts` | OREF (Israel Home Front Command) | Tzeva Adom rocket alert mirror. | | `GET /api/supply-chain/hormuz-tracker` | Internal AIS + registry | Real-time Hormuz transit dashboard data. | @@ -40,14 +35,6 @@ All proxies: Fetches an RSS/Atom feed and returns the parsed JSON. The URL must match one of the patterns in `_rss-allowed-domains.js` — arbitrary URLs are refused to prevent SSRF. -### `GET /api/enrichment/company?domain=` - -Returns company metadata (name, logo, industry, HQ country) for a website domain. Composite of public sources. - -### `GET /api/enrichment/signals?domain=` - -Returns trust and risk signals (TLS grade, DNS age, WHOIS country, threat-list membership) for a domain. - ## Skills registry ### `GET /api/skills/fetch-agentskills` diff --git a/docs/api-scenarios.mdx b/docs/api-scenarios.mdx index 75db048bc..6112e9c25 100644 --- a/docs/api-scenarios.mdx +++ b/docs/api-scenarios.mdx @@ -6,12 +6,24 @@ description: "Run pre-defined supply-chain disruption scenarios against any coun The **scenarios** API is a PRO-only, job-queued surface on top of the WorldMonitor chokepoint + trade dataset. Callers enqueue a named scenario template against an optional country, then poll a job-id until the worker completes. -This service is documented inline (not yet proto-backed). Proto migration is tracked in [issue #3207](https://github.com/koala73/worldmonitor/issues/3207) and will replace this page with auto-generated reference. +This service is proto-backed — see `proto/worldmonitor/scenario/v1/service.proto`. Auto-generated reference will replace this page once the scenario service is included in the published OpenAPI bundle. + +**Legacy v1 URL aliases** — the sebuf migration (#3207) renamed the three v1 endpoints to align with the proto RPC names. The old URLs are preserved as thin aliases so existing integrations keep working: + +| Legacy URL | Canonical URL | +|---|---| +| `POST /api/scenario/v1/run` | `POST /api/scenario/v1/run-scenario` | +| `GET /api/scenario/v1/status` | `GET /api/scenario/v1/get-scenario-status` | +| `GET /api/scenario/v1/templates` | `GET /api/scenario/v1/list-scenario-templates` | + +Prefer the canonical URLs in new code — the aliases will retire at the next v1→v2 break (tracked in [#3282](https://github.com/koala73/worldmonitor/issues/3282)). + + ## List templates -### `GET /api/scenario/v1/templates` +### `GET /api/scenario/v1/list-scenario-templates` Returns the catalog of pre-defined scenario templates. Cached `public, max-age=3600`. @@ -32,18 +44,18 @@ Returns the catalog of pre-defined scenario templates. Cached `public, max-age=3 } ``` -Other shipped templates at the time of writing: `taiwan-strait-full-closure`, `suez-bab-simultaneous`, `panama-drought-50pct`, `russia-baltic-grain-suspension`, `us-tariff-escalation-electronics`. Use the live `/templates` response as the source of truth — the set grows over time. +Other shipped templates at the time of writing: `taiwan-strait-full-closure`, `suez-bab-simultaneous`, `panama-drought-50pct`, `russia-baltic-grain-suspension`, `us-tariff-escalation-electronics`. Use the live `/list-scenario-templates` response as the source of truth — the set grows over time. `affectedHs2: []` on the wire means the scenario affects ALL sectors (the registry's `null` sentinel, which `repeated string` cannot carry directly). ## Run a scenario -### `POST /api/scenario/v1/run` +### `POST /api/scenario/v1/run-scenario` -Enqueues a job. Returns `202 Accepted` with a `jobId` the caller must poll. +Enqueues a job. Returns the assigned `jobId` the caller must poll. - **Auth**: PRO entitlement required. Granted by either (a) a valid `X-WorldMonitor-Key` (env key from `WORLDMONITOR_VALID_KEYS`, or a user-owned `wm_`-prefixed key whose owner has the `apiAccess` entitlement), **or** (b) a Clerk bearer token whose user has role `pro` or Dodo entitlement tier ≥ 1. A trusted browser Origin alone is **not** sufficient — `isCallerPremium()` in `server/_shared/premium-check.ts` only counts explicit credentials. Browser calls work because `premiumFetch()` (`src/services/premium-fetch.ts`) injects one of the two credential forms on the caller's behalf. - **Rate limits**: - - 10 jobs / minute / user - - Global queue capped at 100 in-flight jobs; excess rejected with `429` + `Retry-After: 30` + - 10 jobs / minute / IP (enforced at the gateway via `ENDPOINT_RATE_POLICIES` in `server/_shared/rate-limit.ts`) + - Global queue capped at 100 in-flight jobs; excess rejected with `429` **Request**: ```json @@ -53,77 +65,83 @@ Enqueues a job. Returns `202 Accepted` with a `jobId` the caller must poll. } ``` -- `scenarioId` — id from `/templates`. Required. -- `iso2` — optional ISO-3166-1 alpha-2 (uppercase). Scopes the scenario to one country. +- `scenarioId` — id from `/list-scenario-templates`. Required. +- `iso2` — optional ISO-3166-1 alpha-2 (uppercase). Scopes the scenario to one country. Empty string = scope-all. -**Response (`202`)**: +**Response (`200`)**: ```json { "jobId": "scenario:1713456789012:a1b2c3d4", "status": "pending", - "statusUrl": "/api/scenario/v1/status?jobId=scenario:1713456789012:a1b2c3d4" + "statusUrl": "/api/scenario/v1/get-scenario-status?jobId=scenario%3A1713456789012%3Aa1b2c3d4" } ``` +- `statusUrl` — server-computed convenience URL. Callers that don't want to hardcode the status path can follow this directly (it URL-encodes the `jobId`). + + +**Wire-contract change (v1 → v1)** — the pre-sebuf-migration endpoint returned `202 Accepted` on successful enqueue; the migrated endpoint returns `200 OK`. No per-RPC status-code configuration is available in sebuf's HTTP annotations today, and introducing a `/v2` for a single status-code shift was judged heavier than the break itself. + +If your integration branches on `response.status === 202`, switch to branching on response body shape (`response.body.status === "pending"` indicates enqueue success). `statusUrl` is preserved exactly as before and is a safe signal to key off. + + **Errors**: -| Status | `error` | Cause | -|--------|---------|-------| -| 400 | `Invalid JSON body` | Body is not valid JSON | -| 400 | `scenarioId is required` | Missing field | -| 400 | `Unknown scenario: ...` | `scenarioId` not in the template catalog | -| 400 | `iso2 must be a 2-letter uppercase country code` | Malformed `iso2` | +| Status | `message` | Cause | +|--------|-----------|-------| +| 400 | `Validation failed` (violations include `scenarioId`) | Missing or unknown `scenarioId` | +| 400 | `Validation failed` (violations include `iso2`) | Malformed `iso2` | | 403 | `PRO subscription required` | Not PRO | -| 405 | — | Method other than `POST` | -| 429 | `Rate limit exceeded: 10 scenario jobs per minute` | Per-user rate limit | +| 405 | — | Method other than `POST` (enforced by sebuf service-config) | +| 429 | `Too many requests` | Per-IP 10/min gateway rate limit | | 429 | `Scenario queue is at capacity, please try again later` | Global queue > 100 | | 502 | `Failed to enqueue scenario job` | Redis enqueue failure | -| 503 | `Service temporarily unavailable` | Missing env | ## Poll job status -### `GET /api/scenario/v1/status?jobId=` +### `GET /api/scenario/v1/get-scenario-status?jobId=` Returns the job's current state as written by the worker, or a synthesised `pending` stub while the job is still queued. -- **Auth**: same as `/run` +- **Auth**: same as `/run-scenario` - **jobId format**: `scenario:{unix-ms}:{8-char-suffix}` — strictly validated to guard against path traversal **Status lifecycle**: -| `status` | When | Additional fields | -|---|---|---| -| `pending` | Job enqueued but worker has not picked it up yet. Synthesised by the status handler when no Redis record exists. | — | -| `processing` | Worker dequeued the job and started computing. Written by the worker at job pickup. | `startedAt` (ms epoch) | -| `done` | Worker completed successfully. | `completedAt`, `result` (scenario-specific payload) | -| `failed` | Worker hit a computation error. | `failedAt`, `error` (string) | +| `status` | When | +|---|---| +| `pending` | Job enqueued but worker has not picked it up yet. Synthesised by the status handler when no Redis record exists. | +| `processing` | Worker dequeued the job and started computing. | +| `done` | Worker completed successfully; `result` is populated. | +| `failed` | Worker hit a computation error; `error` is populated. | **Pending response (`200`)**: ```json -{ - "jobId": "scenario:1713456789012:a1b2c3d4", - "status": "pending" -} +{ "status": "pending", "error": "" } ``` **Processing response (`200`)**: ```json -{ - "status": "processing", - "startedAt": 1713456789500 -} +{ "status": "processing", "error": "" } ``` -**Done response (`200`)** — the worker writes the result directly to Redis; the status endpoint returns it verbatim: +**Done response (`200`)** — `result` carries the worker's computed payload: ```json { "status": "done", - "completedAt": 1713456890123, + "error": "", "result": { - "costShockPct": 14.2, - "affectedImportValueUsd": 8400000000, - "topExposedSectors": ["refined_petroleum", "chemicals"] + "affectedChokepointIds": ["hormuz_strait"], + "topImpactCountries": [ + { "iso2": "JP", "totalImpact": 1500.0, "impactPct": 100 } + ], + "template": { + "name": "hormuz_strait", + "disruptionPct": 100, + "durationDays": 14, + "costShockMultiplier": 2.10 + } } } ``` @@ -131,25 +149,19 @@ Returns the job's current state as written by the worker, or a synthesised `pend **Failed response (`200`)**: ```json -{ - "status": "failed", - "error": "computation_error", - "failedAt": 1713456890123 -} +{ "status": "failed", "error": "computation_error" } ``` Poll loop: treat `pending` and `processing` as non-terminal; only `done` and `failed` are terminal. Both pending and processing can legitimately persist for several seconds under load. **Errors**: -| Status | `error` | Cause | -|--------|---------|-------| -| 400 | `Invalid or missing jobId` | Missing or malformed `jobId` | +| Status | `message` | Cause | +|--------|-----------|-------| +| 400 | `Validation failed` (violations include `jobId`) | Missing or malformed `jobId` | | 403 | `PRO subscription required` | Not PRO | -| 405 | — | Method other than `GET` | -| 500 | `Corrupted job result` | Worker wrote invalid JSON | +| 405 | — | Method other than `GET` (enforced by sebuf service-config) | | 502 | `Failed to fetch job status` | Redis read failure | -| 503 | `Service temporarily unavailable` | Missing env | ## Polling strategy diff --git a/docs/api/LeadsService.openapi.json b/docs/api/LeadsService.openapi.json new file mode 100644 index 000000000..4b29246be --- /dev/null +++ b/docs/api/LeadsService.openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"RegisterInterestRequest":{"description":"RegisterInterestRequest carries a Pro-waitlist signup.","properties":{"appVersion":{"type":"string"},"email":{"type":"string"},"referredBy":{"type":"string"},"source":{"type":"string"},"turnstileToken":{"description":"Cloudflare Turnstile token. Desktop sources bypass Turnstile; see handler.","type":"string"},"website":{"description":"Honeypot — bots auto-fill this hidden field; real submissions leave it empty.","type":"string"}},"type":"object"},"RegisterInterestResponse":{"description":"RegisterInterestResponse mirrors the Convex registerInterest:register return shape.","properties":{"emailSuppressed":{"description":"True when the email is on the suppression list (prior bounce) and no confirmation was sent.","type":"boolean"},"position":{"description":"Waitlist position at registration time. Present only when status == \"registered\".","format":"int32","type":"integer"},"referralCode":{"description":"Stable referral code for this email.","type":"string"},"referralCount":{"description":"Number of signups credited to this email.","format":"int32","type":"integer"},"status":{"description":"\"registered\" for a new signup; \"already_registered\" for a returning email.","type":"string"}},"type":"object"},"SubmitContactRequest":{"description":"SubmitContactRequest carries an enterprise contact form submission.","properties":{"email":{"type":"string"},"message":{"type":"string"},"name":{"type":"string"},"organization":{"type":"string"},"phone":{"type":"string"},"source":{"type":"string"},"turnstileToken":{"description":"Cloudflare Turnstile token proving the submitter is human.","type":"string"},"website":{"description":"Honeypot — bots auto-fill this hidden field; real submissions leave it empty.","type":"string"}},"type":"object"},"SubmitContactResponse":{"description":"SubmitContactResponse reports the outcome of storing the lead and notifying ops.","properties":{"emailSent":{"description":"True when the Resend notification to ops was delivered.","type":"boolean"},"status":{"description":"Always \"sent\" on success.","type":"string"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"LeadsService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/leads/v1/register-interest":{"post":{"description":"RegisterInterest adds an email to the Pro waitlist and sends a confirmation email.","operationId":"RegisterInterest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInterestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInterestResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"RegisterInterest","tags":["LeadsService"]}},"/api/leads/v1/submit-contact":{"post":{"description":"SubmitContact stores an enterprise contact submission in Convex and emails ops.","operationId":"SubmitContact","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitContactRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitContactResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"SubmitContact","tags":["LeadsService"]}}}} \ No newline at end of file diff --git a/docs/api/LeadsService.openapi.yaml b/docs/api/LeadsService.openapi.yaml new file mode 100644 index 000000000..c7d1126e1 --- /dev/null +++ b/docs/api/LeadsService.openapi.yaml @@ -0,0 +1,173 @@ +openapi: 3.1.0 +info: + title: LeadsService API + version: 1.0.0 +paths: + /api/leads/v1/submit-contact: + post: + tags: + - LeadsService + summary: SubmitContact + description: SubmitContact stores an enterprise contact submission in Convex and emails ops. + operationId: SubmitContact + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SubmitContactRequest' + required: true + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/SubmitContactResponse' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + default: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/leads/v1/register-interest: + post: + tags: + - LeadsService + summary: RegisterInterest + description: RegisterInterest adds an email to the Pro waitlist and sends a confirmation email. + operationId: RegisterInterest + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterInterestRequest' + required: true + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterInterestResponse' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + default: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Error: + type: object + properties: + message: + type: string + description: Error message (e.g., 'user not found', 'database connection failed') + description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize. + FieldViolation: + type: object + properties: + field: + type: string + description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key') + description: + type: string + description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing') + required: + - field + - description + description: FieldViolation describes a single validation error for a specific field. + ValidationError: + type: object + properties: + violations: + type: array + items: + $ref: '#/components/schemas/FieldViolation' + description: List of validation violations + required: + - violations + description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong. + SubmitContactRequest: + type: object + properties: + email: + type: string + name: + type: string + organization: + type: string + phone: + type: string + message: + type: string + source: + type: string + website: + type: string + description: Honeypot — bots auto-fill this hidden field; real submissions leave it empty. + turnstileToken: + type: string + description: Cloudflare Turnstile token proving the submitter is human. + description: SubmitContactRequest carries an enterprise contact form submission. + SubmitContactResponse: + type: object + properties: + status: + type: string + description: Always "sent" on success. + emailSent: + type: boolean + description: True when the Resend notification to ops was delivered. + description: SubmitContactResponse reports the outcome of storing the lead and notifying ops. + RegisterInterestRequest: + type: object + properties: + email: + type: string + source: + type: string + appVersion: + type: string + referredBy: + type: string + website: + type: string + description: Honeypot — bots auto-fill this hidden field; real submissions leave it empty. + turnstileToken: + type: string + description: Cloudflare Turnstile token. Desktop sources bypass Turnstile; see handler. + description: RegisterInterestRequest carries a Pro-waitlist signup. + RegisterInterestResponse: + type: object + properties: + status: + type: string + description: '"registered" for a new signup; "already_registered" for a returning email.' + referralCode: + type: string + description: Stable referral code for this email. + referralCount: + type: integer + format: int32 + description: Number of signups credited to this email. + position: + type: integer + format: int32 + description: Waitlist position at registration time. Present only when status == "registered". + emailSuppressed: + type: boolean + description: True when the email is on the suppression list (prior bounce) and no confirmation was sent. + description: RegisterInterestResponse mirrors the Convex registerInterest:register return shape. diff --git a/docs/api/MaritimeService.openapi.json b/docs/api/MaritimeService.openapi.json index 0eef7e2e2..ae1de46fa 100644 --- a/docs/api/MaritimeService.openapi.json +++ b/docs/api/MaritimeService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AisDensityZone":{"description":"AisDensityZone represents a zone of concentrated vessel traffic.","properties":{"deltaPct":{"description":"Change from baseline as a percentage.","format":"double","type":"number"},"id":{"description":"Zone identifier.","minLength":1,"type":"string"},"intensity":{"description":"Traffic intensity score (0-100).","format":"double","maximum":100,"minimum":0,"type":"number"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Zone name (e.g., \"Strait of Malacca\").","type":"string"},"note":{"description":"Analyst note.","type":"string"},"shipsPerDay":{"description":"Estimated ships per day.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisDisruption":{"description":"AisDisruption represents a detected anomaly in AIS vessel tracking data.","properties":{"changePct":{"description":"Percentage change from normal.","format":"double","type":"number"},"darkShips":{"description":"Number of dark ships (AIS off) detected.","format":"int32","type":"integer"},"description":{"description":"Human-readable description.","type":"string"},"id":{"description":"Disruption identifier.","minLength":1,"type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name.","type":"string"},"region":{"description":"Region name.","type":"string"},"severity":{"description":"AisDisruptionSeverity represents the severity of an AIS disruption.","enum":["AIS_DISRUPTION_SEVERITY_UNSPECIFIED","AIS_DISRUPTION_SEVERITY_LOW","AIS_DISRUPTION_SEVERITY_ELEVATED","AIS_DISRUPTION_SEVERITY_HIGH"],"type":"string"},"type":{"description":"AisDisruptionType represents the type of AIS tracking anomaly.\n Maps to TS union: 'gap_spike' | 'chokepoint_congestion'.","enum":["AIS_DISRUPTION_TYPE_UNSPECIFIED","AIS_DISRUPTION_TYPE_GAP_SPIKE","AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION"],"type":"string"},"vesselCount":{"description":"Number of vessels in the affected area.","format":"int32","type":"integer"},"windowHours":{"description":"Analysis window in hours.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"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"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetVesselSnapshotRequest":{"description":"GetVesselSnapshotRequest specifies filters for the vessel snapshot.","properties":{"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"GetVesselSnapshotResponse":{"description":"GetVesselSnapshotResponse contains the vessel traffic snapshot.","properties":{"snapshot":{"$ref":"#/components/schemas/VesselSnapshot"}},"type":"object"},"ListNavigationalWarningsRequest":{"description":"ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings.","properties":{"area":{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListNavigationalWarningsResponse":{"description":"ListNavigationalWarningsResponse contains navigational warnings matching the request.","properties":{"pagination":{"$ref":"#/components/schemas/PaginationResponse"},"warnings":{"items":{"$ref":"#/components/schemas/NavigationalWarning"},"type":"array"}},"type":"object"},"NavigationalWarning":{"description":"NavigationalWarning represents a maritime safety warning from NGA.","properties":{"area":{"description":"Geographic area affected.","type":"string"},"authority":{"description":"Warning source authority.","type":"string"},"expiresAt":{"description":"Warning expiry date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Warning identifier.","type":"string"},"issuedAt":{"description":"Warning issue date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"text":{"description":"Full warning text.","type":"string"},"title":{"description":"Warning title.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"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"},"VesselSnapshot":{"description":"VesselSnapshot represents a point-in-time view of civilian AIS vessel data.","properties":{"densityZones":{"items":{"$ref":"#/components/schemas/AisDensityZone"},"type":"array"},"disruptions":{"items":{"$ref":"#/components/schemas/AisDisruption"},"type":"array"},"snapshotAt":{"description":"Snapshot timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"}}},"info":{"title":"MaritimeService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/maritime/v1/get-vessel-snapshot":{"get":{"description":"GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions.","operationId":"GetVesselSnapshot","parameters":[{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVesselSnapshotResponse"}}},"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":"GetVesselSnapshot","tags":["MaritimeService"]}},"/api/maritime/v1/list-navigational-warnings":{"get":{"description":"ListNavigationalWarnings retrieves active maritime safety warnings from NGA.","operationId":"ListNavigationalWarnings","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","in":"query","name":"area","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNavigationalWarningsResponse"}}},"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":"ListNavigationalWarnings","tags":["MaritimeService"]}}}} \ No newline at end of file +{"components":{"schemas":{"AisDensityZone":{"description":"AisDensityZone represents a zone of concentrated vessel traffic.","properties":{"deltaPct":{"description":"Change from baseline as a percentage.","format":"double","type":"number"},"id":{"description":"Zone identifier.","minLength":1,"type":"string"},"intensity":{"description":"Traffic intensity score (0-100).","format":"double","maximum":100,"minimum":0,"type":"number"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Zone name (e.g., \"Strait of Malacca\").","type":"string"},"note":{"description":"Analyst note.","type":"string"},"shipsPerDay":{"description":"Estimated ships per day.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisDisruption":{"description":"AisDisruption represents a detected anomaly in AIS vessel tracking data.","properties":{"changePct":{"description":"Percentage change from normal.","format":"double","type":"number"},"darkShips":{"description":"Number of dark ships (AIS off) detected.","format":"int32","type":"integer"},"description":{"description":"Human-readable description.","type":"string"},"id":{"description":"Disruption identifier.","minLength":1,"type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name.","type":"string"},"region":{"description":"Region name.","type":"string"},"severity":{"description":"AisDisruptionSeverity represents the severity of an AIS disruption.","enum":["AIS_DISRUPTION_SEVERITY_UNSPECIFIED","AIS_DISRUPTION_SEVERITY_LOW","AIS_DISRUPTION_SEVERITY_ELEVATED","AIS_DISRUPTION_SEVERITY_HIGH"],"type":"string"},"type":{"description":"AisDisruptionType represents the type of AIS tracking anomaly.\n Maps to TS union: 'gap_spike' | 'chokepoint_congestion'.","enum":["AIS_DISRUPTION_TYPE_UNSPECIFIED","AIS_DISRUPTION_TYPE_GAP_SPIKE","AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION"],"type":"string"},"vesselCount":{"description":"Number of vessels in the affected area.","format":"int32","type":"integer"},"windowHours":{"description":"Analysis window in hours.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisSnapshotStatus":{"description":"AisSnapshotStatus reports relay health at the time of the snapshot.","properties":{"connected":{"description":"Whether the relay WebSocket is connected to the AIS provider.","type":"boolean"},"messages":{"description":"Total AIS messages processed in the current session.","format":"int32","type":"integer"},"vessels":{"description":"Number of vessels currently tracked by the relay.","format":"int32","type":"integer"}},"type":"object"},"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"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetVesselSnapshotRequest":{"description":"GetVesselSnapshotRequest specifies filters for the vessel snapshot.","properties":{"includeCandidates":{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","type":"boolean"},"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"GetVesselSnapshotResponse":{"description":"GetVesselSnapshotResponse contains the vessel traffic snapshot.","properties":{"snapshot":{"$ref":"#/components/schemas/VesselSnapshot"}},"type":"object"},"ListNavigationalWarningsRequest":{"description":"ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings.","properties":{"area":{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListNavigationalWarningsResponse":{"description":"ListNavigationalWarningsResponse contains navigational warnings matching the request.","properties":{"pagination":{"$ref":"#/components/schemas/PaginationResponse"},"warnings":{"items":{"$ref":"#/components/schemas/NavigationalWarning"},"type":"array"}},"type":"object"},"NavigationalWarning":{"description":"NavigationalWarning represents a maritime safety warning from NGA.","properties":{"area":{"description":"Geographic area affected.","type":"string"},"authority":{"description":"Warning source authority.","type":"string"},"expiresAt":{"description":"Warning expiry date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Warning identifier.","type":"string"},"issuedAt":{"description":"Warning issue date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"text":{"description":"Full warning text.","type":"string"},"title":{"description":"Warning title.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"SnapshotCandidateReport":{"description":"SnapshotCandidateReport is a per-vessel position report attached to a\n snapshot. Used to drive the client-side position callback system.","properties":{"course":{"description":"Course over ground in degrees.","format":"int32","type":"integer"},"heading":{"description":"Heading in degrees (0-359, or 511 for unavailable).","format":"int32","type":"integer"},"lat":{"description":"Latitude in decimal degrees.","format":"double","type":"number"},"lon":{"description":"Longitude in decimal degrees.","format":"double","type":"number"},"mmsi":{"description":"Maritime Mobile Service Identity.","type":"string"},"name":{"description":"Vessel name (may be empty if unknown).","type":"string"},"shipType":{"description":"AIS ship type code (0 if unknown).","format":"int32","type":"integer"},"speed":{"description":"Speed over ground in knots.","format":"double","type":"number"},"timestamp":{"description":"Report timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"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"},"VesselSnapshot":{"description":"VesselSnapshot represents a point-in-time view of civilian AIS vessel data.","properties":{"candidateReports":{"items":{"$ref":"#/components/schemas/SnapshotCandidateReport"},"type":"array"},"densityZones":{"items":{"$ref":"#/components/schemas/AisDensityZone"},"type":"array"},"disruptions":{"items":{"$ref":"#/components/schemas/AisDisruption"},"type":"array"},"sequence":{"description":"Monotonic sequence number from the relay. Clients use this to detect stale\n responses during polling.","format":"int32","type":"integer"},"snapshotAt":{"description":"Snapshot timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"status":{"$ref":"#/components/schemas/AisSnapshotStatus"}},"type":"object"}}},"info":{"title":"MaritimeService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/maritime/v1/get-vessel-snapshot":{"get":{"description":"GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions.","operationId":"GetVesselSnapshot","parameters":[{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","in":"query","name":"include_candidates","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVesselSnapshotResponse"}}},"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":"GetVesselSnapshot","tags":["MaritimeService"]}},"/api/maritime/v1/list-navigational-warnings":{"get":{"description":"ListNavigationalWarnings retrieves active maritime safety warnings from NGA.","operationId":"ListNavigationalWarnings","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","in":"query","name":"area","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNavigationalWarningsResponse"}}},"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":"ListNavigationalWarnings","tags":["MaritimeService"]}}}} \ No newline at end of file diff --git a/docs/api/MaritimeService.openapi.yaml b/docs/api/MaritimeService.openapi.yaml index 93af48f78..1e44db3a8 100644 --- a/docs/api/MaritimeService.openapi.yaml +++ b/docs/api/MaritimeService.openapi.yaml @@ -39,6 +39,15 @@ paths: schema: type: number format: double + - name: include_candidates + in: query + description: |- + When true, populate VesselSnapshot.candidate_reports with per-vessel + position reports. Clients with no position callbacks should leave this + false to keep responses small. + required: false + schema: + type: boolean responses: "200": description: Successful response @@ -156,6 +165,12 @@ components: type: number format: double description: South-west corner longitude of bounding box. + includeCandidates: + type: boolean + description: |- + When true, populate VesselSnapshot.candidate_reports with per-vessel + position reports. Clients with no position callbacks should leave this + false to keep responses small. description: GetVesselSnapshotRequest specifies filters for the vessel snapshot. GetVesselSnapshotResponse: type: object @@ -178,6 +193,18 @@ components: type: array items: $ref: '#/components/schemas/AisDisruption' + sequence: + type: integer + format: int32 + description: |- + Monotonic sequence number from the relay. Clients use this to detect stale + responses during polling. + status: + $ref: '#/components/schemas/AisSnapshotStatus' + candidateReports: + type: array + items: + $ref: '#/components/schemas/SnapshotCandidateReport' description: VesselSnapshot represents a point-in-time view of civilian AIS vessel data. AisDensityZone: type: object @@ -281,6 +308,61 @@ components: required: - id description: AisDisruption represents a detected anomaly in AIS vessel tracking data. + AisSnapshotStatus: + type: object + properties: + connected: + type: boolean + description: Whether the relay WebSocket is connected to the AIS provider. + vessels: + type: integer + format: int32 + description: Number of vessels currently tracked by the relay. + messages: + type: integer + format: int32 + description: Total AIS messages processed in the current session. + description: AisSnapshotStatus reports relay health at the time of the snapshot. + SnapshotCandidateReport: + type: object + properties: + mmsi: + type: string + description: Maritime Mobile Service Identity. + name: + type: string + description: Vessel name (may be empty if unknown). + lat: + type: number + format: double + description: Latitude in decimal degrees. + lon: + type: number + format: double + description: Longitude in decimal degrees. + shipType: + type: integer + format: int32 + description: AIS ship type code (0 if unknown). + heading: + type: integer + format: int32 + description: Heading in degrees (0-359, or 511 for unavailable). + speed: + type: number + format: double + description: Speed over ground in knots. + course: + type: integer + format: int32 + description: Course over ground in degrees. + timestamp: + type: integer + format: int64 + description: 'Report timestamp, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript' + description: |- + SnapshotCandidateReport is a per-vessel position report attached to a + snapshot. Used to drive the client-side position callback system. ListNavigationalWarningsRequest: type: object properties: diff --git a/docs/api/MilitaryService.openapi.json b/docs/api/MilitaryService.openapi.json index 8b56d078f..310bcc661 100644 --- a/docs/api/MilitaryService.openapi.json +++ b/docs/api/MilitaryService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AircraftDetails":{"description":"AircraftDetails contains Wingbits aircraft enrichment data.","properties":{"built":{"description":"Build date.","type":"string"},"categoryDescription":{"description":"ICAO category description.","type":"string"},"engines":{"description":"Engine description.","type":"string"},"icao24":{"description":"ICAO 24-bit hex address.","type":"string"},"icaoAircraftType":{"description":"ICAO aircraft type designator.","type":"string"},"manufacturerIcao":{"description":"ICAO manufacturer code.","type":"string"},"manufacturerName":{"description":"Full manufacturer name.","type":"string"},"model":{"description":"Aircraft model.","type":"string"},"operator":{"description":"Operator name.","type":"string"},"operatorCallsign":{"description":"Operator callsign.","type":"string"},"operatorIcao":{"description":"Operator ICAO code.","type":"string"},"owner":{"description":"Registered owner.","type":"string"},"registration":{"description":"Aircraft registration number.","type":"string"},"serialNumber":{"description":"Manufacturer serial number.","type":"string"},"typecode":{"description":"ICAO type designator code.","type":"string"}},"type":"object"},"BattleForceSummary":{"description":"BattleForceSummary contains fleet-wide ship count statistics.","properties":{"deployed":{"description":"Number of ships currently deployed.","format":"int32","minimum":0,"type":"integer"},"totalShips":{"description":"Total ships in the battle force.","format":"int32","minimum":0,"type":"integer"},"underway":{"description":"Number of ships currently underway.","format":"int32","minimum":0,"type":"integer"}},"type":"object"},"DefensePatentFiling":{"description":"DefensePatentFiling is a single patent filing from a defense/dual-use organization.","properties":{"abstract":{"description":"Patent abstract (truncated to 500 chars).","type":"string"},"assignee":{"description":"Primary assignee organization name.","type":"string"},"cpcCode":{"description":"CPC classification code (e.g. \"H04B\", \"F42B\").","type":"string"},"cpcDesc":{"description":"CPC class description.","type":"string"},"date":{"description":"Filing date (YYYY-MM-DD).","type":"string"},"patentId":{"description":"USPTO patent ID.","type":"string"},"title":{"description":"Patent title.","type":"string"},"url":{"description":"USPTO patent URL.","type":"string"}},"type":"object"},"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"},"FlightEnrichment":{"description":"FlightEnrichment contains additional data from Wingbits aircraft database.","properties":{"builtYear":{"description":"Year the aircraft was built.","type":"string"},"confirmedMilitary":{"description":"Whether confirmed as military.","type":"boolean"},"manufacturer":{"description":"Aircraft manufacturer.","type":"string"},"militaryBranch":{"description":"Military branch designation.","type":"string"},"operatorName":{"description":"Operator name.","type":"string"},"owner":{"description":"Registered owner.","type":"string"},"typeCode":{"description":"ICAO type code.","type":"string"}},"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetAircraftDetailsBatchRequest":{"description":"GetAircraftDetailsBatchRequest looks up multiple aircraft by ICAO 24-bit hex.","properties":{"icao24s":{"items":{"description":"ICAO 24-bit hex addresses (lowercase). Max 20.","maxItems":20,"minItems":1,"type":"string"},"maxItems":20,"minItems":1,"type":"array"}},"type":"object"},"GetAircraftDetailsBatchResponse":{"description":"GetAircraftDetailsBatchResponse contains the batch lookup results.","properties":{"configured":{"description":"Whether the Wingbits API is configured.","type":"boolean"},"fetched":{"description":"Number of aircraft successfully fetched from upstream.","format":"int32","type":"integer"},"requested":{"description":"Number of aircraft requested.","format":"int32","type":"integer"},"results":{"additionalProperties":{"$ref":"#/components/schemas/AircraftDetails"},"description":"Map of icao24 -\u003e aircraft details for found aircraft.","type":"object"}},"type":"object"},"GetAircraftDetailsRequest":{"description":"GetAircraftDetailsRequest looks up a single aircraft by ICAO 24-bit hex.","properties":{"icao24":{"description":"ICAO 24-bit hex address (lowercase).","minLength":1,"type":"string"}},"required":["icao24"],"type":"object"},"GetAircraftDetailsResponse":{"description":"GetAircraftDetailsResponse contains the aircraft enrichment data.","properties":{"configured":{"description":"Whether the Wingbits API is configured.","type":"boolean"},"details":{"$ref":"#/components/schemas/AircraftDetails"}},"type":"object"},"GetTheaterPostureRequest":{"description":"GetTheaterPostureRequest specifies the theater to assess.","properties":{"theater":{"description":"Theater name (e.g., \"indo-pacific\", \"european\", \"middle-east\"). Empty for all theaters.","type":"string"}},"type":"object"},"GetTheaterPostureResponse":{"description":"GetTheaterPostureResponse contains theater posture assessments.","properties":{"theaters":{"items":{"$ref":"#/components/schemas/TheaterPosture"},"type":"array"}},"type":"object"},"GetUSNIFleetReportRequest":{"description":"GetUSNIFleetReportRequest requests the latest USNI Fleet Tracker report.","properties":{"forceRefresh":{"description":"When true, bypass cache and fetch fresh data from USNI.","type":"boolean"}},"type":"object"},"GetUSNIFleetReportResponse":{"description":"GetUSNIFleetReportResponse returns the parsed USNI Fleet Tracker report.","properties":{"cached":{"description":"Whether the response was served from cache.","type":"boolean"},"error":{"description":"Error message, if any.","type":"string"},"report":{"$ref":"#/components/schemas/USNIFleetReport"},"stale":{"description":"Whether the cached data is stale (served after a fetch failure).","type":"boolean"}},"type":"object"},"GetWingbitsLiveFlightRequest":{"description":"GetWingbitsLiveFlightRequest fetches live Wingbits ECS data for a single aircraft.","properties":{"icao24":{"description":"ICAO 24-bit hex address (lowercase, 6 characters).","minLength":1,"type":"string"}},"required":["icao24"],"type":"object"},"GetWingbitsLiveFlightResponse":{"description":"GetWingbitsLiveFlightResponse contains the live flight data, if available.","properties":{"flight":{"$ref":"#/components/schemas/WingbitsLiveFlight"}},"type":"object"},"GetWingbitsStatusRequest":{"description":"GetWingbitsStatusRequest checks whether the Wingbits enrichment API is configured.","type":"object"},"GetWingbitsStatusResponse":{"description":"GetWingbitsStatusResponse indicates whether Wingbits is available.","properties":{"configured":{"description":"Whether the Wingbits API key is configured on the server.","type":"boolean"}},"type":"object"},"ListDefensePatentsRequest":{"description":"ListDefensePatentsRequest filters defense/dual-use patent filings.","properties":{"assignee":{"description":"Assignee keyword filter (case-insensitive substring match). Empty returns all.","type":"string"},"cpcCode":{"description":"CPC category filter (e.g. \"H04B\", \"F42B\"). Empty returns all categories.","type":"string"},"limit":{"description":"Maximum results to return (default 20, max 100).","format":"int32","type":"integer"}},"type":"object"},"ListDefensePatentsResponse":{"description":"ListDefensePatentsResponse contains recent defense/dual-use patent filings.","properties":{"fetchedAt":{"description":"ISO 8601 timestamp when data was seeded.","type":"string"},"patents":{"items":{"$ref":"#/components/schemas/DefensePatentFiling"},"type":"array"},"total":{"description":"Total number of filings in the seeded dataset.","format":"int32","type":"integer"}},"type":"object"},"ListMilitaryBasesRequest":{"properties":{"country":{"type":"string"},"kind":{"type":"string"},"neLat":{"format":"double","type":"number"},"neLon":{"format":"double","type":"number"},"swLat":{"format":"double","type":"number"},"swLon":{"format":"double","type":"number"},"type":{"type":"string"},"zoom":{"format":"int32","type":"integer"}},"type":"object"},"ListMilitaryBasesResponse":{"properties":{"bases":{"items":{"$ref":"#/components/schemas/MilitaryBaseEntry"},"type":"array"},"clusters":{"items":{"$ref":"#/components/schemas/MilitaryBaseCluster"},"type":"array"},"totalInView":{"format":"int32","type":"integer"},"truncated":{"type":"boolean"}},"type":"object"},"ListMilitaryFlightsRequest":{"description":"ListMilitaryFlightsRequest specifies filters for retrieving military flight data.","properties":{"aircraftType":{"description":"MilitaryAircraftType represents the classification of a military aircraft.","enum":["MILITARY_AIRCRAFT_TYPE_UNSPECIFIED","MILITARY_AIRCRAFT_TYPE_FIGHTER","MILITARY_AIRCRAFT_TYPE_BOMBER","MILITARY_AIRCRAFT_TYPE_TRANSPORT","MILITARY_AIRCRAFT_TYPE_TANKER","MILITARY_AIRCRAFT_TYPE_AWACS","MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE","MILITARY_AIRCRAFT_TYPE_HELICOPTER","MILITARY_AIRCRAFT_TYPE_DRONE","MILITARY_AIRCRAFT_TYPE_PATROL","MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS","MILITARY_AIRCRAFT_TYPE_VIP","MILITARY_AIRCRAFT_TYPE_UNKNOWN"],"type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"operator":{"description":"MilitaryOperator represents the military branch or force operating an asset.","enum":["MILITARY_OPERATOR_UNSPECIFIED","MILITARY_OPERATOR_USAF","MILITARY_OPERATOR_USN","MILITARY_OPERATOR_USMC","MILITARY_OPERATOR_USA","MILITARY_OPERATOR_RAF","MILITARY_OPERATOR_RN","MILITARY_OPERATOR_FAF","MILITARY_OPERATOR_GAF","MILITARY_OPERATOR_PLAAF","MILITARY_OPERATOR_PLAN","MILITARY_OPERATOR_VKS","MILITARY_OPERATOR_IAF","MILITARY_OPERATOR_NATO","MILITARY_OPERATOR_OTHER"],"type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"ListMilitaryFlightsResponse":{"description":"ListMilitaryFlightsResponse contains military flights and clusters.","properties":{"clusters":{"items":{"$ref":"#/components/schemas/MilitaryFlightCluster"},"type":"array"},"flights":{"items":{"$ref":"#/components/schemas/MilitaryFlight"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"MilitaryBaseCluster":{"properties":{"count":{"format":"int32","type":"integer"},"dominantType":{"type":"string"},"expansionZoom":{"format":"int32","type":"integer"},"latitude":{"format":"double","type":"number"},"longitude":{"format":"double","type":"number"}},"type":"object"},"MilitaryBaseEntry":{"properties":{"branch":{"type":"string"},"catAirforce":{"type":"boolean"},"catNaval":{"type":"boolean"},"catNuclear":{"type":"boolean"},"catSpace":{"type":"boolean"},"catTraining":{"type":"boolean"},"countryIso2":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"},"latitude":{"format":"double","type":"number"},"longitude":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"tier":{"format":"int32","type":"integer"},"type":{"type":"string"}},"type":"object"},"MilitaryFlight":{"description":"MilitaryFlight represents a tracked military aircraft from OpenSky or Wingbits.","properties":{"aircraftModel":{"description":"Specific aircraft model (e.g., \"F-35A\", \"C-17A\").","type":"string"},"aircraftType":{"description":"MilitaryAircraftType represents the classification of a military aircraft.","enum":["MILITARY_AIRCRAFT_TYPE_UNSPECIFIED","MILITARY_AIRCRAFT_TYPE_FIGHTER","MILITARY_AIRCRAFT_TYPE_BOMBER","MILITARY_AIRCRAFT_TYPE_TRANSPORT","MILITARY_AIRCRAFT_TYPE_TANKER","MILITARY_AIRCRAFT_TYPE_AWACS","MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE","MILITARY_AIRCRAFT_TYPE_HELICOPTER","MILITARY_AIRCRAFT_TYPE_DRONE","MILITARY_AIRCRAFT_TYPE_PATROL","MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS","MILITARY_AIRCRAFT_TYPE_VIP","MILITARY_AIRCRAFT_TYPE_UNKNOWN"],"type":"string"},"altitude":{"description":"Altitude in feet.","format":"double","type":"number"},"callsign":{"description":"Aircraft callsign.","type":"string"},"confidence":{"description":"MilitaryConfidence represents confidence in asset identification.","enum":["MILITARY_CONFIDENCE_UNSPECIFIED","MILITARY_CONFIDENCE_LOW","MILITARY_CONFIDENCE_MEDIUM","MILITARY_CONFIDENCE_HIGH"],"type":"string"},"destination":{"description":"ICAO code of the destination airport.","type":"string"},"enrichment":{"$ref":"#/components/schemas/FlightEnrichment"},"firstSeenAt":{"description":"First seen time, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"heading":{"description":"Heading in degrees.","format":"double","type":"number"},"hexCode":{"description":"ICAO 24-bit hex address.","type":"string"},"id":{"description":"Unique flight identifier.","minLength":1,"type":"string"},"isInteresting":{"description":"Whether flagged for unusual activity.","type":"boolean"},"lastSeenAt":{"description":"Last seen time, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"note":{"description":"Analyst note.","type":"string"},"onGround":{"description":"Whether the aircraft is on the ground.","type":"boolean"},"operator":{"description":"MilitaryOperator represents the military branch or force operating an asset.","enum":["MILITARY_OPERATOR_UNSPECIFIED","MILITARY_OPERATOR_USAF","MILITARY_OPERATOR_USN","MILITARY_OPERATOR_USMC","MILITARY_OPERATOR_USA","MILITARY_OPERATOR_RAF","MILITARY_OPERATOR_RN","MILITARY_OPERATOR_FAF","MILITARY_OPERATOR_GAF","MILITARY_OPERATOR_PLAAF","MILITARY_OPERATOR_PLAN","MILITARY_OPERATOR_VKS","MILITARY_OPERATOR_IAF","MILITARY_OPERATOR_NATO","MILITARY_OPERATOR_OTHER"],"type":"string"},"operatorCountry":{"description":"Country operating the aircraft (ISO 3166-1 alpha-2).","type":"string"},"origin":{"description":"ICAO code of the origin airport.","type":"string"},"registration":{"description":"Aircraft registration number.","type":"string"},"speed":{"description":"Speed in knots.","format":"double","type":"number"},"squawk":{"description":"Transponder squawk code.","type":"string"},"verticalRate":{"description":"Vertical rate in feet per minute.","format":"double","type":"number"}},"required":["id"],"type":"object"},"MilitaryFlightCluster":{"description":"MilitaryFlightCluster represents a geographic cluster of military flights.","properties":{"activityType":{"description":"MilitaryActivityType represents the assessed type of military activity.","enum":["MILITARY_ACTIVITY_TYPE_UNSPECIFIED","MILITARY_ACTIVITY_TYPE_EXERCISE","MILITARY_ACTIVITY_TYPE_PATROL","MILITARY_ACTIVITY_TYPE_TRANSPORT","MILITARY_ACTIVITY_TYPE_DEPLOYMENT","MILITARY_ACTIVITY_TYPE_TRANSIT","MILITARY_ACTIVITY_TYPE_UNKNOWN"],"type":"string"},"dominantOperator":{"description":"MilitaryOperator represents the military branch or force operating an asset.","enum":["MILITARY_OPERATOR_UNSPECIFIED","MILITARY_OPERATOR_USAF","MILITARY_OPERATOR_USN","MILITARY_OPERATOR_USMC","MILITARY_OPERATOR_USA","MILITARY_OPERATOR_RAF","MILITARY_OPERATOR_RN","MILITARY_OPERATOR_FAF","MILITARY_OPERATOR_GAF","MILITARY_OPERATOR_PLAAF","MILITARY_OPERATOR_PLAN","MILITARY_OPERATOR_VKS","MILITARY_OPERATOR_IAF","MILITARY_OPERATOR_NATO","MILITARY_OPERATOR_OTHER"],"type":"string"},"flightCount":{"description":"Number of flights in the cluster.","format":"int32","type":"integer"},"flights":{"items":{"$ref":"#/components/schemas/MilitaryFlight"},"type":"array"},"id":{"description":"Unique cluster identifier.","type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name of the cluster.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"ResultsEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/AircraftDetails"}},"type":"object"},"TheaterPosture":{"description":"TheaterPosture represents an assessed military posture for a geographic theater.","properties":{"activeFlights":{"description":"Number of active flights in the theater.","format":"int32","type":"integer"},"activeOperations":{"items":{"description":"Notable ongoing operations.","type":"string"},"type":"array"},"assessedAt":{"description":"Assessment timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"postureLevel":{"description":"Overall posture assessment.","type":"string"},"theater":{"description":"Theater name (e.g., \"Indo-Pacific\", \"European\", \"Middle East\").","type":"string"},"trackedVessels":{"description":"Number of tracked vessels in the theater.","format":"int32","type":"integer"}},"type":"object"},"USNIFleetReport":{"description":"USNIFleetReport is the full parsed output of a USNI Fleet Tracker article.","properties":{"articleDate":{"description":"Publication date of the article.","type":"string"},"articleTitle":{"description":"Title of the article.","type":"string"},"articleUrl":{"description":"URL of the source article.","type":"string"},"battleForceSummary":{"$ref":"#/components/schemas/BattleForceSummary"},"parsingWarnings":{"items":{"description":"Warnings generated during parsing.","type":"string"},"type":"array"},"regions":{"items":{"description":"Unique region names mentioned in the article.","type":"string"},"type":"array"},"strikeGroups":{"items":{"$ref":"#/components/schemas/USNIStrikeGroup"},"type":"array"},"timestamp":{"description":"Time the report was generated, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"vessels":{"items":{"$ref":"#/components/schemas/USNIVessel"},"type":"array"}},"type":"object"},"USNIStrikeGroup":{"description":"USNIStrikeGroup represents a carrier strike group parsed from the article.","properties":{"airWing":{"description":"Assigned air wing (e.g., \"Carrier Air Wing Nine\").","type":"string"},"carrier":{"description":"Carrier name and hull (e.g., \"USS Abraham Lincoln (CVN-72)\").","type":"string"},"destroyerSquadron":{"description":"Assigned destroyer squadron.","type":"string"},"escorts":{"items":{"description":"Escort vessels in the strike group.","type":"string"},"type":"array"},"name":{"description":"Strike group name (e.g., \"Abraham Lincoln Carrier Strike Group\").","type":"string"}},"type":"object"},"USNIVessel":{"description":"USNIVessel represents a single vessel parsed from a USNI Fleet Tracker article.","properties":{"activityDescription":{"description":"Brief activity description parsed from article prose.","type":"string"},"articleDate":{"description":"Publication date of the USNI article.","type":"string"},"articleUrl":{"description":"URL of the USNI article this vessel was parsed from.","type":"string"},"deploymentStatus":{"description":"Deployment status (e.g., \"deployed\", \"underway\", \"in-port\", \"unknown\").","type":"string"},"homePort":{"description":"Home port, if identified from the article text.","type":"string"},"hullNumber":{"description":"Hull designation (e.g., \"CVN-72\", \"DDG-51\").","minLength":1,"type":"string"},"name":{"description":"Vessel name (e.g., \"USS Abraham Lincoln\").","minLength":1,"type":"string"},"region":{"description":"Region name where the vessel is operating.","type":"string"},"regionLat":{"description":"Approximate latitude for the region.","format":"double","type":"number"},"regionLon":{"description":"Approximate longitude for the region.","format":"double","type":"number"},"strikeGroup":{"description":"Strike group assignment, if any.","type":"string"},"vesselType":{"description":"Vessel type classification (e.g., \"carrier\", \"destroyer\", \"submarine\").","type":"string"}},"required":["name","hullNumber"],"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"},"WingbitsLiveFlight":{"description":"WingbitsLiveFlight contains real-time flight position data from the Wingbits ECS network.","properties":{"airlineName":{"description":"Airline name (e.g. \"Emirates\"). Empty if no mapping.","type":"string"},"altitude":{"description":"Altitude in feet.","format":"double","type":"number"},"arrDelayedMin":{"description":"Arrival delay in minutes (negative = early).","format":"int32","type":"integer"},"arrEstimatedUtc":{"description":"Estimated arrival time UTC (ISO 8601).","type":"string"},"arrIata":{"description":"Arrival airport IATA code (e.g. \"BOM\").","type":"string"},"arrTerminal":{"description":"Arrival terminal (e.g. \"2\").","type":"string"},"arrTimeUtc":{"description":"Scheduled arrival time UTC (ISO 8601).","type":"string"},"callsign":{"description":"Live callsign.","type":"string"},"callsignIata":{"description":"Airline — resolved server-side from the ICAO callsign prefix.\n IATA-equivalent callsign (e.g. \"EK528\" for ICAO \"UAE528\"). Empty if no mapping.","type":"string"},"depDelayedMin":{"description":"Departure delay in minutes (negative = early).","format":"int32","type":"integer"},"depEstimatedUtc":{"description":"Estimated departure time UTC (ISO 8601).","type":"string"},"depIata":{"description":"Schedule — from ecs-api.wingbits.com/v1/flights/schedule/{callsign}\n Departure airport IATA code (e.g. \"FJR\").","type":"string"},"depTimeUtc":{"description":"Scheduled departure time UTC (ISO 8601).","type":"string"},"flightDurationMin":{"description":"Scheduled flight duration in minutes.","format":"int32","type":"integer"},"flightStatus":{"description":"Flight status string (e.g. \"en-route\", \"landed\", \"scheduled\").","type":"string"},"heading":{"description":"Track/heading in degrees.","format":"double","type":"number"},"icao24":{"description":"ICAO 24-bit hex address.","type":"string"},"lastSeen":{"description":"Unix timestamp of the last position update.","format":"int64","type":"string"},"lat":{"description":"Latitude in decimal degrees.","format":"double","type":"number"},"lon":{"description":"Longitude in decimal degrees.","format":"double","type":"number"},"model":{"description":"Aircraft model (e.g. \"PC-12/45\").","type":"string"},"onGround":{"description":"True if the aircraft is on the ground.","type":"boolean"},"operator":{"description":"Operator name.","type":"string"},"photoCredit":{"description":"Photographer credit name.","type":"string"},"photoLink":{"description":"Permalink to the photo page on planespotters.net.","type":"string"},"photoUrl":{"description":"Photo — from api.planespotters.net/pub/photos/hex/{icao24}\n Aircraft photo URL (large thumbnail ~497x280).","type":"string"},"registration":{"description":"Aircraft registration number.","type":"string"},"speed":{"description":"Ground speed in knots.","format":"double","type":"number"},"verticalRate":{"description":"Vertical rate in feet per minute (positive = climb, negative = descent).","format":"double","type":"number"}},"type":"object"}}},"info":{"title":"MilitaryService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/military/v1/get-aircraft-details":{"get":{"description":"GetAircraftDetails retrieves Wingbits aircraft enrichment data for a single ICAO24 hex.","operationId":"GetAircraftDetails","parameters":[{"description":"ICAO 24-bit hex address (lowercase).","in":"query","name":"icao24","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAircraftDetailsResponse"}}},"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":"GetAircraftDetails","tags":["MilitaryService"]}},"/api/military/v1/get-aircraft-details-batch":{"post":{"description":"GetAircraftDetailsBatch retrieves Wingbits aircraft enrichment data for multiple ICAO24 hexes.","operationId":"GetAircraftDetailsBatch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAircraftDetailsBatchRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAircraftDetailsBatchResponse"}}},"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":"GetAircraftDetailsBatch","tags":["MilitaryService"]}},"/api/military/v1/get-theater-posture":{"get":{"description":"GetTheaterPosture retrieves military posture assessments for geographic theaters.","operationId":"GetTheaterPosture","parameters":[{"description":"Theater name (e.g., \"indo-pacific\", \"european\", \"middle-east\"). Empty for all theaters.","in":"query","name":"theater","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetTheaterPostureResponse"}}},"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":"GetTheaterPosture","tags":["MilitaryService"]}},"/api/military/v1/get-usni-fleet-report":{"get":{"description":"GetUSNIFleetReport retrieves the latest parsed USNI Fleet Tracker report.","operationId":"GetUSNIFleetReport","parameters":[{"description":"When true, bypass cache and fetch fresh data from USNI.","in":"query","name":"force_refresh","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetUSNIFleetReportResponse"}}},"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":"GetUSNIFleetReport","tags":["MilitaryService"]}},"/api/military/v1/get-wingbits-live-flight":{"get":{"description":"GetWingbitsLiveFlight retrieves real-time position data from the Wingbits ECS network for a single aircraft.","operationId":"GetWingbitsLiveFlight","parameters":[{"description":"ICAO 24-bit hex address (lowercase, 6 characters).","in":"query","name":"icao24","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetWingbitsLiveFlightResponse"}}},"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":"GetWingbitsLiveFlight","tags":["MilitaryService"]}},"/api/military/v1/get-wingbits-status":{"get":{"description":"GetWingbitsStatus checks whether the Wingbits enrichment API is configured.","operationId":"GetWingbitsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetWingbitsStatusResponse"}}},"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":"GetWingbitsStatus","tags":["MilitaryService"]}},"/api/military/v1/list-defense-patents":{"get":{"description":"ListDefensePatents retrieves recent defense/dual-use patent filings from USPTO PatentsView.","operationId":"ListDefensePatents","parameters":[{"description":"CPC category filter (e.g. \"H04B\", \"F42B\"). Empty returns all categories.","in":"query","name":"cpc_code","required":false,"schema":{"type":"string"}},{"description":"Assignee keyword filter (case-insensitive substring match). Empty returns all.","in":"query","name":"assignee","required":false,"schema":{"type":"string"}},{"description":"Maximum results to return (default 20, max 100).","in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDefensePatentsResponse"}}},"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":"ListDefensePatents","tags":["MilitaryService"]}},"/api/military/v1/list-military-bases":{"get":{"description":"ListMilitaryBases retrieves military bases within a bounding box, with server-side clustering.","operationId":"ListMilitaryBases","parameters":[{"in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"zoom","required":false,"schema":{"format":"int32","type":"integer"}},{"in":"query","name":"type","required":false,"schema":{"type":"string"}},{"in":"query","name":"kind","required":false,"schema":{"type":"string"}},{"in":"query","name":"country","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMilitaryBasesResponse"}}},"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":"ListMilitaryBases","tags":["MilitaryService"]}},"/api/military/v1/list-military-flights":{"get":{"description":"ListMilitaryFlights retrieves tracked military aircraft from OpenSky and Wingbits.","operationId":"ListMilitaryFlights","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"Optional operator filter.","in":"query","name":"operator","required":false,"schema":{"type":"string"}},{"description":"Optional aircraft type filter.","in":"query","name":"aircraft_type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMilitaryFlightsResponse"}}},"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":"ListMilitaryFlights","tags":["MilitaryService"]}}}} \ No newline at end of file +{"components":{"schemas":{"AircraftDetails":{"description":"AircraftDetails contains Wingbits aircraft enrichment data.","properties":{"built":{"description":"Build date.","type":"string"},"categoryDescription":{"description":"ICAO category description.","type":"string"},"engines":{"description":"Engine description.","type":"string"},"icao24":{"description":"ICAO 24-bit hex address.","type":"string"},"icaoAircraftType":{"description":"ICAO aircraft type designator.","type":"string"},"manufacturerIcao":{"description":"ICAO manufacturer code.","type":"string"},"manufacturerName":{"description":"Full manufacturer name.","type":"string"},"model":{"description":"Aircraft model.","type":"string"},"operator":{"description":"Operator name.","type":"string"},"operatorCallsign":{"description":"Operator callsign.","type":"string"},"operatorIcao":{"description":"Operator ICAO code.","type":"string"},"owner":{"description":"Registered owner.","type":"string"},"registration":{"description":"Aircraft registration number.","type":"string"},"serialNumber":{"description":"Manufacturer serial number.","type":"string"},"typecode":{"description":"ICAO type designator code.","type":"string"}},"type":"object"},"BattleForceSummary":{"description":"BattleForceSummary contains fleet-wide ship count statistics.","properties":{"deployed":{"description":"Number of ships currently deployed.","format":"int32","minimum":0,"type":"integer"},"totalShips":{"description":"Total ships in the battle force.","format":"int32","minimum":0,"type":"integer"},"underway":{"description":"Number of ships currently underway.","format":"int32","minimum":0,"type":"integer"}},"type":"object"},"DefensePatentFiling":{"description":"DefensePatentFiling is a single patent filing from a defense/dual-use organization.","properties":{"abstract":{"description":"Patent abstract (truncated to 500 chars).","type":"string"},"assignee":{"description":"Primary assignee organization name.","type":"string"},"cpcCode":{"description":"CPC classification code (e.g. \"H04B\", \"F42B\").","type":"string"},"cpcDesc":{"description":"CPC class description.","type":"string"},"date":{"description":"Filing date (YYYY-MM-DD).","type":"string"},"patentId":{"description":"USPTO patent ID.","type":"string"},"title":{"description":"Patent title.","type":"string"},"url":{"description":"USPTO patent URL.","type":"string"}},"type":"object"},"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"},"FlightEnrichment":{"description":"FlightEnrichment contains additional data from Wingbits aircraft database.","properties":{"builtYear":{"description":"Year the aircraft was built.","type":"string"},"confirmedMilitary":{"description":"Whether confirmed as military.","type":"boolean"},"manufacturer":{"description":"Aircraft manufacturer.","type":"string"},"militaryBranch":{"description":"Military branch designation.","type":"string"},"operatorName":{"description":"Operator name.","type":"string"},"owner":{"description":"Registered owner.","type":"string"},"typeCode":{"description":"ICAO type code.","type":"string"}},"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetAircraftDetailsBatchRequest":{"description":"GetAircraftDetailsBatchRequest looks up multiple aircraft by ICAO 24-bit hex.","properties":{"icao24s":{"items":{"description":"ICAO 24-bit hex addresses (lowercase). Max 20.","maxItems":20,"minItems":1,"type":"string"},"maxItems":20,"minItems":1,"type":"array"}},"type":"object"},"GetAircraftDetailsBatchResponse":{"description":"GetAircraftDetailsBatchResponse contains the batch lookup results.","properties":{"configured":{"description":"Whether the Wingbits API is configured.","type":"boolean"},"fetched":{"description":"Number of aircraft successfully fetched from upstream.","format":"int32","type":"integer"},"requested":{"description":"Number of aircraft requested.","format":"int32","type":"integer"},"results":{"additionalProperties":{"$ref":"#/components/schemas/AircraftDetails"},"description":"Map of icao24 -\u003e aircraft details for found aircraft.","type":"object"}},"type":"object"},"GetAircraftDetailsRequest":{"description":"GetAircraftDetailsRequest looks up a single aircraft by ICAO 24-bit hex.","properties":{"icao24":{"description":"ICAO 24-bit hex address (lowercase).","minLength":1,"type":"string"}},"required":["icao24"],"type":"object"},"GetAircraftDetailsResponse":{"description":"GetAircraftDetailsResponse contains the aircraft enrichment data.","properties":{"configured":{"description":"Whether the Wingbits API is configured.","type":"boolean"},"details":{"$ref":"#/components/schemas/AircraftDetails"}},"type":"object"},"GetTheaterPostureRequest":{"description":"GetTheaterPostureRequest specifies the theater to assess.","properties":{"theater":{"description":"Theater name (e.g., \"indo-pacific\", \"european\", \"middle-east\"). Empty for all theaters.","type":"string"}},"type":"object"},"GetTheaterPostureResponse":{"description":"GetTheaterPostureResponse contains theater posture assessments.","properties":{"theaters":{"items":{"$ref":"#/components/schemas/TheaterPosture"},"type":"array"}},"type":"object"},"GetUSNIFleetReportRequest":{"description":"GetUSNIFleetReportRequest requests the latest USNI Fleet Tracker report.","properties":{"forceRefresh":{"description":"When true, bypass cache and fetch fresh data from USNI.","type":"boolean"}},"type":"object"},"GetUSNIFleetReportResponse":{"description":"GetUSNIFleetReportResponse returns the parsed USNI Fleet Tracker report.","properties":{"cached":{"description":"Whether the response was served from cache.","type":"boolean"},"error":{"description":"Error message, if any.","type":"string"},"report":{"$ref":"#/components/schemas/USNIFleetReport"},"stale":{"description":"Whether the cached data is stale (served after a fetch failure).","type":"boolean"}},"type":"object"},"GetWingbitsLiveFlightRequest":{"description":"GetWingbitsLiveFlightRequest fetches live Wingbits ECS data for a single aircraft.","properties":{"icao24":{"description":"ICAO 24-bit hex address (lowercase, 6 characters).","minLength":1,"type":"string"}},"required":["icao24"],"type":"object"},"GetWingbitsLiveFlightResponse":{"description":"GetWingbitsLiveFlightResponse contains the live flight data, if available.","properties":{"flight":{"$ref":"#/components/schemas/WingbitsLiveFlight"}},"type":"object"},"GetWingbitsStatusRequest":{"description":"GetWingbitsStatusRequest checks whether the Wingbits enrichment API is configured.","type":"object"},"GetWingbitsStatusResponse":{"description":"GetWingbitsStatusResponse indicates whether Wingbits is available.","properties":{"configured":{"description":"Whether the Wingbits API key is configured on the server.","type":"boolean"}},"type":"object"},"ListDefensePatentsRequest":{"description":"ListDefensePatentsRequest filters defense/dual-use patent filings.","properties":{"assignee":{"description":"Assignee keyword filter (case-insensitive substring match). Empty returns all.","type":"string"},"cpcCode":{"description":"CPC category filter (e.g. \"H04B\", \"F42B\"). Empty returns all categories.","type":"string"},"limit":{"description":"Maximum results to return (default 20, max 100).","format":"int32","type":"integer"}},"type":"object"},"ListDefensePatentsResponse":{"description":"ListDefensePatentsResponse contains recent defense/dual-use patent filings.","properties":{"fetchedAt":{"description":"ISO 8601 timestamp when data was seeded.","type":"string"},"patents":{"items":{"$ref":"#/components/schemas/DefensePatentFiling"},"type":"array"},"total":{"description":"Total number of filings in the seeded dataset.","format":"int32","type":"integer"}},"type":"object"},"ListMilitaryBasesRequest":{"properties":{"country":{"type":"string"},"kind":{"type":"string"},"neLat":{"format":"double","type":"number"},"neLon":{"format":"double","type":"number"},"swLat":{"format":"double","type":"number"},"swLon":{"format":"double","type":"number"},"type":{"type":"string"},"zoom":{"format":"int32","type":"integer"}},"type":"object"},"ListMilitaryBasesResponse":{"properties":{"bases":{"items":{"$ref":"#/components/schemas/MilitaryBaseEntry"},"type":"array"},"clusters":{"items":{"$ref":"#/components/schemas/MilitaryBaseCluster"},"type":"array"},"totalInView":{"format":"int32","type":"integer"},"truncated":{"type":"boolean"}},"type":"object"},"ListMilitaryFlightsRequest":{"description":"ListMilitaryFlightsRequest specifies filters for retrieving military flight data.","properties":{"aircraftType":{"description":"MilitaryAircraftType represents the classification of a military aircraft.","enum":["MILITARY_AIRCRAFT_TYPE_UNSPECIFIED","MILITARY_AIRCRAFT_TYPE_FIGHTER","MILITARY_AIRCRAFT_TYPE_BOMBER","MILITARY_AIRCRAFT_TYPE_TRANSPORT","MILITARY_AIRCRAFT_TYPE_TANKER","MILITARY_AIRCRAFT_TYPE_AWACS","MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE","MILITARY_AIRCRAFT_TYPE_HELICOPTER","MILITARY_AIRCRAFT_TYPE_DRONE","MILITARY_AIRCRAFT_TYPE_PATROL","MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS","MILITARY_AIRCRAFT_TYPE_VIP","MILITARY_AIRCRAFT_TYPE_UNKNOWN"],"type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"operator":{"description":"MilitaryOperator represents the military branch or force operating an asset.","enum":["MILITARY_OPERATOR_UNSPECIFIED","MILITARY_OPERATOR_USAF","MILITARY_OPERATOR_USN","MILITARY_OPERATOR_USMC","MILITARY_OPERATOR_USA","MILITARY_OPERATOR_RAF","MILITARY_OPERATOR_RN","MILITARY_OPERATOR_FAF","MILITARY_OPERATOR_GAF","MILITARY_OPERATOR_PLAAF","MILITARY_OPERATOR_PLAN","MILITARY_OPERATOR_VKS","MILITARY_OPERATOR_IAF","MILITARY_OPERATOR_NATO","MILITARY_OPERATOR_OTHER"],"type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"ListMilitaryFlightsResponse":{"description":"ListMilitaryFlightsResponse contains military flights and clusters.","properties":{"clusters":{"items":{"$ref":"#/components/schemas/MilitaryFlightCluster"},"type":"array"},"flights":{"items":{"$ref":"#/components/schemas/MilitaryFlight"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"MilitaryBaseCluster":{"properties":{"count":{"format":"int32","type":"integer"},"dominantType":{"type":"string"},"expansionZoom":{"format":"int32","type":"integer"},"latitude":{"format":"double","type":"number"},"longitude":{"format":"double","type":"number"}},"type":"object"},"MilitaryBaseEntry":{"properties":{"branch":{"type":"string"},"catAirforce":{"type":"boolean"},"catNaval":{"type":"boolean"},"catNuclear":{"type":"boolean"},"catSpace":{"type":"boolean"},"catTraining":{"type":"boolean"},"countryIso2":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"},"latitude":{"format":"double","type":"number"},"longitude":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"tier":{"format":"int32","type":"integer"},"type":{"type":"string"}},"type":"object"},"MilitaryFlight":{"description":"MilitaryFlight represents a tracked military aircraft from OpenSky or Wingbits.","properties":{"aircraftModel":{"description":"Specific aircraft model (e.g., \"F-35A\", \"C-17A\").","type":"string"},"aircraftType":{"description":"MilitaryAircraftType represents the classification of a military aircraft.","enum":["MILITARY_AIRCRAFT_TYPE_UNSPECIFIED","MILITARY_AIRCRAFT_TYPE_FIGHTER","MILITARY_AIRCRAFT_TYPE_BOMBER","MILITARY_AIRCRAFT_TYPE_TRANSPORT","MILITARY_AIRCRAFT_TYPE_TANKER","MILITARY_AIRCRAFT_TYPE_AWACS","MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE","MILITARY_AIRCRAFT_TYPE_HELICOPTER","MILITARY_AIRCRAFT_TYPE_DRONE","MILITARY_AIRCRAFT_TYPE_PATROL","MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS","MILITARY_AIRCRAFT_TYPE_VIP","MILITARY_AIRCRAFT_TYPE_UNKNOWN"],"type":"string"},"altitude":{"description":"Altitude in feet.","format":"double","type":"number"},"callsign":{"description":"Aircraft callsign.","type":"string"},"confidence":{"description":"MilitaryConfidence represents confidence in asset identification.","enum":["MILITARY_CONFIDENCE_UNSPECIFIED","MILITARY_CONFIDENCE_LOW","MILITARY_CONFIDENCE_MEDIUM","MILITARY_CONFIDENCE_HIGH"],"type":"string"},"destination":{"description":"ICAO code of the destination airport.","type":"string"},"enrichment":{"$ref":"#/components/schemas/FlightEnrichment"},"firstSeenAt":{"description":"First seen time, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"heading":{"description":"Heading in degrees.","format":"double","type":"number"},"hexCode":{"description":"ICAO 24-bit hex address. Canonical form is UPPERCASE — seeders and\n handlers must uppercase before writing so hex-based lookups\n (src/services/military-flights.ts:getFlightByHex) match regardless of\n upstream source casing.","type":"string"},"id":{"description":"Unique flight identifier.","minLength":1,"type":"string"},"isInteresting":{"description":"Whether flagged for unusual activity.","type":"boolean"},"lastSeenAt":{"description":"Last seen time, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"note":{"description":"Analyst note.","type":"string"},"onGround":{"description":"Whether the aircraft is on the ground.","type":"boolean"},"operator":{"description":"MilitaryOperator represents the military branch or force operating an asset.","enum":["MILITARY_OPERATOR_UNSPECIFIED","MILITARY_OPERATOR_USAF","MILITARY_OPERATOR_USN","MILITARY_OPERATOR_USMC","MILITARY_OPERATOR_USA","MILITARY_OPERATOR_RAF","MILITARY_OPERATOR_RN","MILITARY_OPERATOR_FAF","MILITARY_OPERATOR_GAF","MILITARY_OPERATOR_PLAAF","MILITARY_OPERATOR_PLAN","MILITARY_OPERATOR_VKS","MILITARY_OPERATOR_IAF","MILITARY_OPERATOR_NATO","MILITARY_OPERATOR_OTHER"],"type":"string"},"operatorCountry":{"description":"Country operating the aircraft (ISO 3166-1 alpha-2).","type":"string"},"origin":{"description":"ICAO code of the origin airport.","type":"string"},"registration":{"description":"Aircraft registration number.","type":"string"},"speed":{"description":"Speed in knots.","format":"double","type":"number"},"squawk":{"description":"Transponder squawk code.","type":"string"},"verticalRate":{"description":"Vertical rate in feet per minute.","format":"double","type":"number"}},"required":["id"],"type":"object"},"MilitaryFlightCluster":{"description":"MilitaryFlightCluster represents a geographic cluster of military flights.","properties":{"activityType":{"description":"MilitaryActivityType represents the assessed type of military activity.","enum":["MILITARY_ACTIVITY_TYPE_UNSPECIFIED","MILITARY_ACTIVITY_TYPE_EXERCISE","MILITARY_ACTIVITY_TYPE_PATROL","MILITARY_ACTIVITY_TYPE_TRANSPORT","MILITARY_ACTIVITY_TYPE_DEPLOYMENT","MILITARY_ACTIVITY_TYPE_TRANSIT","MILITARY_ACTIVITY_TYPE_UNKNOWN"],"type":"string"},"dominantOperator":{"description":"MilitaryOperator represents the military branch or force operating an asset.","enum":["MILITARY_OPERATOR_UNSPECIFIED","MILITARY_OPERATOR_USAF","MILITARY_OPERATOR_USN","MILITARY_OPERATOR_USMC","MILITARY_OPERATOR_USA","MILITARY_OPERATOR_RAF","MILITARY_OPERATOR_RN","MILITARY_OPERATOR_FAF","MILITARY_OPERATOR_GAF","MILITARY_OPERATOR_PLAAF","MILITARY_OPERATOR_PLAN","MILITARY_OPERATOR_VKS","MILITARY_OPERATOR_IAF","MILITARY_OPERATOR_NATO","MILITARY_OPERATOR_OTHER"],"type":"string"},"flightCount":{"description":"Number of flights in the cluster.","format":"int32","type":"integer"},"flights":{"items":{"$ref":"#/components/schemas/MilitaryFlight"},"type":"array"},"id":{"description":"Unique cluster identifier.","type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name of the cluster.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"ResultsEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/AircraftDetails"}},"type":"object"},"TheaterPosture":{"description":"TheaterPosture represents an assessed military posture for a geographic theater.","properties":{"activeFlights":{"description":"Number of active flights in the theater.","format":"int32","type":"integer"},"activeOperations":{"items":{"description":"Notable ongoing operations.","type":"string"},"type":"array"},"assessedAt":{"description":"Assessment timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"postureLevel":{"description":"Overall posture assessment.","type":"string"},"theater":{"description":"Theater name (e.g., \"Indo-Pacific\", \"European\", \"Middle East\").","type":"string"},"trackedVessels":{"description":"Number of tracked vessels in the theater.","format":"int32","type":"integer"}},"type":"object"},"USNIFleetReport":{"description":"USNIFleetReport is the full parsed output of a USNI Fleet Tracker article.","properties":{"articleDate":{"description":"Publication date of the article.","type":"string"},"articleTitle":{"description":"Title of the article.","type":"string"},"articleUrl":{"description":"URL of the source article.","type":"string"},"battleForceSummary":{"$ref":"#/components/schemas/BattleForceSummary"},"parsingWarnings":{"items":{"description":"Warnings generated during parsing.","type":"string"},"type":"array"},"regions":{"items":{"description":"Unique region names mentioned in the article.","type":"string"},"type":"array"},"strikeGroups":{"items":{"$ref":"#/components/schemas/USNIStrikeGroup"},"type":"array"},"timestamp":{"description":"Time the report was generated, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"vessels":{"items":{"$ref":"#/components/schemas/USNIVessel"},"type":"array"}},"type":"object"},"USNIStrikeGroup":{"description":"USNIStrikeGroup represents a carrier strike group parsed from the article.","properties":{"airWing":{"description":"Assigned air wing (e.g., \"Carrier Air Wing Nine\").","type":"string"},"carrier":{"description":"Carrier name and hull (e.g., \"USS Abraham Lincoln (CVN-72)\").","type":"string"},"destroyerSquadron":{"description":"Assigned destroyer squadron.","type":"string"},"escorts":{"items":{"description":"Escort vessels in the strike group.","type":"string"},"type":"array"},"name":{"description":"Strike group name (e.g., \"Abraham Lincoln Carrier Strike Group\").","type":"string"}},"type":"object"},"USNIVessel":{"description":"USNIVessel represents a single vessel parsed from a USNI Fleet Tracker article.","properties":{"activityDescription":{"description":"Brief activity description parsed from article prose.","type":"string"},"articleDate":{"description":"Publication date of the USNI article.","type":"string"},"articleUrl":{"description":"URL of the USNI article this vessel was parsed from.","type":"string"},"deploymentStatus":{"description":"Deployment status (e.g., \"deployed\", \"underway\", \"in-port\", \"unknown\").","type":"string"},"homePort":{"description":"Home port, if identified from the article text.","type":"string"},"hullNumber":{"description":"Hull designation (e.g., \"CVN-72\", \"DDG-51\").","minLength":1,"type":"string"},"name":{"description":"Vessel name (e.g., \"USS Abraham Lincoln\").","minLength":1,"type":"string"},"region":{"description":"Region name where the vessel is operating.","type":"string"},"regionLat":{"description":"Approximate latitude for the region.","format":"double","type":"number"},"regionLon":{"description":"Approximate longitude for the region.","format":"double","type":"number"},"strikeGroup":{"description":"Strike group assignment, if any.","type":"string"},"vesselType":{"description":"Vessel type classification (e.g., \"carrier\", \"destroyer\", \"submarine\").","type":"string"}},"required":["name","hullNumber"],"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"},"WingbitsLiveFlight":{"description":"WingbitsLiveFlight contains real-time flight position data from the Wingbits ECS network.","properties":{"airlineName":{"description":"Airline name (e.g. \"Emirates\"). Empty if no mapping.","type":"string"},"altitude":{"description":"Altitude in feet.","format":"double","type":"number"},"arrDelayedMin":{"description":"Arrival delay in minutes (negative = early).","format":"int32","type":"integer"},"arrEstimatedUtc":{"description":"Estimated arrival time UTC (ISO 8601).","type":"string"},"arrIata":{"description":"Arrival airport IATA code (e.g. \"BOM\").","type":"string"},"arrTerminal":{"description":"Arrival terminal (e.g. \"2\").","type":"string"},"arrTimeUtc":{"description":"Scheduled arrival time UTC (ISO 8601).","type":"string"},"callsign":{"description":"Live callsign.","type":"string"},"callsignIata":{"description":"Airline — resolved server-side from the ICAO callsign prefix.\n IATA-equivalent callsign (e.g. \"EK528\" for ICAO \"UAE528\"). Empty if no mapping.","type":"string"},"depDelayedMin":{"description":"Departure delay in minutes (negative = early).","format":"int32","type":"integer"},"depEstimatedUtc":{"description":"Estimated departure time UTC (ISO 8601).","type":"string"},"depIata":{"description":"Schedule — from ecs-api.wingbits.com/v1/flights/schedule/{callsign}\n Departure airport IATA code (e.g. \"FJR\").","type":"string"},"depTimeUtc":{"description":"Scheduled departure time UTC (ISO 8601).","type":"string"},"flightDurationMin":{"description":"Scheduled flight duration in minutes.","format":"int32","type":"integer"},"flightStatus":{"description":"Flight status string (e.g. \"en-route\", \"landed\", \"scheduled\").","type":"string"},"heading":{"description":"Track/heading in degrees.","format":"double","type":"number"},"icao24":{"description":"ICAO 24-bit hex address.","type":"string"},"lastSeen":{"description":"Unix timestamp of the last position update.","format":"int64","type":"string"},"lat":{"description":"Latitude in decimal degrees.","format":"double","type":"number"},"lon":{"description":"Longitude in decimal degrees.","format":"double","type":"number"},"model":{"description":"Aircraft model (e.g. \"PC-12/45\").","type":"string"},"onGround":{"description":"True if the aircraft is on the ground.","type":"boolean"},"operator":{"description":"Operator name.","type":"string"},"photoCredit":{"description":"Photographer credit name.","type":"string"},"photoLink":{"description":"Permalink to the photo page on planespotters.net.","type":"string"},"photoUrl":{"description":"Photo — from api.planespotters.net/pub/photos/hex/{icao24}\n Aircraft photo URL (large thumbnail ~497x280).","type":"string"},"registration":{"description":"Aircraft registration number.","type":"string"},"speed":{"description":"Ground speed in knots.","format":"double","type":"number"},"verticalRate":{"description":"Vertical rate in feet per minute (positive = climb, negative = descent).","format":"double","type":"number"}},"type":"object"}}},"info":{"title":"MilitaryService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/military/v1/get-aircraft-details":{"get":{"description":"GetAircraftDetails retrieves Wingbits aircraft enrichment data for a single ICAO24 hex.","operationId":"GetAircraftDetails","parameters":[{"description":"ICAO 24-bit hex address (lowercase).","in":"query","name":"icao24","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAircraftDetailsResponse"}}},"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":"GetAircraftDetails","tags":["MilitaryService"]}},"/api/military/v1/get-aircraft-details-batch":{"post":{"description":"GetAircraftDetailsBatch retrieves Wingbits aircraft enrichment data for multiple ICAO24 hexes.","operationId":"GetAircraftDetailsBatch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAircraftDetailsBatchRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAircraftDetailsBatchResponse"}}},"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":"GetAircraftDetailsBatch","tags":["MilitaryService"]}},"/api/military/v1/get-theater-posture":{"get":{"description":"GetTheaterPosture retrieves military posture assessments for geographic theaters.","operationId":"GetTheaterPosture","parameters":[{"description":"Theater name (e.g., \"indo-pacific\", \"european\", \"middle-east\"). Empty for all theaters.","in":"query","name":"theater","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetTheaterPostureResponse"}}},"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":"GetTheaterPosture","tags":["MilitaryService"]}},"/api/military/v1/get-usni-fleet-report":{"get":{"description":"GetUSNIFleetReport retrieves the latest parsed USNI Fleet Tracker report.","operationId":"GetUSNIFleetReport","parameters":[{"description":"When true, bypass cache and fetch fresh data from USNI.","in":"query","name":"force_refresh","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetUSNIFleetReportResponse"}}},"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":"GetUSNIFleetReport","tags":["MilitaryService"]}},"/api/military/v1/get-wingbits-live-flight":{"get":{"description":"GetWingbitsLiveFlight retrieves real-time position data from the Wingbits ECS network for a single aircraft.","operationId":"GetWingbitsLiveFlight","parameters":[{"description":"ICAO 24-bit hex address (lowercase, 6 characters).","in":"query","name":"icao24","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetWingbitsLiveFlightResponse"}}},"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":"GetWingbitsLiveFlight","tags":["MilitaryService"]}},"/api/military/v1/get-wingbits-status":{"get":{"description":"GetWingbitsStatus checks whether the Wingbits enrichment API is configured.","operationId":"GetWingbitsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetWingbitsStatusResponse"}}},"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":"GetWingbitsStatus","tags":["MilitaryService"]}},"/api/military/v1/list-defense-patents":{"get":{"description":"ListDefensePatents retrieves recent defense/dual-use patent filings from USPTO PatentsView.","operationId":"ListDefensePatents","parameters":[{"description":"CPC category filter (e.g. \"H04B\", \"F42B\"). Empty returns all categories.","in":"query","name":"cpc_code","required":false,"schema":{"type":"string"}},{"description":"Assignee keyword filter (case-insensitive substring match). Empty returns all.","in":"query","name":"assignee","required":false,"schema":{"type":"string"}},{"description":"Maximum results to return (default 20, max 100).","in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDefensePatentsResponse"}}},"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":"ListDefensePatents","tags":["MilitaryService"]}},"/api/military/v1/list-military-bases":{"get":{"description":"ListMilitaryBases retrieves military bases within a bounding box, with server-side clustering.","operationId":"ListMilitaryBases","parameters":[{"in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"zoom","required":false,"schema":{"format":"int32","type":"integer"}},{"in":"query","name":"type","required":false,"schema":{"type":"string"}},{"in":"query","name":"kind","required":false,"schema":{"type":"string"}},{"in":"query","name":"country","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMilitaryBasesResponse"}}},"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":"ListMilitaryBases","tags":["MilitaryService"]}},"/api/military/v1/list-military-flights":{"get":{"description":"ListMilitaryFlights retrieves tracked military aircraft from OpenSky and Wingbits.","operationId":"ListMilitaryFlights","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"Optional operator filter.","in":"query","name":"operator","required":false,"schema":{"type":"string"}},{"description":"Optional aircraft type filter.","in":"query","name":"aircraft_type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMilitaryFlightsResponse"}}},"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":"ListMilitaryFlights","tags":["MilitaryService"]}}}} \ No newline at end of file diff --git a/docs/api/MilitaryService.openapi.yaml b/docs/api/MilitaryService.openapi.yaml index e412daafb..50c2084d7 100644 --- a/docs/api/MilitaryService.openapi.yaml +++ b/docs/api/MilitaryService.openapi.yaml @@ -513,7 +513,11 @@ components: description: Aircraft callsign. hexCode: type: string - description: ICAO 24-bit hex address. + description: |- + ICAO 24-bit hex address. Canonical form is UPPERCASE — seeders and + handlers must uppercase before writing so hex-based lookups + (src/services/military-flights.ts:getFlightByHex) match regardless of + upstream source casing. registration: type: string description: Aircraft registration number. diff --git a/docs/api/ScenarioService.openapi.json b/docs/api/ScenarioService.openapi.json new file mode 100644 index 000000000..3f89433ca --- /dev/null +++ b/docs/api/ScenarioService.openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetScenarioStatusRequest":{"description":"GetScenarioStatusRequest polls the worker result for an enqueued job id.","properties":{"jobId":{"description":"Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal\n guarded by JOB_ID_RE in the handler.","pattern":"^scenario:[0-9]{13}:[a-z0-9]{8}$","type":"string"}},"required":["jobId"],"type":"object"},"GetScenarioStatusResponse":{"description":"GetScenarioStatusResponse reflects the worker's lifecycle state.\n \"pending\" — no key yet (job still queued or very-recent enqueue).\n \"processing\" — worker has claimed the job but hasn't completed compute.\n \"done\" — compute succeeded; `result` is populated.\n \"failed\" — compute errored; `error` is populated.","properties":{"error":{"description":"Populated only when status == \"failed\".","type":"string"},"result":{"$ref":"#/components/schemas/ScenarioResult"},"status":{"type":"string"}},"type":"object"},"ListScenarioTemplatesRequest":{"type":"object"},"ListScenarioTemplatesResponse":{"properties":{"templates":{"items":{"$ref":"#/components/schemas/ScenarioTemplate"},"type":"array"}},"type":"object"},"RunScenarioRequest":{"description":"RunScenarioRequest enqueues a scenario job on the scenario-queue:pending\n Upstash list for the async scenario-worker to pick up.","properties":{"iso2":{"description":"Optional 2-letter ISO country code to scope the impact computation.\n When absent, the worker computes for all countries with seeded exposure.","pattern":"^([A-Z]{2})?$","type":"string"},"scenarioId":{"description":"Scenario template id — must match an entry in SCENARIO_TEMPLATES.","maxLength":128,"minLength":1,"type":"string"}},"required":["scenarioId"],"type":"object"},"RunScenarioResponse":{"description":"RunScenarioResponse carries the enqueued job id. Clients poll\n GetScenarioStatus with this id until status != \"pending\".\n\n NOTE: the legacy (pre-sebuf) endpoint returned HTTP 202 Accepted on\n enqueue; the sebuf-generated server emits 200 OK for all successful\n responses (no per-RPC status-code configuration is available in the\n current sebuf HTTP annotations). The 202 → 200 shift on a same-version\n (v1 → v1) migration is called out in docs/api-scenarios.mdx and the\n OpenAPI bundle; external consumers keying off `response.status === 202`\n need to branch on response body shape instead.","properties":{"jobId":{"description":"Generated job id of the form `scenario:{epoch_ms}:{8-char-suffix}`.","type":"string"},"status":{"description":"Always \"pending\" at enqueue time.","type":"string"},"statusUrl":{"description":"Convenience URL the caller can use to poll this job's status.\n Server-computed as `/api/scenario/v1/get-scenario-status?jobId=\u003cjob_id\u003e`.\n Restored after the v1 → v1 sebuf migration because external callers\n may key off this field.","type":"string"}},"type":"object"},"ScenarioImpactCountry":{"description":"ScenarioImpactCountry carries a single country's scenario impact score.","properties":{"impactPct":{"description":"Impact as a 0-100 share of the worst-hit country.","format":"int32","type":"integer"},"iso2":{"description":"2-letter ISO country code.","type":"string"},"totalImpact":{"description":"Raw weighted impact value aggregated across the country's exposed HS2\n chapters. Relative-only — not a currency amount.","format":"double","type":"number"}},"type":"object"},"ScenarioResult":{"description":"ScenarioResult is the computed payload the scenario-worker writes back\n under the `scenario-result:{job_id}` Redis key. Populated only when\n GetScenarioStatusResponse.status == \"done\".","properties":{"affectedChokepointIds":{"items":{"description":"Chokepoint ids disrupted by this scenario.","type":"string"},"type":"array"},"template":{"$ref":"#/components/schemas/ScenarioResultTemplate"},"topImpactCountries":{"items":{"$ref":"#/components/schemas/ScenarioImpactCountry"},"type":"array"}},"type":"object"},"ScenarioResultTemplate":{"description":"ScenarioResultTemplate carries template parameters echoed into the worker's\n computed result so clients can render them without re-looking up the\n template registry.","properties":{"costShockMultiplier":{"description":"Freight cost multiplier applied on top of bypass corridor costs.","format":"double","type":"number"},"disruptionPct":{"description":"0-100 percent of chokepoint capacity blocked.","format":"int32","type":"integer"},"durationDays":{"description":"Estimated duration of disruption in days.","format":"int32","type":"integer"},"name":{"description":"Display name (worker derives this from affected_chokepoint_ids; may be\n `tariff_shock` for tariff-type scenarios).","type":"string"}},"type":"object"},"ScenarioTemplate":{"description":"ScenarioTemplate mirrors the catalog shape served by\n GET /api/scenario/v1/list-scenario-templates. The authoritative template\n registry lives in server/worldmonitor/supply-chain/v1/scenario-templates.ts.","properties":{"affectedChokepointIds":{"items":{"description":"Chokepoint ids this scenario disrupts. Empty for tariff-shock scenarios\n that have no physical chokepoint closure.","type":"string"},"type":"array"},"affectedHs2":{"items":{"description":"HS2 chapter codes affected. Empty means ALL sectors are affected.","type":"string"},"type":"array"},"costShockMultiplier":{"description":"Freight cost multiplier applied on top of bypass corridor costs.","format":"double","type":"number"},"disruptionPct":{"description":"0-100 percent of chokepoint capacity blocked.","format":"int32","type":"integer"},"durationDays":{"description":"Estimated duration of disruption in days.","format":"int32","type":"integer"},"id":{"type":"string"},"name":{"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":"ScenarioService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/scenario/v1/get-scenario-status":{"get":{"description":"GetScenarioStatus polls a single job's result. PRO-gated.\n Returns status=\"pending\" when no result key exists, mirroring the\n worker's lifecycle state once the key is written.","operationId":"GetScenarioStatus","parameters":[{"description":"Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal\n guarded by JOB_ID_RE in the handler.","in":"query","name":"jobId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetScenarioStatusResponse"}}},"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":"GetScenarioStatus","tags":["ScenarioService"]}},"/api/scenario/v1/list-scenario-templates":{"get":{"description":"ListScenarioTemplates returns the catalog of pre-defined scenarios.\n Not PRO-gated — used by documented public API consumers.","operationId":"ListScenarioTemplates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListScenarioTemplatesResponse"}}},"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":"ListScenarioTemplates","tags":["ScenarioService"]}},"/api/scenario/v1/run-scenario":{"post":{"description":"RunScenario enqueues a scenario job on scenario-queue:pending. PRO-gated.\n The scenario-worker (scripts/scenario-worker.mjs) pulls jobs off the\n queue via BLMOVE and writes results under scenario-result:{job_id}.","operationId":"RunScenario","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunScenarioRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunScenarioResponse"}}},"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":"RunScenario","tags":["ScenarioService"]}}}} \ No newline at end of file diff --git a/docs/api/ScenarioService.openapi.yaml b/docs/api/ScenarioService.openapi.yaml new file mode 100644 index 000000000..7ed408182 --- /dev/null +++ b/docs/api/ScenarioService.openapi.yaml @@ -0,0 +1,316 @@ +openapi: 3.1.0 +info: + title: ScenarioService API + version: 1.0.0 +paths: + /api/scenario/v1/run-scenario: + post: + tags: + - ScenarioService + summary: RunScenario + description: |- + RunScenario enqueues a scenario job on scenario-queue:pending. PRO-gated. + The scenario-worker (scripts/scenario-worker.mjs) pulls jobs off the + queue via BLMOVE and writes results under scenario-result:{job_id}. + operationId: RunScenario + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RunScenarioRequest' + required: true + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RunScenarioResponse' + "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/scenario/v1/get-scenario-status: + get: + tags: + - ScenarioService + summary: GetScenarioStatus + description: |- + GetScenarioStatus polls a single job's result. PRO-gated. + Returns status="pending" when no result key exists, mirroring the + worker's lifecycle state once the key is written. + operationId: GetScenarioStatus + parameters: + - name: jobId + in: query + description: |- + Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal + guarded by JOB_ID_RE in the handler. + required: false + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetScenarioStatusResponse' + "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/scenario/v1/list-scenario-templates: + get: + tags: + - ScenarioService + summary: ListScenarioTemplates + description: |- + ListScenarioTemplates returns the catalog of pre-defined scenarios. + Not PRO-gated — used by documented public API consumers. + operationId: ListScenarioTemplates + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ListScenarioTemplatesResponse' + "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. + RunScenarioRequest: + type: object + properties: + scenarioId: + type: string + maxLength: 128 + minLength: 1 + description: Scenario template id — must match an entry in SCENARIO_TEMPLATES. + iso2: + type: string + pattern: ^([A-Z]{2})?$ + description: |- + Optional 2-letter ISO country code to scope the impact computation. + When absent, the worker computes for all countries with seeded exposure. + required: + - scenarioId + description: |- + RunScenarioRequest enqueues a scenario job on the scenario-queue:pending + Upstash list for the async scenario-worker to pick up. + RunScenarioResponse: + type: object + properties: + jobId: + type: string + description: Generated job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. + status: + type: string + description: Always "pending" at enqueue time. + statusUrl: + type: string + description: |- + Convenience URL the caller can use to poll this job's status. + Server-computed as `/api/scenario/v1/get-scenario-status?jobId=`. + Restored after the v1 → v1 sebuf migration because external callers + may key off this field. + description: |- + RunScenarioResponse carries the enqueued job id. Clients poll + GetScenarioStatus with this id until status != "pending". + + NOTE: the legacy (pre-sebuf) endpoint returned HTTP 202 Accepted on + enqueue; the sebuf-generated server emits 200 OK for all successful + responses (no per-RPC status-code configuration is available in the + current sebuf HTTP annotations). The 202 → 200 shift on a same-version + (v1 → v1) migration is called out in docs/api-scenarios.mdx and the + OpenAPI bundle; external consumers keying off `response.status === 202` + need to branch on response body shape instead. + GetScenarioStatusRequest: + type: object + properties: + jobId: + type: string + pattern: ^scenario:[0-9]{13}:[a-z0-9]{8}$ + description: |- + Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal + guarded by JOB_ID_RE in the handler. + required: + - jobId + description: GetScenarioStatusRequest polls the worker result for an enqueued job id. + GetScenarioStatusResponse: + type: object + properties: + status: + type: string + result: + $ref: '#/components/schemas/ScenarioResult' + error: + type: string + description: Populated only when status == "failed". + description: |- + GetScenarioStatusResponse reflects the worker's lifecycle state. + "pending" — no key yet (job still queued or very-recent enqueue). + "processing" — worker has claimed the job but hasn't completed compute. + "done" — compute succeeded; `result` is populated. + "failed" — compute errored; `error` is populated. + ScenarioResult: + type: object + properties: + affectedChokepointIds: + type: array + items: + type: string + description: Chokepoint ids disrupted by this scenario. + topImpactCountries: + type: array + items: + $ref: '#/components/schemas/ScenarioImpactCountry' + template: + $ref: '#/components/schemas/ScenarioResultTemplate' + description: |- + ScenarioResult is the computed payload the scenario-worker writes back + under the `scenario-result:{job_id}` Redis key. Populated only when + GetScenarioStatusResponse.status == "done". + ScenarioImpactCountry: + type: object + properties: + iso2: + type: string + description: 2-letter ISO country code. + totalImpact: + type: number + format: double + description: |- + Raw weighted impact value aggregated across the country's exposed HS2 + chapters. Relative-only — not a currency amount. + impactPct: + type: integer + format: int32 + description: Impact as a 0-100 share of the worst-hit country. + description: ScenarioImpactCountry carries a single country's scenario impact score. + ScenarioResultTemplate: + type: object + properties: + name: + type: string + description: |- + Display name (worker derives this from affected_chokepoint_ids; may be + `tariff_shock` for tariff-type scenarios). + disruptionPct: + type: integer + format: int32 + description: 0-100 percent of chokepoint capacity blocked. + durationDays: + type: integer + format: int32 + description: Estimated duration of disruption in days. + costShockMultiplier: + type: number + format: double + description: Freight cost multiplier applied on top of bypass corridor costs. + description: |- + ScenarioResultTemplate carries template parameters echoed into the worker's + computed result so clients can render them without re-looking up the + template registry. + ListScenarioTemplatesRequest: + type: object + ListScenarioTemplatesResponse: + type: object + properties: + templates: + type: array + items: + $ref: '#/components/schemas/ScenarioTemplate' + ScenarioTemplate: + type: object + properties: + id: + type: string + name: + type: string + affectedChokepointIds: + type: array + items: + type: string + description: |- + Chokepoint ids this scenario disrupts. Empty for tariff-shock scenarios + that have no physical chokepoint closure. + disruptionPct: + type: integer + format: int32 + description: 0-100 percent of chokepoint capacity blocked. + durationDays: + type: integer + format: int32 + description: Estimated duration of disruption in days. + affectedHs2: + type: array + items: + type: string + description: HS2 chapter codes affected. Empty means ALL sectors are affected. + costShockMultiplier: + type: number + format: double + description: Freight cost multiplier applied on top of bypass corridor costs. + description: |- + ScenarioTemplate mirrors the catalog shape served by + GET /api/scenario/v1/list-scenario-templates. The authoritative template + registry lives in server/worldmonitor/supply-chain/v1/scenario-templates.ts. diff --git a/docs/api/ShippingV2Service.openapi.json b/docs/api/ShippingV2Service.openapi.json new file mode 100644 index 000000000..ef7238608 --- /dev/null +++ b/docs/api/ShippingV2Service.openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"BypassOption":{"description":"Single bypass-corridor option around a disrupted chokepoint.","properties":{"activationThreshold":{"description":"Enum-like string, e.g., \"DISRUPTION_SCORE_60\".","type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"id":{"type":"string"},"name":{"type":"string"},"type":{"description":"Type of bypass (e.g., \"maritime_detour\", \"land_corridor\").","type":"string"}},"type":"object"},"ChokepointExposure":{"description":"Single chokepoint exposure for a route.","properties":{"chokepointId":{"type":"string"},"chokepointName":{"type":"string"},"exposurePct":{"format":"int32","type":"integer"}},"type":"object"},"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"},"ListWebhooksRequest":{"description":"ListWebhooksRequest has no fields — the owner is derived from the caller's\n API-key fingerprint (SHA-256 of X-WorldMonitor-Key).","type":"object"},"ListWebhooksResponse":{"description":"ListWebhooksResponse wire shape preserved exactly: the `webhooks` field\n name and the omission of `secret` are part of the partner contract.","properties":{"webhooks":{"items":{"$ref":"#/components/schemas/WebhookSummary"},"type":"array"}},"type":"object"},"RegisterWebhookRequest":{"description":"RegisterWebhookRequest creates a new chokepoint-disruption webhook\n subscription. Wire shape is byte-compatible with the pre-migration\n legacy POST body.","properties":{"alertThreshold":{"description":"Disruption-score threshold for delivery, 0-100. Default 50.","format":"int32","maximum":100,"minimum":0,"type":"integer"},"callbackUrl":{"description":"HTTPS callback URL. Must not resolve to a private/loopback address at\n registration time (SSRF guard). The delivery worker re-validates the\n resolved IP before each send to mitigate DNS rebinding.","maxLength":2048,"minLength":8,"type":"string"},"chokepointIds":{"items":{"description":"Zero or more chokepoint IDs to subscribe to. Empty list subscribes to\n the entire CHOKEPOINT_REGISTRY. Unknown IDs fail with 400.","type":"string"},"type":"array"}},"required":["callbackUrl"],"type":"object"},"RegisterWebhookResponse":{"description":"RegisterWebhookResponse wire shape preserved exactly — partners persist the\n `secret` because the server never returns it again except via rotate-secret.","properties":{"secret":{"description":"Raw 64-char lowercase hex secret (32 random bytes). No `whsec_` prefix.","type":"string"},"subscriberId":{"description":"`wh_` prefix + 24 lowercase hex chars (12 random bytes).","type":"string"}},"type":"object"},"RouteIntelligenceRequest":{"description":"RouteIntelligenceRequest scopes a route-intelligence query by origin and\n destination country. Query-parameter names are preserved verbatim from the\n legacy partner contract (fromIso2/toIso2/cargoType/hs2 — camelCase).","properties":{"cargoType":{"description":"Cargo type — one of: container (default), tanker, bulk, roro.\n Empty string defers to the server default. Unknown values are coerced to\n \"container\" to preserve legacy behavior.","type":"string"},"fromIso2":{"description":"Origin country, ISO-3166-1 alpha-2 uppercase.","pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"description":"2-digit HS commodity code (default \"27\" — mineral fuels). Non-digit\n characters are stripped server-side to match legacy behavior.","type":"string"},"toIso2":{"description":"Destination country, ISO-3166-1 alpha-2 uppercase.","pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2"],"type":"object"},"RouteIntelligenceResponse":{"description":"RouteIntelligenceResponse wire shape preserved byte-for-byte from the\n pre-migration JSON at docs/api-shipping-v2.mdx. `fetched_at` is intentionally\n a string (ISO-8601) rather than int64 epoch ms because partners depend on\n the ISO-8601 shape.","properties":{"bypassOptions":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"},"cargoType":{"type":"string"},"chokepointExposures":{"items":{"$ref":"#/components/schemas/ChokepointExposure"},"type":"array"},"disruptionScore":{"description":"Disruption score of the primary chokepoint, 0-100.","format":"int32","type":"integer"},"fetchedAt":{"description":"ISO-8601 timestamp of when the response was assembled.","type":"string"},"fromIso2":{"type":"string"},"hs2":{"type":"string"},"primaryRouteId":{"type":"string"},"toIso2":{"type":"string"},"warRiskTier":{"description":"War-risk tier enum string, e.g., \"WAR_RISK_TIER_NORMAL\" or \"WAR_RISK_TIER_ELEVATED\".","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"},"WebhookSummary":{"description":"Single webhook record in the list response. `secret` is intentionally\n omitted; use rotate-secret to obtain a new one.","properties":{"active":{"type":"boolean"},"alertThreshold":{"format":"int32","type":"integer"},"callbackUrl":{"type":"string"},"chokepointIds":{"items":{"type":"string"},"type":"array"},"createdAt":{"description":"ISO-8601 timestamp of registration.","type":"string"},"subscriberId":{"type":"string"}},"type":"object"}}},"info":{"title":"ShippingV2Service API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/v2/shipping/route-intelligence":{"get":{"description":"RouteIntelligence scores a country-pair trade route for chokepoint exposure\n and current disruption risk. Partner-facing; wire shape is byte-compatible\n with the pre-migration JSON response documented at docs/api-shipping-v2.mdx.","operationId":"RouteIntelligence","parameters":[{"description":"Origin country, ISO-3166-1 alpha-2 uppercase.","in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"description":"Destination country, ISO-3166-1 alpha-2 uppercase.","in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"description":"Cargo type — one of: container (default), tanker, bulk, roro.\n Empty string defers to the server default. Unknown values are coerced to\n \"container\" to preserve legacy behavior.","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"2-digit HS commodity code (default \"27\" — mineral fuels). Non-digit\n characters are stripped server-side to match legacy behavior.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteIntelligenceResponse"}}},"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":"RouteIntelligence","tags":["ShippingV2Service"]}},"/api/v2/shipping/webhooks":{"get":{"description":"ListWebhooks returns the caller's registered webhooks filtered by the\n SHA-256 owner tag of the calling API key. The `secret` is intentionally\n omitted from the response; use rotate-secret to obtain a new one.","operationId":"ListWebhooks","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhooksResponse"}}},"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":"ListWebhooks","tags":["ShippingV2Service"]},"post":{"description":"RegisterWebhook subscribes a callback URL to chokepoint disruption alerts.\n Returns the subscriberId and the raw HMAC secret — the secret is never\n returned again except via rotate-secret.","operationId":"RegisterWebhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterWebhookRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterWebhookResponse"}}},"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":"RegisterWebhook","tags":["ShippingV2Service"]}}}} \ No newline at end of file diff --git a/docs/api/ShippingV2Service.openapi.yaml b/docs/api/ShippingV2Service.openapi.yaml new file mode 100644 index 000000000..79fa11136 --- /dev/null +++ b/docs/api/ShippingV2Service.openapi.yaml @@ -0,0 +1,335 @@ +openapi: 3.1.0 +info: + title: ShippingV2Service API + version: 1.0.0 +paths: + /api/v2/shipping/route-intelligence: + get: + tags: + - ShippingV2Service + summary: RouteIntelligence + description: |- + RouteIntelligence scores a country-pair trade route for chokepoint exposure + and current disruption risk. Partner-facing; wire shape is byte-compatible + with the pre-migration JSON response documented at docs/api-shipping-v2.mdx. + operationId: RouteIntelligence + parameters: + - name: fromIso2 + in: query + description: Origin country, ISO-3166-1 alpha-2 uppercase. + required: false + schema: + type: string + - name: toIso2 + in: query + description: Destination country, ISO-3166-1 alpha-2 uppercase. + required: false + schema: + type: string + - name: cargoType + in: query + description: |- + Cargo type — one of: container (default), tanker, bulk, roro. + Empty string defers to the server default. Unknown values are coerced to + "container" to preserve legacy behavior. + required: false + schema: + type: string + - name: hs2 + in: query + description: |- + 2-digit HS commodity code (default "27" — mineral fuels). Non-digit + characters are stripped server-side to match legacy behavior. + required: false + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RouteIntelligenceResponse' + "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/v2/shipping/webhooks: + get: + tags: + - ShippingV2Service + summary: ListWebhooks + description: |- + ListWebhooks returns the caller's registered webhooks filtered by the + SHA-256 owner tag of the calling API key. The `secret` is intentionally + omitted from the response; use rotate-secret to obtain a new one. + operationId: ListWebhooks + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ListWebhooksResponse' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + default: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: + - ShippingV2Service + summary: RegisterWebhook + description: |- + RegisterWebhook subscribes a callback URL to chokepoint disruption alerts. + Returns the subscriberId and the raw HMAC secret — the secret is never + returned again except via rotate-secret. + operationId: RegisterWebhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterWebhookRequest' + required: true + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterWebhookResponse' + "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. + RouteIntelligenceRequest: + type: object + properties: + fromIso2: + type: string + pattern: ^[A-Z]{2}$ + description: Origin country, ISO-3166-1 alpha-2 uppercase. + toIso2: + type: string + pattern: ^[A-Z]{2}$ + description: Destination country, ISO-3166-1 alpha-2 uppercase. + cargoType: + type: string + description: |- + Cargo type — one of: container (default), tanker, bulk, roro. + Empty string defers to the server default. Unknown values are coerced to + "container" to preserve legacy behavior. + hs2: + type: string + description: |- + 2-digit HS commodity code (default "27" — mineral fuels). Non-digit + characters are stripped server-side to match legacy behavior. + required: + - fromIso2 + - toIso2 + description: |- + RouteIntelligenceRequest scopes a route-intelligence query by origin and + destination country. Query-parameter names are preserved verbatim from the + legacy partner contract (fromIso2/toIso2/cargoType/hs2 — camelCase). + RouteIntelligenceResponse: + type: object + properties: + fromIso2: + type: string + toIso2: + type: string + cargoType: + type: string + hs2: + type: string + primaryRouteId: + type: string + chokepointExposures: + type: array + items: + $ref: '#/components/schemas/ChokepointExposure' + bypassOptions: + type: array + items: + $ref: '#/components/schemas/BypassOption' + warRiskTier: + type: string + description: War-risk tier enum string, e.g., "WAR_RISK_TIER_NORMAL" or "WAR_RISK_TIER_ELEVATED". + disruptionScore: + type: integer + format: int32 + description: Disruption score of the primary chokepoint, 0-100. + fetchedAt: + type: string + description: ISO-8601 timestamp of when the response was assembled. + description: |- + RouteIntelligenceResponse wire shape preserved byte-for-byte from the + pre-migration JSON at docs/api-shipping-v2.mdx. `fetched_at` is intentionally + a string (ISO-8601) rather than int64 epoch ms because partners depend on + the ISO-8601 shape. + ChokepointExposure: + type: object + properties: + chokepointId: + type: string + chokepointName: + type: string + exposurePct: + type: integer + format: int32 + description: Single chokepoint exposure for a route. + BypassOption: + type: object + properties: + id: + type: string + name: + type: string + type: + type: string + description: Type of bypass (e.g., "maritime_detour", "land_corridor"). + addedTransitDays: + type: integer + format: int32 + addedCostMultiplier: + type: number + format: double + activationThreshold: + type: string + description: Enum-like string, e.g., "DISRUPTION_SCORE_60". + description: Single bypass-corridor option around a disrupted chokepoint. + RegisterWebhookRequest: + type: object + properties: + callbackUrl: + type: string + maxLength: 2048 + minLength: 8 + description: |- + HTTPS callback URL. Must not resolve to a private/loopback address at + registration time (SSRF guard). The delivery worker re-validates the + resolved IP before each send to mitigate DNS rebinding. + chokepointIds: + type: array + items: + type: string + description: |- + Zero or more chokepoint IDs to subscribe to. Empty list subscribes to + the entire CHOKEPOINT_REGISTRY. Unknown IDs fail with 400. + alertThreshold: + type: integer + maximum: 100 + minimum: 0 + format: int32 + description: Disruption-score threshold for delivery, 0-100. Default 50. + required: + - callbackUrl + description: |- + RegisterWebhookRequest creates a new chokepoint-disruption webhook + subscription. Wire shape is byte-compatible with the pre-migration + legacy POST body. + RegisterWebhookResponse: + type: object + properties: + subscriberId: + type: string + description: '`wh_` prefix + 24 lowercase hex chars (12 random bytes).' + secret: + type: string + description: Raw 64-char lowercase hex secret (32 random bytes). No `whsec_` prefix. + description: |- + RegisterWebhookResponse wire shape preserved exactly — partners persist the + `secret` because the server never returns it again except via rotate-secret. + ListWebhooksRequest: + type: object + description: |- + ListWebhooksRequest has no fields — the owner is derived from the caller's + API-key fingerprint (SHA-256 of X-WorldMonitor-Key). + ListWebhooksResponse: + type: object + properties: + webhooks: + type: array + items: + $ref: '#/components/schemas/WebhookSummary' + description: |- + ListWebhooksResponse wire shape preserved exactly: the `webhooks` field + name and the omission of `secret` are part of the partner contract. + WebhookSummary: + type: object + properties: + subscriberId: + type: string + callbackUrl: + type: string + chokepointIds: + type: array + items: + type: string + alertThreshold: + type: integer + format: int32 + createdAt: + type: string + description: ISO-8601 timestamp of registration. + active: + type: boolean + description: |- + Single webhook record in the list response. `secret` is intentionally + omitted; use rotate-secret to obtain a new one. diff --git a/docs/api/SupplyChainService.openapi.json b/docs/api/SupplyChainService.openapi.json index 5fc1e6089..19effbb3c 100644 --- a/docs/api/SupplyChainService.openapi.json +++ b/docs/api/SupplyChainService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"BypassCorridorOption":{"description":"BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI.\n Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes\n directly without any client-side geometry lookup.","properties":{"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"fromPort":{"$ref":"#/components/schemas/GeoPoint"},"id":{"type":"string"},"name":{"type":"string"},"status":{"description":"Status of a bypass corridor for UI labeling. \"active\" means usable today;\n \"proposed\" means documented but not yet built/operational; \"unavailable\"\n means blockaded or otherwise blocked from use.","enum":["CORRIDOR_STATUS_UNSPECIFIED","CORRIDOR_STATUS_ACTIVE","CORRIDOR_STATUS_PROPOSED","CORRIDOR_STATUS_UNAVAILABLE"],"type":"string"},"toPort":{"$ref":"#/components/schemas/GeoPoint"},"type":{"type":"string"},"warRiskTier":{"type":"string"}},"type":"object"},"BypassOption":{"properties":{"activationThreshold":{"type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"bypassWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"},"capacityConstraintTonnage":{"format":"int64","type":"string"},"id":{"type":"string"},"liveScore":{"format":"double","type":"number"},"name":{"type":"string"},"notes":{"type":"string"},"suitableCargoTypes":{"items":{"type":"string"},"type":"array"},"type":{"type":"string"},"waypointChokepointIds":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ChokepointExposureEntry":{"description":"ChokepointExposureEntry holds per-chokepoint exposure data for a country.","properties":{"chokepointId":{"description":"Canonical chokepoint ID from the chokepoint registry.","type":"string"},"chokepointName":{"description":"Human-readable chokepoint name.","type":"string"},"coastSide":{"description":"Which ocean/basin side the country's ports face (atlantic, pacific, indian, med, multi, landlocked).","type":"string"},"exposureScore":{"description":"Exposure score 0–100; higher = more dependent on this chokepoint.","format":"double","type":"number"},"shockSupported":{"description":"Whether the shock model is supported for this chokepoint + hs2 combination.","type":"boolean"}},"type":"object"},"ChokepointExposureSummary":{"properties":{"chokepointId":{"type":"string"},"chokepointName":{"type":"string"},"exposurePct":{"format":"int32","type":"integer"}},"type":"object"},"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"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"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GeoPoint":{"description":"GeoPoint is a [longitude, latitude] pair.","properties":{"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"}},"type":"object"},"GetBypassOptionsRequest":{"properties":{"cargoType":{"description":"container | tanker | bulk | roro (default: \"container\")","type":"string"},"chokepointId":{"type":"string"},"closurePct":{"description":"0-100, percent of capacity blocked (default: 100)","format":"int32","type":"integer"}},"required":["chokepointId"],"type":"object"},"GetBypassOptionsResponse":{"properties":{"cargoType":{"type":"string"},"chokepointId":{"type":"string"},"closurePct":{"format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"options":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"},"primaryChokepointWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetChokepointHistoryRequest":{"description":"GetChokepointHistory returns the transit-count history for a single\n chokepoint. Loaded lazily on card expand so the main chokepoint-status\n response can stay compact (no 180-day history per chokepoint).","properties":{"chokepointId":{"type":"string"}},"required":["chokepointId"],"type":"object"},"GetChokepointHistoryResponse":{"properties":{"chokepointId":{"type":"string"},"fetchedAt":{"format":"int64","type":"string"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCountryChokepointIndexRequest":{"description":"GetCountryChokepointIndexRequest specifies the country and optional HS2 chapter.","properties":{"hs2":{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code (uppercase).","pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryChokepointIndexResponse":{"description":"GetCountryChokepointIndexResponse returns exposure scores for all relevant chokepoints.","properties":{"exposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureEntry"},"type":"array"},"fetchedAt":{"description":"ISO timestamp of when this data was last seeded.","type":"string"},"hs2":{"description":"HS2 chapter used for the computation.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code echoed from the request.","type":"string"},"primaryChokepointId":{"description":"Canonical ID of the chokepoint with the highest exposure score.","type":"string"},"vulnerabilityIndex":{"description":"Composite vulnerability index 0–100 (weighted sum of top-3 exposures).","format":"double","type":"number"}},"type":"object"},"GetCountryCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"hs2":{"description":"HS2 chapter (default: \"27\")","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetCountryCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"coverageDays":{"description":"Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters)","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"hasEnergyModel":{"description":"Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false)","type":"boolean"},"hs2":{"type":"string"},"iso2":{"type":"string"},"supplyDeficitPct":{"description":"Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only)","format":"double","type":"number"},"unavailableReason":{"description":"Null/unavailable explanation for non-energy sectors","type":"string"},"warRiskPremiumBps":{"description":"War risk insurance premium in basis points for this chokepoint","format":"int32","type":"integer"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetRouteExplorerLaneRequest":{"properties":{"cargoType":{"description":"One of: container, tanker, bulk, roro","type":"string"},"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"description":"HS2 chapter code, e.g. \"27\", \"85\"","type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2","cargoType"],"type":"object"},"GetRouteExplorerLaneResponse":{"properties":{"bypassOptions":{"items":{"$ref":"#/components/schemas/BypassCorridorOption"},"type":"array"},"cargoType":{"type":"string"},"chokepointExposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureSummary"},"type":"array"},"disruptionScore":{"format":"double","type":"number"},"estFreightUsdPerTeuRange":{"$ref":"#/components/schemas/NumberRange"},"estTransitDaysRange":{"$ref":"#/components/schemas/NumberRange"},"fetchedAt":{"type":"string"},"fromIso2":{"type":"string"},"hs2":{"type":"string"},"noModeledLane":{"description":"True when the wrapper fell back to the origin's first route (no shared route\n between origin and destination clusters). Signals \"no modeled lane\" to the UI.","type":"boolean"},"primaryRouteGeometry":{"items":{"$ref":"#/components/schemas/GeoPoint"},"type":"array"},"primaryRouteId":{"description":"Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane.","type":"string"},"toIso2":{"type":"string"},"warRiskTier":{"type":"string"}},"type":"object"},"GetRouteImpactRequest":{"properties":{"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2"],"type":"object"},"GetRouteImpactResponse":{"properties":{"comtradeSource":{"type":"string"},"dependencyFlags":{"items":{"description":"DependencyFlag classifies how a country+sector dependency can fail.","enum":["DEPENDENCY_FLAG_UNSPECIFIED","DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL","DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL","DEPENDENCY_FLAG_COMPOUND_RISK","DEPENDENCY_FLAG_DIVERSIFIABLE"],"type":"string"},"type":"array"},"fetchedAt":{"type":"string"},"hs2InSeededUniverse":{"type":"boolean"},"laneValueUsd":{"format":"double","type":"number"},"primaryExporterIso2":{"type":"string"},"primaryExporterShare":{"format":"double","type":"number"},"resilienceScore":{"format":"double","type":"number"},"topStrategicProducts":{"items":{"$ref":"#/components/schemas/StrategicProduct"},"type":"array"}},"type":"object"},"GetSectorDependencyRequest":{"properties":{"hs2":{"description":"HS2 chapter code, e.g. \"27\" (mineral fuels), \"85\" (electronics)","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","hs2"],"type":"object"},"GetSectorDependencyResponse":{"properties":{"fetchedAt":{"type":"string"},"flags":{"items":{"description":"DependencyFlag classifies how a country+sector dependency can fail.","enum":["DEPENDENCY_FLAG_UNSPECIFIED","DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL","DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL","DEPENDENCY_FLAG_COMPOUND_RISK","DEPENDENCY_FLAG_DIVERSIFIABLE"],"type":"string"},"type":"array"},"hasViableBypass":{"description":"Whether at least one viable bypass corridor exists for the primary chokepoint.","type":"boolean"},"hs2":{"type":"string"},"hs2Label":{"description":"Human-readable HS2 chapter name.","type":"string"},"iso2":{"type":"string"},"primaryChokepointExposure":{"description":"Exposure score for the primary chokepoint (0–100).","format":"double","type":"number"},"primaryChokepointId":{"description":"Chokepoint ID with the highest exposure score for this country+sector.","type":"string"},"primaryExporterIso2":{"description":"ISO2 of the country supplying the largest share of this sector's imports.","type":"string"},"primaryExporterShare":{"description":"Share of imports from the primary exporter (0–1). 0 = no Comtrade data available.","format":"double","type":"number"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"NumberRange":{"description":"Inclusive integer range for transit days / freight USD estimates.","properties":{"max":{"format":"int32","type":"integer"},"min":{"format":"int32","type":"integer"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"StrategicProduct":{"properties":{"hs4":{"type":"string"},"label":{"type":"string"},"primaryChokepointId":{"type":"string"},"topExporterIso2":{"type":"string"},"topExporterShare":{"format":"double","type":"number"},"totalValueUsd":{"format":"double","type":"number"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"dataAvailable":{"description":"False when the upstream portwatch/relay source did not return data for\n this chokepoint in the current cycle — the summary fields are zero-state\n fill, not a genuine \"zero traffic\" reading. Client should render a\n \"transit data unavailable\" indicator and skip stat/chart rendering.","type":"boolean"},"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"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":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-bypass-options":{"get":{"description":"GetBypassOptions returns ranked bypass corridors for a chokepoint. PRO-gated.","operationId":"GetBypassOptions","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"container | tanker | bulk | roro (default: \"container\")","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"0-100, percent of capacity blocked (default: 100)","in":"query","name":"closurePct","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBypassOptionsResponse"}}},"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":"GetBypassOptions","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-history":{"get":{"description":"GetChokepointHistory returns transit-count history for a single chokepoint,\n loaded lazily on card expand. Keeps the status RPC compact (no 180-day\n history per chokepoint on every call).","operationId":"GetChokepointHistory","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointHistoryResponse"}}},"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":"GetChokepointHistory","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"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":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-chokepoint-index":{"get":{"description":"GetCountryChokepointIndex returns per-chokepoint exposure scores for a country. PRO-gated.","operationId":"GetCountryChokepointIndex","parameters":[{"description":"ISO 3166-1 alpha-2 country code (uppercase).","in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryChokepointIndexResponse"}}},"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":"GetCountryChokepointIndex","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-cost-shock":{"get":{"description":"GetCountryCostShock returns cost shock and war risk data for a country+chokepoint. PRO-gated.","operationId":"GetCountryCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (default: \"27\")","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryCostShockResponse"}}},"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":"GetCountryCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"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":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-route-explorer-lane":{"get":{"description":"GetRouteExplorerLane returns the primary maritime route, chokepoint exposures,\n bypass options with geometry, war risk, and static transit/freight estimates for\n a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor\n endpoint's compute with browser-callable auth and adds fields needed by the\n Route Explorer UI.","operationId":"GetRouteExplorerLane","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter code, e.g. \"27\", \"85\"","in":"query","name":"hs2","required":false,"schema":{"type":"string"}},{"description":"One of: container, tanker, bulk, roro","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteExplorerLaneResponse"}}},"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":"GetRouteExplorerLane","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-route-impact":{"get":{"operationId":"GetRouteImpact","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteImpactResponse"}}},"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":"GetRouteImpact","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-sector-dependency":{"get":{"description":"GetSectorDependency returns dependency flags and risk profile for a country+HS2 sector. PRO-gated.","operationId":"GetSectorDependency","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter code, e.g. \"27\" (mineral fuels), \"85\" (electronics)","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSectorDependencyResponse"}}},"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":"GetSectorDependency","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"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":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"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":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file +{"components":{"schemas":{"BypassCorridorOption":{"description":"BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI.\n Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes\n directly without any client-side geometry lookup.","properties":{"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"fromPort":{"$ref":"#/components/schemas/GeoPoint"},"id":{"type":"string"},"name":{"type":"string"},"status":{"description":"Status of a bypass corridor for UI labeling. \"active\" means usable today;\n \"proposed\" means documented but not yet built/operational; \"unavailable\"\n means blockaded or otherwise blocked from use.","enum":["CORRIDOR_STATUS_UNSPECIFIED","CORRIDOR_STATUS_ACTIVE","CORRIDOR_STATUS_PROPOSED","CORRIDOR_STATUS_UNAVAILABLE"],"type":"string"},"toPort":{"$ref":"#/components/schemas/GeoPoint"},"type":{"type":"string"},"warRiskTier":{"type":"string"}},"type":"object"},"BypassOption":{"properties":{"activationThreshold":{"type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"bypassWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"},"capacityConstraintTonnage":{"format":"int64","type":"string"},"id":{"type":"string"},"liveScore":{"format":"double","type":"number"},"name":{"type":"string"},"notes":{"type":"string"},"suitableCargoTypes":{"items":{"type":"string"},"type":"array"},"type":{"type":"string"},"waypointChokepointIds":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ChokepointExposureEntry":{"description":"ChokepointExposureEntry holds per-chokepoint exposure data for a country.","properties":{"chokepointId":{"description":"Canonical chokepoint ID from the chokepoint registry.","type":"string"},"chokepointName":{"description":"Human-readable chokepoint name.","type":"string"},"coastSide":{"description":"Which ocean/basin side the country's ports face (atlantic, pacific, indian, med, multi, landlocked).","type":"string"},"exposureScore":{"description":"Exposure score 0–100; higher = more dependent on this chokepoint.","format":"double","type":"number"},"shockSupported":{"description":"Whether the shock model is supported for this chokepoint + hs2 combination.","type":"boolean"}},"type":"object"},"ChokepointExposureSummary":{"properties":{"chokepointId":{"type":"string"},"chokepointName":{"type":"string"},"exposurePct":{"format":"int32","type":"integer"}},"type":"object"},"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"CountryProduct":{"properties":{"description":{"type":"string"},"hs4":{"type":"string"},"topExporters":{"items":{"$ref":"#/components/schemas/ProductExporter"},"type":"array"},"totalValue":{"format":"double","type":"number"},"year":{"format":"int32","type":"integer"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"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"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GeoPoint":{"description":"GeoPoint is a [longitude, latitude] pair.","properties":{"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"}},"type":"object"},"GetBypassOptionsRequest":{"properties":{"cargoType":{"description":"container | tanker | bulk | roro (default: \"container\")","type":"string"},"chokepointId":{"type":"string"},"closurePct":{"description":"0-100, percent of capacity blocked (default: 100)","format":"int32","type":"integer"}},"required":["chokepointId"],"type":"object"},"GetBypassOptionsResponse":{"properties":{"cargoType":{"type":"string"},"chokepointId":{"type":"string"},"closurePct":{"format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"options":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"},"primaryChokepointWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetChokepointHistoryRequest":{"description":"GetChokepointHistory returns the transit-count history for a single\n chokepoint. Loaded lazily on card expand so the main chokepoint-status\n response can stay compact (no 180-day history per chokepoint).","properties":{"chokepointId":{"type":"string"}},"required":["chokepointId"],"type":"object"},"GetChokepointHistoryResponse":{"properties":{"chokepointId":{"type":"string"},"fetchedAt":{"format":"int64","type":"string"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCountryChokepointIndexRequest":{"description":"GetCountryChokepointIndexRequest specifies the country and optional HS2 chapter.","properties":{"hs2":{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code (uppercase).","pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryChokepointIndexResponse":{"description":"GetCountryChokepointIndexResponse returns exposure scores for all relevant chokepoints.","properties":{"exposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureEntry"},"type":"array"},"fetchedAt":{"description":"ISO timestamp of when this data was last seeded.","type":"string"},"hs2":{"description":"HS2 chapter used for the computation.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code echoed from the request.","type":"string"},"primaryChokepointId":{"description":"Canonical ID of the chokepoint with the highest exposure score.","type":"string"},"vulnerabilityIndex":{"description":"Composite vulnerability index 0–100 (weighted sum of top-3 exposures).","format":"double","type":"number"}},"type":"object"},"GetCountryCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"hs2":{"description":"HS2 chapter (default: \"27\")","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetCountryCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"coverageDays":{"description":"Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters)","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"hasEnergyModel":{"description":"Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false)","type":"boolean"},"hs2":{"type":"string"},"iso2":{"type":"string"},"supplyDeficitPct":{"description":"Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only)","format":"double","type":"number"},"unavailableReason":{"description":"Null/unavailable explanation for non-energy sectors","type":"string"},"warRiskPremiumBps":{"description":"War risk insurance premium in basis points for this chokepoint","format":"int32","type":"integer"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetCountryProductsRequest":{"properties":{"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryProductsResponse":{"properties":{"fetchedAt":{"description":"ISO timestamp from the seeded payload (empty when no data is cached).","type":"string"},"iso2":{"type":"string"},"products":{"items":{"$ref":"#/components/schemas/CountryProduct"},"type":"array"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetMultiSectorCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"closureDays":{"description":"Closure-window duration in days. Server clamps to [1, 365]. Defaults to 30.","format":"int32","type":"integer"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetMultiSectorCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"closureDays":{"description":"Server-clamped closure-window duration in days (1-365).","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"iso2":{"type":"string"},"sectors":{"items":{"$ref":"#/components/schemas/MultiSectorCostShock"},"type":"array"},"totalAddedCost":{"description":"Sum of total_cost_shock across all sectors.","format":"double","type":"number"},"unavailableReason":{"description":"Populated when no seeded import data is available for the country.","type":"string"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetRouteExplorerLaneRequest":{"properties":{"cargoType":{"description":"One of: container, tanker, bulk, roro","type":"string"},"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"description":"HS2 chapter code, e.g. \"27\", \"85\"","type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2","cargoType"],"type":"object"},"GetRouteExplorerLaneResponse":{"properties":{"bypassOptions":{"items":{"$ref":"#/components/schemas/BypassCorridorOption"},"type":"array"},"cargoType":{"type":"string"},"chokepointExposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureSummary"},"type":"array"},"disruptionScore":{"format":"double","type":"number"},"estFreightUsdPerTeuRange":{"$ref":"#/components/schemas/NumberRange"},"estTransitDaysRange":{"$ref":"#/components/schemas/NumberRange"},"fetchedAt":{"type":"string"},"fromIso2":{"type":"string"},"hs2":{"type":"string"},"noModeledLane":{"description":"True when the wrapper fell back to the origin's first route (no shared route\n between origin and destination clusters). Signals \"no modeled lane\" to the UI.","type":"boolean"},"primaryRouteGeometry":{"items":{"$ref":"#/components/schemas/GeoPoint"},"type":"array"},"primaryRouteId":{"description":"Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane.","type":"string"},"toIso2":{"type":"string"},"warRiskTier":{"type":"string"}},"type":"object"},"GetRouteImpactRequest":{"properties":{"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2"],"type":"object"},"GetRouteImpactResponse":{"properties":{"comtradeSource":{"type":"string"},"dependencyFlags":{"items":{"description":"DependencyFlag classifies how a country+sector dependency can fail.","enum":["DEPENDENCY_FLAG_UNSPECIFIED","DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL","DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL","DEPENDENCY_FLAG_COMPOUND_RISK","DEPENDENCY_FLAG_DIVERSIFIABLE"],"type":"string"},"type":"array"},"fetchedAt":{"type":"string"},"hs2InSeededUniverse":{"type":"boolean"},"laneValueUsd":{"format":"double","type":"number"},"primaryExporterIso2":{"type":"string"},"primaryExporterShare":{"format":"double","type":"number"},"resilienceScore":{"format":"double","type":"number"},"topStrategicProducts":{"items":{"$ref":"#/components/schemas/StrategicProduct"},"type":"array"}},"type":"object"},"GetSectorDependencyRequest":{"properties":{"hs2":{"description":"HS2 chapter code, e.g. \"27\" (mineral fuels), \"85\" (electronics)","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","hs2"],"type":"object"},"GetSectorDependencyResponse":{"properties":{"fetchedAt":{"type":"string"},"flags":{"items":{"description":"DependencyFlag classifies how a country+sector dependency can fail.","enum":["DEPENDENCY_FLAG_UNSPECIFIED","DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL","DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL","DEPENDENCY_FLAG_COMPOUND_RISK","DEPENDENCY_FLAG_DIVERSIFIABLE"],"type":"string"},"type":"array"},"hasViableBypass":{"description":"Whether at least one viable bypass corridor exists for the primary chokepoint.","type":"boolean"},"hs2":{"type":"string"},"hs2Label":{"description":"Human-readable HS2 chapter name.","type":"string"},"iso2":{"type":"string"},"primaryChokepointExposure":{"description":"Exposure score for the primary chokepoint (0–100).","format":"double","type":"number"},"primaryChokepointId":{"description":"Chokepoint ID with the highest exposure score for this country+sector.","type":"string"},"primaryExporterIso2":{"description":"ISO2 of the country supplying the largest share of this sector's imports.","type":"string"},"primaryExporterShare":{"description":"Share of imports from the primary exporter (0–1). 0 = no Comtrade data available.","format":"double","type":"number"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"MultiSectorCostShock":{"properties":{"addedTransitDays":{"description":"Bypass-corridor transit penalty (informational).","format":"int32","type":"integer"},"closureDays":{"description":"Echoes the clamped closure duration used for total_cost_shock (1-365).","format":"int32","type":"integer"},"freightAddedPctPerTon":{"description":"Bypass-corridor freight uplift fraction (0.10 == +10% per ton).","format":"double","type":"number"},"hs2":{"description":"HS2 chapter code (e.g. \"27\" mineral fuels, \"85\" electronics).","type":"string"},"hs2Label":{"description":"Friendly chapter label (e.g. \"Energy\", \"Electronics\").","type":"string"},"importValueAnnual":{"description":"Total annual import value (USD) for this sector.","format":"double","type":"number"},"totalCostShock":{"description":"Cost for the requested closure_days window.","format":"double","type":"number"},"totalCostShock30Days":{"format":"double","type":"number"},"totalCostShock90Days":{"format":"double","type":"number"},"totalCostShockPerDay":{"format":"double","type":"number"},"warRiskPremiumBps":{"description":"War-risk insurance premium (basis points) sourced from the chokepoint tier.","format":"int32","type":"integer"}},"type":"object"},"NumberRange":{"description":"Inclusive integer range for transit days / freight USD estimates.","properties":{"max":{"format":"int32","type":"integer"},"min":{"format":"int32","type":"integer"}},"type":"object"},"ProductExporter":{"properties":{"partnerCode":{"format":"int32","type":"integer"},"partnerIso2":{"type":"string"},"share":{"format":"double","type":"number"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"StrategicProduct":{"properties":{"hs4":{"type":"string"},"label":{"type":"string"},"primaryChokepointId":{"type":"string"},"topExporterIso2":{"type":"string"},"topExporterShare":{"format":"double","type":"number"},"totalValueUsd":{"format":"double","type":"number"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"dataAvailable":{"description":"False when the upstream portwatch/relay source did not return data for\n this chokepoint in the current cycle — the summary fields are zero-state\n fill, not a genuine \"zero traffic\" reading. Client should render a\n \"transit data unavailable\" indicator and skip stat/chart rendering.","type":"boolean"},"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"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":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-bypass-options":{"get":{"description":"GetBypassOptions returns ranked bypass corridors for a chokepoint. PRO-gated.","operationId":"GetBypassOptions","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"container | tanker | bulk | roro (default: \"container\")","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"0-100, percent of capacity blocked (default: 100)","in":"query","name":"closurePct","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBypassOptionsResponse"}}},"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":"GetBypassOptions","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-history":{"get":{"description":"GetChokepointHistory returns transit-count history for a single chokepoint,\n loaded lazily on card expand. Keeps the status RPC compact (no 180-day\n history per chokepoint on every call).","operationId":"GetChokepointHistory","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointHistoryResponse"}}},"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":"GetChokepointHistory","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"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":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-chokepoint-index":{"get":{"description":"GetCountryChokepointIndex returns per-chokepoint exposure scores for a country. PRO-gated.","operationId":"GetCountryChokepointIndex","parameters":[{"description":"ISO 3166-1 alpha-2 country code (uppercase).","in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryChokepointIndexResponse"}}},"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":"GetCountryChokepointIndex","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-cost-shock":{"get":{"description":"GetCountryCostShock returns cost shock and war risk data for a country+chokepoint. PRO-gated.","operationId":"GetCountryCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (default: \"27\")","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryCostShockResponse"}}},"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":"GetCountryCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-products":{"get":{"description":"GetCountryProducts returns the seeded bilateral-HS4 import basket for a country. PRO-gated.","operationId":"GetCountryProducts","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryProductsResponse"}}},"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":"GetCountryProducts","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"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":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-multi-sector-cost-shock":{"get":{"description":"GetMultiSectorCostShock returns per-sector cost-shock estimates for a\n country+chokepoint+closure-window. PRO-gated.","operationId":"GetMultiSectorCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"Closure-window duration in days. Server clamps to [1, 365]. Defaults to 30.","in":"query","name":"closureDays","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMultiSectorCostShockResponse"}}},"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":"GetMultiSectorCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-route-explorer-lane":{"get":{"description":"GetRouteExplorerLane returns the primary maritime route, chokepoint exposures,\n bypass options with geometry, war risk, and static transit/freight estimates for\n a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor\n endpoint's compute with browser-callable auth and adds fields needed by the\n Route Explorer UI.","operationId":"GetRouteExplorerLane","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter code, e.g. \"27\", \"85\"","in":"query","name":"hs2","required":false,"schema":{"type":"string"}},{"description":"One of: container, tanker, bulk, roro","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteExplorerLaneResponse"}}},"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":"GetRouteExplorerLane","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-route-impact":{"get":{"operationId":"GetRouteImpact","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteImpactResponse"}}},"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":"GetRouteImpact","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-sector-dependency":{"get":{"description":"GetSectorDependency returns dependency flags and risk profile for a country+HS2 sector. PRO-gated.","operationId":"GetSectorDependency","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter code, e.g. \"27\" (mineral fuels), \"85\" (electronics)","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSectorDependencyResponse"}}},"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":"GetSectorDependency","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"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":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"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":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file diff --git a/docs/api/SupplyChainService.openapi.yaml b/docs/api/SupplyChainService.openapi.yaml index 30fb0f4d8..1941345fe 100644 --- a/docs/api/SupplyChainService.openapi.yaml +++ b/docs/api/SupplyChainService.openapi.yaml @@ -266,6 +266,84 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/supply-chain/v1/get-country-products: + get: + tags: + - SupplyChainService + summary: GetCountryProducts + description: GetCountryProducts returns the seeded bilateral-HS4 import basket for a country. PRO-gated. + operationId: GetCountryProducts + parameters: + - name: iso2 + in: query + required: false + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetCountryProductsResponse' + "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/supply-chain/v1/get-multi-sector-cost-shock: + get: + tags: + - SupplyChainService + summary: GetMultiSectorCostShock + description: |- + GetMultiSectorCostShock returns per-sector cost-shock estimates for a + country+chokepoint+closure-window. PRO-gated. + operationId: GetMultiSectorCostShock + parameters: + - name: iso2 + in: query + required: false + schema: + type: string + - name: chokepointId + in: query + required: false + schema: + type: string + - name: closureDays + in: query + description: Closure-window duration in days. Server clamps to [1, 365]. Defaults to 30. + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetMultiSectorCostShockResponse' + "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/supply-chain/v1/get-sector-dependency: get: tags: @@ -991,6 +1069,153 @@ components: description: Null/unavailable explanation for non-energy sectors fetchedAt: type: string + GetCountryProductsRequest: + type: object + properties: + iso2: + type: string + pattern: ^[A-Z]{2}$ + required: + - iso2 + GetCountryProductsResponse: + type: object + properties: + iso2: + type: string + products: + type: array + items: + $ref: '#/components/schemas/CountryProduct' + fetchedAt: + type: string + description: ISO timestamp from the seeded payload (empty when no data is cached). + CountryProduct: + type: object + properties: + hs4: + type: string + description: + type: string + totalValue: + type: number + format: double + topExporters: + type: array + items: + $ref: '#/components/schemas/ProductExporter' + year: + type: integer + format: int32 + ProductExporter: + type: object + properties: + partnerCode: + type: integer + format: int32 + partnerIso2: + type: string + value: + type: number + format: double + share: + type: number + format: double + GetMultiSectorCostShockRequest: + type: object + properties: + iso2: + type: string + pattern: ^[A-Z]{2}$ + chokepointId: + type: string + closureDays: + type: integer + format: int32 + description: Closure-window duration in days. Server clamps to [1, 365]. Defaults to 30. + required: + - iso2 + - chokepointId + GetMultiSectorCostShockResponse: + type: object + properties: + iso2: + type: string + chokepointId: + type: string + closureDays: + type: integer + format: int32 + description: Server-clamped closure-window duration in days (1-365). + warRiskTier: + type: string + enum: + - WAR_RISK_TIER_UNSPECIFIED + - WAR_RISK_TIER_NORMAL + - WAR_RISK_TIER_ELEVATED + - WAR_RISK_TIER_HIGH + - WAR_RISK_TIER_CRITICAL + - WAR_RISK_TIER_WAR_ZONE + description: |- + * + War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification. + This is a FREE field (no PRO gate) — it exposes the existing server-internal + threatLevel from ChokepointConfig, making it available to clients for badges + and bypass corridor scoring. + sectors: + type: array + items: + $ref: '#/components/schemas/MultiSectorCostShock' + totalAddedCost: + type: number + format: double + description: Sum of total_cost_shock across all sectors. + fetchedAt: + type: string + unavailableReason: + type: string + description: Populated when no seeded import data is available for the country. + MultiSectorCostShock: + type: object + properties: + hs2: + type: string + description: HS2 chapter code (e.g. "27" mineral fuels, "85" electronics). + hs2Label: + type: string + description: Friendly chapter label (e.g. "Energy", "Electronics"). + importValueAnnual: + type: number + format: double + description: Total annual import value (USD) for this sector. + freightAddedPctPerTon: + type: number + format: double + description: Bypass-corridor freight uplift fraction (0.10 == +10% per ton). + warRiskPremiumBps: + type: integer + format: int32 + description: War-risk insurance premium (basis points) sourced from the chokepoint tier. + addedTransitDays: + type: integer + format: int32 + description: Bypass-corridor transit penalty (informational). + totalCostShockPerDay: + type: number + format: double + totalCostShock30Days: + type: number + format: double + totalCostShock90Days: + type: number + format: double + totalCostShock: + type: number + format: double + description: Cost for the requested closure_days window. + closureDays: + type: integer + format: int32 + description: Echoes the clamped closure duration used for total_cost_shock (1-365). GetSectorDependencyRequest: type: object properties: diff --git a/docs/architecture.mdx b/docs/architecture.mdx index b77379e84..4506fae57 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -271,7 +271,7 @@ World Monitor uses 60+ Vercel Edge Functions as a lightweight API layer, split i - **BIS Integration** — policy rates, real effective exchange rates, and credit-to-GDP ratios from the Bank for International Settlements, cached with 30-minute TTL - **WTO Trade Policy** — trade restrictions, tariff trends, bilateral trade flows, and SPS/TBT barriers from the World Trade Organization - **Supply Chain Intelligence** — maritime chokepoint disruption scores (cross-referencing NGA warnings + AIS data), FRED shipping freight indices with spike detection, and critical mineral supply concentration via Herfindahl-Hirschman Index analysis -- **Company Enrichment** — `/api/enrichment/company` aggregates GitHub organization data, inferred tech stack (derived from repository language distributions weighted by star count), SEC EDGAR public filings (10-K, 10-Q, 8-K), and Hacker News mentions into a single response. `/api/enrichment/signals` surfaces real-time company activity signals — funding events, hiring surges, executive changes, and expansion announcements — sourced from Hacker News and GitHub, each classified by signal type and scored for strength based on engagement, comment volume, and recency +- **Company Enrichment** — `IntelligenceService.GetCompanyEnrichment` aggregates GitHub organization data, inferred tech stack (derived from repository language distributions weighted by star count), SEC EDGAR public filings (10-K, 10-Q, 8-K), and Hacker News mentions into a single response. `IntelligenceService.ListCompanySignals` surfaces real-time company activity signals — funding events, hiring surges, executive changes, and expansion announcements — sourced from Hacker News and GitHub, each classified by signal type and scored for strength based on engagement, comment volume, and recency All edge functions include circuit breaker logic and return cached stale data when upstream APIs are unavailable, ensuring the dashboard never shows blank panels. diff --git a/docs/changelog.mdx b/docs/changelog.mdx index e129452bf..5fe596a74 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -21,9 +21,23 @@ All notable changes to World Monitor are documented here. Subscribe via [RSS](/c - R2 trace storage for forecast debugging with Cloudflare API upload (#1655) - `@ts-nocheck` injection in Makefile generate target for CI proto-freshness parity (#1637) +### Changed + +- **Sebuf API migration (#3207)** — scenario + supply-chain endpoints moved to the typed sebuf contract. RPC URLs now derive from method names; five renamed v1 URLs remain live as thin aliases so existing integrations keep working: + - `/api/scenario/v1/run` → `/api/scenario/v1/run-scenario` + - `/api/scenario/v1/status` → `/api/scenario/v1/get-scenario-status` + - `/api/scenario/v1/templates` → `/api/scenario/v1/list-scenario-templates` + - `/api/supply-chain/v1/country-products` → `/api/supply-chain/v1/get-country-products` + - `/api/supply-chain/v1/multi-sector-cost-shock` → `/api/supply-chain/v1/get-multi-sector-cost-shock` + + Aliases retire at the next v1→v2 break (tracked in [#3282](https://github.com/koala73/worldmonitor/issues/3282)). + +- `POST /api/scenario/v1/run-scenario` now returns `200 OK` instead of the pre-migration `202 Accepted`. sebuf's HTTP annotations don't carry per-RPC status codes. Branch on response body `status === "pending"` instead of `response.status === 202`. `statusUrl` field is preserved. + ### Security - **CDN cache bypass closed**: `CDN-Cache-Control` header now only emitted for trusted origins (worldmonitor.app, Vercel previews, Tauri). No-origin server-side requests always invoke the edge function so `validateApiKey` runs, preventing a cached 200 from being served to external scrapers. +- **Shipping v2 webhook tenant isolation (#3242)**: `POST /api/v2/shipping/webhooks` and `GET /api/v2/shipping/webhooks` now enforce `validateApiKey(req, { forceKey: true })`, matching the sibling `[subscriberId]{,/[action]}` routes. Without this gate, a Clerk-authenticated pro user with no API key would collapse into a shared `'anon'` fingerprint bucket and see/overwrite webhooks owned by other `'anon'`-bucket tenants. ### Fixed diff --git a/docs/panels/sanctions-pressure.mdx b/docs/panels/sanctions-pressure.mdx index c91ba3ec5..b408e62e3 100644 --- a/docs/panels/sanctions-pressure.mdx +++ b/docs/panels/sanctions-pressure.mdx @@ -42,4 +42,4 @@ OFAC publishes list updates on an irregular cadence (daily-to-weekly). The panel ## API reference - [Sanctions service](/api/SanctionsService.openapi.yaml) — sanctions pressure + entity search RPCs. -- Related: `/api/sanctions-entity-search` endpoint (documented under [Proxies](/api-proxies)) for fuzzy SDN entity lookups. +- Related: `GET /api/sanctions/v1/lookup-entity?q=...` — fuzzy entity lookup against OpenSanctions (live) with OFAC local index as a fallback. diff --git a/docs/panels/supply-chain.mdx b/docs/panels/supply-chain.mdx index c630c808a..e976e9ef8 100644 --- a/docs/panels/supply-chain.mdx +++ b/docs/panels/supply-chain.mdx @@ -24,7 +24,7 @@ Panel id is `supply-chain`; canonical component is `src/components/SupplyChainPa ## Data sources -Reads the cached supply-chain dataset (composite stress, carrier list, minerals, scenario templates). The Scenario Engine triggers call `POST /api/scenario/v1/run` and poll `/status` — see [Scenarios API](/api-scenarios) for the HTTP contract. +Reads the cached supply-chain dataset (composite stress, carrier list, minerals, scenario templates). The Scenario Engine triggers call `POST /api/scenario/v1/run-scenario` and poll `GET /api/scenario/v1/get-scenario-status` — see [Scenarios API](/api-scenarios) for the HTTP contract. ## Refresh cadence diff --git a/docs/scenario-engine.mdx b/docs/scenario-engine.mdx index fd70b7e68..14ac9166b 100644 --- a/docs/scenario-engine.mdx +++ b/docs/scenario-engine.mdx @@ -45,9 +45,9 @@ The UI is state-driven, not modal — activating a scenario sets a `scenarioStat ## Tier & gating -Scenario Engine is **PRO**. Free users see the trigger buttons but are blocked at activation: a `scenario-engine` gate-hit event is logged and the map is not repainted. The `/api/scenario/v1/run` endpoint itself also enforces PRO at the edge (`api/scenario/v1/run.ts`). +Scenario Engine is **PRO**. Free users see the trigger buttons but are blocked at activation: a `scenario-engine` gate-hit event is logged and the map is not repainted. The `ScenarioService.RunScenario` handler also enforces PRO at the edge (`server/worldmonitor/scenario/v1/run-scenario.ts`). -Rate limits on the API side — 10 jobs / minute / user, with a global queue cap at 100 in-flight — are documented in [Scenarios API](/api-scenarios#run-a-scenario). +Rate limits on the API side — 10 jobs / minute / IP, with a global queue cap at 100 in-flight — are documented in [Scenarios API](/api-scenarios#run-a-scenario). ## Run it yourself @@ -59,7 +59,7 @@ The workflow is inherently async — the edge function enqueues a job, a Railway 4. When the result lands, the map repaints, and a scenario banner is prepended to the panel. The banner always shows: a ⚠ icon, the scenario name, the top 5 impacted countries with per-country impact %, and a **×** dismiss control. When the scenario's result payload includes template parameters (duration, disruption %, cost-shock multiplier), the banner additionally renders a chip row (e.g. `14d · +110% cost`) and a tagline line such as *"Simulating 14d / 100% closure / +110% cost on 1 chokepoint. Chokepoint card below shows projected score; map highlights disrupted routes."* The affected chokepoints themselves are highlighted on the map and on the chokepoint cards rather than listed by name in the banner. 5. Click the **×** dismiss control on the banner (aria-label: "Dismiss scenario") to clear the scenario state — the map repaints to its baseline and the panel re-renders without the projected score and red-border callouts. -For scripted use, see [`POST /api/scenario/v1/run`](/api-scenarios#run-a-scenario) — enqueue, then poll `/status` until the response has a terminal status (`"done"` on success, `"failed"` on error). Non-terminal states are `"pending"` (queued) and `"processing"` (worker started); both can persist for several seconds. See the [status lifecycle table](/api-scenarios#poll-job-status) for the full contract. +For scripted use, see [`POST /api/scenario/v1/run-scenario`](/api-scenarios#run-a-scenario) — enqueue, then poll `GET /api/scenario/v1/get-scenario-status` until the response has a terminal status (`"done"` on success, `"failed"` on error). Non-terminal states are `"pending"` (queued) and `"processing"` (worker started); both can persist for several seconds. See the [status lifecycle table](/api-scenarios#poll-job-status) for the full contract. ## Data behind Scenario Engine diff --git a/docs/usage-errors.mdx b/docs/usage-errors.mdx index 54ee718fb..9a1beb352 100644 --- a/docs/usage-errors.mdx +++ b/docs/usage-errors.mdx @@ -28,7 +28,7 @@ OAuth endpoints follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rf | Code | Meaning | Retry? | |------|---------|--------| | `200` | OK | — | -| `202` | Accepted — job enqueued (e.g. `scenario/v1/run`). Poll `statusUrl`. | — | +| `202` | Accepted — job enqueued. Poll for terminal status. | — | | `304` | Not Modified (conditional cache hit) | — | | `400` | Bad request — validation error | No — fix input | | `401` | Missing / invalid auth | No — fix auth | @@ -55,13 +55,13 @@ OAuth endpoints follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rf | `Rate limit exceeded` | 429 fired; honor `Retry-After`. | | `Service temporarily unavailable` | Upstash or another hard dependency missing at request time. | | `service_unavailable` | Signing secret / required env not configured. | -| `Failed to enqueue scenario job` | Redis pipeline failure on `/api/scenario/v1/run`. | +| `Failed to enqueue scenario job` | Redis pipeline failure on `/api/scenario/v1/run-scenario`. | ## Retry strategy **Idempotent reads** (`GET`): retry 429/5xx with exponential backoff (1s, 2s, 4s, cap 30s, 5 attempts). Most GET responses are cached at the edge, so the retry usually goes faster. -**Writes**: never auto-retry 4xx. For 5xx on writes, inspect: `POST /api/brief/share-url` and `POST /api/v2/shipping/webhooks` are idempotent; `POST /api/scenario/v1/run` is **not** — it enqueues a new job on each call. Retrying a 5xx on `run` may double-charge the rate-limit counter. +**Writes**: never auto-retry 4xx. For 5xx on writes, inspect: `POST /api/brief/share-url` and `POST /api/v2/shipping/webhooks` are idempotent; `POST /api/scenario/v1/run-scenario` is **not** — it enqueues a new job on each call. Retrying a 5xx on `run-scenario` may double-charge the rate-limit counter. **MCP**: the server returns tool errors in the JSON-RPC result with `isError: true` and a text explanation — those are not HTTP errors. Handle them at the tool-call layer. diff --git a/docs/usage-rate-limits.mdx b/docs/usage-rate-limits.mdx index 0750255b5..f1f9c7457 100644 --- a/docs/usage-rate-limits.mdx +++ b/docs/usage-rate-limits.mdx @@ -37,10 +37,10 @@ Exceeding any of these during the OAuth flow will cause the MCP client to fail t | Endpoint | Limit | Window | Scope | |----------|-------|--------|-------| -| `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/scenario/v1/run-scenario` | 10 | 60 s | Per IP | +| `POST /api/scenario/v1/run-scenario` (queue depth) | 100 in-flight | — | Global | +| `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. @@ -74,5 +74,5 @@ Content-Type: application/json - Webhook callback URLs must be HTTPS (except localhost). - `api/download` file sizes capped at ~50 MB per request. -- `POST /api/scenario/v1/run` globally pauses new jobs when the pending queue exceeds **100** — returns 429 with `Retry-After: 30`. +- `POST /api/scenario/v1/run-scenario` globally pauses new jobs when the pending queue exceeds **100** — returns 429. - `api/v2/shipping/webhooks` TTL is **30 days** — re-register to extend. diff --git a/package.json b/package.json index 19d47f49e..5601ca0cc 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "lint": "biome lint ./src ./server ./api ./tests ./e2e ./scripts ./middleware.ts", "lint:fix": "biome check ./src ./server ./api ./tests ./e2e ./scripts ./middleware.ts --fix", "lint:boundaries": "node scripts/lint-boundaries.mjs", + "lint:api-contract": "node scripts/enforce-sebuf-api-contract.mjs", + "lint:rate-limit-policies": "node scripts/enforce-rate-limit-policies.mjs", "lint:unicode": "node scripts/check-unicode-safety.mjs", "lint:unicode:staged": "node scripts/check-unicode-safety.mjs --staged", "lint:md": "markdownlint-cli2 '**/*.md' '!**/node_modules/**' '!.agent/**' '!.agents/**' '!.claude/**' '!.factory/**' '!.windsurf/**' '!skills/**' '!docs/internal/**' '!docs/Docs_To_Review/**' '!todos/**' '!docs/plans/**'", diff --git a/pro-test/src/App.tsx b/pro-test/src/App.tsx index a3528d67a..538e612a7 100644 --- a/pro-test/src/App.tsx +++ b/pro-test/src/App.tsx @@ -1097,7 +1097,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({ @@ -1115,7 +1115,7 @@ const EnterprisePage = () => ( if (!res.ok) { const data = await res.json().catch(() => ({})); if (res.status === 422 && errorEl) { - errorEl.textContent = data.error || t('enterpriseShowcase.workEmailRequired'); + errorEl.textContent = data.message || data.error || t('enterpriseShowcase.workEmailRequired'); errorEl.classList.remove('hidden'); btn.textContent = origText; btn.disabled = false; diff --git a/proto/worldmonitor/leads/v1/register_interest.proto b/proto/worldmonitor/leads/v1/register_interest.proto new file mode 100644 index 000000000..f1ed1690a --- /dev/null +++ b/proto/worldmonitor/leads/v1/register_interest.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package worldmonitor.leads.v1; + +// RegisterInterestRequest carries a Pro-waitlist signup. +message RegisterInterestRequest { + string email = 1; + string source = 2; + string app_version = 3; + string referred_by = 4; + // Honeypot — bots auto-fill this hidden field; real submissions leave it empty. + string website = 5; + // Cloudflare Turnstile token. Desktop sources bypass Turnstile; see handler. + string turnstile_token = 6; +} + +// RegisterInterestResponse mirrors the Convex registerInterest:register return shape. +message RegisterInterestResponse { + // "registered" for a new signup; "already_registered" for a returning email. + string status = 1; + // Stable referral code for this email. + string referral_code = 2; + // Number of signups credited to this email. + int32 referral_count = 3; + // Waitlist position at registration time. Present only when status == "registered". + int32 position = 4; + // True when the email is on the suppression list (prior bounce) and no confirmation was sent. + bool email_suppressed = 5; +} diff --git a/proto/worldmonitor/leads/v1/service.proto b/proto/worldmonitor/leads/v1/service.proto new file mode 100644 index 000000000..56b19c7bf --- /dev/null +++ b/proto/worldmonitor/leads/v1/service.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.leads.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/leads/v1/register_interest.proto"; +import "worldmonitor/leads/v1/submit_contact.proto"; + +// LeadsService handles public-facing lead capture: enterprise contact and Pro-waitlist signups. +service LeadsService { + option (sebuf.http.service_config) = {base_path: "/api/leads/v1"}; + + // SubmitContact stores an enterprise contact submission in Convex and emails ops. + rpc SubmitContact(SubmitContactRequest) returns (SubmitContactResponse) { + option (sebuf.http.config) = {path: "/submit-contact", method: HTTP_METHOD_POST}; + } + + // RegisterInterest adds an email to the Pro waitlist and sends a confirmation email. + rpc RegisterInterest(RegisterInterestRequest) returns (RegisterInterestResponse) { + option (sebuf.http.config) = {path: "/register-interest", method: HTTP_METHOD_POST}; + } +} diff --git a/proto/worldmonitor/leads/v1/submit_contact.proto b/proto/worldmonitor/leads/v1/submit_contact.proto new file mode 100644 index 000000000..bd7a22655 --- /dev/null +++ b/proto/worldmonitor/leads/v1/submit_contact.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.leads.v1; + +// SubmitContactRequest carries an enterprise contact form submission. +message SubmitContactRequest { + string email = 1; + string name = 2; + string organization = 3; + string phone = 4; + string message = 5; + string source = 6; + // Honeypot — bots auto-fill this hidden field; real submissions leave it empty. + string website = 7; + // Cloudflare Turnstile token proving the submitter is human. + string turnstile_token = 8; +} + +// SubmitContactResponse reports the outcome of storing the lead and notifying ops. +message SubmitContactResponse { + // Always "sent" on success. + string status = 1; + // True when the Resend notification to ops was delivered. + bool email_sent = 2; +} diff --git a/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto b/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto index 99d2b9cb6..2441613ff 100644 --- a/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto +++ b/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto @@ -15,6 +15,10 @@ message GetVesselSnapshotRequest { double sw_lat = 3 [(sebuf.http.query) = { name: "sw_lat" }]; // South-west corner longitude of bounding box. double sw_lon = 4 [(sebuf.http.query) = { name: "sw_lon" }]; + // When true, populate VesselSnapshot.candidate_reports with per-vessel + // position reports. Clients with no position callbacks should leave this + // false to keep responses small. + bool include_candidates = 5 [(sebuf.http.query) = { name: "include_candidates" }]; } // GetVesselSnapshotResponse contains the vessel traffic snapshot. diff --git a/proto/worldmonitor/maritime/v1/vessel_snapshot.proto b/proto/worldmonitor/maritime/v1/vessel_snapshot.proto index 78ada630b..44b64545b 100644 --- a/proto/worldmonitor/maritime/v1/vessel_snapshot.proto +++ b/proto/worldmonitor/maritime/v1/vessel_snapshot.proto @@ -14,6 +14,47 @@ message VesselSnapshot { repeated AisDensityZone density_zones = 2; // Detected AIS disruptions. repeated AisDisruption disruptions = 3; + // Monotonic sequence number from the relay. Clients use this to detect stale + // responses during polling. + int32 sequence = 4; + // Relay health status. + AisSnapshotStatus status = 5; + // Recent position reports for individual vessels. Only populated when the + // request sets include_candidates=true — empty otherwise. + repeated SnapshotCandidateReport candidate_reports = 6; +} + +// AisSnapshotStatus reports relay health at the time of the snapshot. +message AisSnapshotStatus { + // Whether the relay WebSocket is connected to the AIS provider. + bool connected = 1; + // Number of vessels currently tracked by the relay. + int32 vessels = 2; + // Total AIS messages processed in the current session. + int32 messages = 3; +} + +// SnapshotCandidateReport is a per-vessel position report attached to a +// snapshot. Used to drive the client-side position callback system. +message SnapshotCandidateReport { + // Maritime Mobile Service Identity. + string mmsi = 1; + // Vessel name (may be empty if unknown). + string name = 2; + // Latitude in decimal degrees. + double lat = 3; + // Longitude in decimal degrees. + double lon = 4; + // AIS ship type code (0 if unknown). + int32 ship_type = 5; + // Heading in degrees (0-359, or 511 for unavailable). + int32 heading = 6; + // Speed over ground in knots. + double speed = 7; + // Course over ground in degrees. + int32 course = 8; + // Report timestamp, as Unix epoch milliseconds. + int64 timestamp = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; } // AisDensityZone represents a zone of concentrated vessel traffic. diff --git a/proto/worldmonitor/military/v1/military_flight.proto b/proto/worldmonitor/military/v1/military_flight.proto index 1c608e228..4bcd72f25 100644 --- a/proto/worldmonitor/military/v1/military_flight.proto +++ b/proto/worldmonitor/military/v1/military_flight.proto @@ -15,7 +15,10 @@ message MilitaryFlight { ]; // Aircraft callsign. string callsign = 2; - // ICAO 24-bit hex address. + // ICAO 24-bit hex address. Canonical form is UPPERCASE — seeders and + // handlers must uppercase before writing so hex-based lookups + // (src/services/military-flights.ts:getFlightByHex) match regardless of + // upstream source casing. string hex_code = 3; // Aircraft registration number. string registration = 4; diff --git a/proto/worldmonitor/scenario/v1/get_scenario_status.proto b/proto/worldmonitor/scenario/v1/get_scenario_status.proto new file mode 100644 index 000000000..c83a21173 --- /dev/null +++ b/proto/worldmonitor/scenario/v1/get_scenario_status.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package worldmonitor.scenario.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// ScenarioImpactCountry carries a single country's scenario impact score. +message ScenarioImpactCountry { + // 2-letter ISO country code. + string iso2 = 1; + // Raw weighted impact value aggregated across the country's exposed HS2 + // chapters. Relative-only — not a currency amount. + double total_impact = 2; + // Impact as a 0-100 share of the worst-hit country. + int32 impact_pct = 3; +} + +// ScenarioResultTemplate carries template parameters echoed into the worker's +// computed result so clients can render them without re-looking up the +// template registry. +message ScenarioResultTemplate { + // Display name (worker derives this from affected_chokepoint_ids; may be + // `tariff_shock` for tariff-type scenarios). + string name = 1; + // 0-100 percent of chokepoint capacity blocked. + int32 disruption_pct = 2; + // Estimated duration of disruption in days. + int32 duration_days = 3; + // Freight cost multiplier applied on top of bypass corridor costs. + double cost_shock_multiplier = 4; +} + +// ScenarioResult is the computed payload the scenario-worker writes back +// under the `scenario-result:{job_id}` Redis key. Populated only when +// GetScenarioStatusResponse.status == "done". +message ScenarioResult { + // Chokepoint ids disrupted by this scenario. + repeated string affected_chokepoint_ids = 1; + // Top 20 countries by aggregated impact, sorted desc by total_impact. + repeated ScenarioImpactCountry top_impact_countries = 2; + // Template parameters echoed from the registry. + ScenarioResultTemplate template = 3; +} + +// GetScenarioStatusRequest polls the worker result for an enqueued job id. +message GetScenarioStatusRequest { + // Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal + // guarded by JOB_ID_RE in the handler. + string job_id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.pattern = "^scenario:[0-9]{13}:[a-z0-9]{8}$", + (sebuf.http.query) = {name: "jobId"} + ]; +} + +// GetScenarioStatusResponse reflects the worker's lifecycle state. +// "pending" — no key yet (job still queued or very-recent enqueue). +// "processing" — worker has claimed the job but hasn't completed compute. +// "done" — compute succeeded; `result` is populated. +// "failed" — compute errored; `error` is populated. +message GetScenarioStatusResponse { + string status = 1; + // Populated only when status == "done". + ScenarioResult result = 2; + // Populated only when status == "failed". + string error = 3; +} diff --git a/proto/worldmonitor/scenario/v1/list_scenario_templates.proto b/proto/worldmonitor/scenario/v1/list_scenario_templates.proto new file mode 100644 index 000000000..5b727cce7 --- /dev/null +++ b/proto/worldmonitor/scenario/v1/list_scenario_templates.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package worldmonitor.scenario.v1; + +// ScenarioTemplate mirrors the catalog shape served by +// GET /api/scenario/v1/list-scenario-templates. The authoritative template +// registry lives in server/worldmonitor/supply-chain/v1/scenario-templates.ts. +message ScenarioTemplate { + string id = 1; + string name = 2; + // Chokepoint ids this scenario disrupts. Empty for tariff-shock scenarios + // that have no physical chokepoint closure. + repeated string affected_chokepoint_ids = 3; + // 0-100 percent of chokepoint capacity blocked. + int32 disruption_pct = 4; + // Estimated duration of disruption in days. + int32 duration_days = 5; + // HS2 chapter codes affected. Empty means ALL sectors are affected. + repeated string affected_hs2 = 6; + // Freight cost multiplier applied on top of bypass corridor costs. + double cost_shock_multiplier = 7; +} + +message ListScenarioTemplatesRequest {} + +message ListScenarioTemplatesResponse { + repeated ScenarioTemplate templates = 1; +} diff --git a/proto/worldmonitor/scenario/v1/run_scenario.proto b/proto/worldmonitor/scenario/v1/run_scenario.proto new file mode 100644 index 000000000..58e99371b --- /dev/null +++ b/proto/worldmonitor/scenario/v1/run_scenario.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package worldmonitor.scenario.v1; + +import "buf/validate/validate.proto"; + +// RunScenarioRequest enqueues a scenario job on the scenario-queue:pending +// Upstash list for the async scenario-worker to pick up. +message RunScenarioRequest { + // Scenario template id — must match an entry in SCENARIO_TEMPLATES. + string scenario_id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 128 + ]; + // Optional 2-letter ISO country code to scope the impact computation. + // When absent, the worker computes for all countries with seeded exposure. + string iso2 = 2 [ + (buf.validate.field).string.pattern = "^([A-Z]{2})?$" + ]; +} + +// RunScenarioResponse carries the enqueued job id. Clients poll +// GetScenarioStatus with this id until status != "pending". +// +// NOTE: the legacy (pre-sebuf) endpoint returned HTTP 202 Accepted on +// enqueue; the sebuf-generated server emits 200 OK for all successful +// responses (no per-RPC status-code configuration is available in the +// current sebuf HTTP annotations). The 202 → 200 shift on a same-version +// (v1 → v1) migration is called out in docs/api-scenarios.mdx and the +// OpenAPI bundle; external consumers keying off `response.status === 202` +// need to branch on response body shape instead. +message RunScenarioResponse { + // Generated job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. + string job_id = 1; + // Always "pending" at enqueue time. + string status = 2; + // Convenience URL the caller can use to poll this job's status. + // Server-computed as `/api/scenario/v1/get-scenario-status?jobId=`. + // Restored after the v1 → v1 sebuf migration because external callers + // may key off this field. + string status_url = 3; +} diff --git a/proto/worldmonitor/scenario/v1/service.proto b/proto/worldmonitor/scenario/v1/service.proto new file mode 100644 index 000000000..2692b6300 --- /dev/null +++ b/proto/worldmonitor/scenario/v1/service.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package worldmonitor.scenario.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/scenario/v1/run_scenario.proto"; +import "worldmonitor/scenario/v1/get_scenario_status.proto"; +import "worldmonitor/scenario/v1/list_scenario_templates.proto"; + +// ScenarioService exposes the scenario engine: enqueue a scenario job, +// poll its status, and list the template catalog. +service ScenarioService { + option (sebuf.http.service_config) = {base_path: "/api/scenario/v1"}; + + // RunScenario enqueues a scenario job on scenario-queue:pending. PRO-gated. + // The scenario-worker (scripts/scenario-worker.mjs) pulls jobs off the + // queue via BLMOVE and writes results under scenario-result:{job_id}. + rpc RunScenario(RunScenarioRequest) returns (RunScenarioResponse) { + option (sebuf.http.config) = {path: "/run-scenario", method: HTTP_METHOD_POST}; + } + + // GetScenarioStatus polls a single job's result. PRO-gated. + // Returns status="pending" when no result key exists, mirroring the + // worker's lifecycle state once the key is written. + rpc GetScenarioStatus(GetScenarioStatusRequest) returns (GetScenarioStatusResponse) { + option (sebuf.http.config) = {path: "/get-scenario-status", method: HTTP_METHOD_GET}; + } + + // ListScenarioTemplates returns the catalog of pre-defined scenarios. + // Not PRO-gated — used by documented public API consumers. + rpc ListScenarioTemplates(ListScenarioTemplatesRequest) returns (ListScenarioTemplatesResponse) { + option (sebuf.http.config) = {path: "/list-scenario-templates", method: HTTP_METHOD_GET}; + } +} diff --git a/proto/worldmonitor/shipping/v2/list_webhooks.proto b/proto/worldmonitor/shipping/v2/list_webhooks.proto new file mode 100644 index 000000000..d2ebf59cb --- /dev/null +++ b/proto/worldmonitor/shipping/v2/list_webhooks.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.shipping.v2; + +// ListWebhooksRequest has no fields — the owner is derived from the caller's +// API-key fingerprint (SHA-256 of X-WorldMonitor-Key). +message ListWebhooksRequest {} + +// Single webhook record in the list response. `secret` is intentionally +// omitted; use rotate-secret to obtain a new one. +message WebhookSummary { + string subscriber_id = 1; + string callback_url = 2; + repeated string chokepoint_ids = 3; + int32 alert_threshold = 4; + // ISO-8601 timestamp of registration. + string created_at = 5; + bool active = 6; +} + +// ListWebhooksResponse wire shape preserved exactly: the `webhooks` field +// name and the omission of `secret` are part of the partner contract. +message ListWebhooksResponse { + repeated WebhookSummary webhooks = 1; +} diff --git a/proto/worldmonitor/shipping/v2/register_webhook.proto b/proto/worldmonitor/shipping/v2/register_webhook.proto new file mode 100644 index 000000000..0e86bd6f8 --- /dev/null +++ b/proto/worldmonitor/shipping/v2/register_webhook.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package worldmonitor.shipping.v2; + +import "buf/validate/validate.proto"; + +// RegisterWebhookRequest creates a new chokepoint-disruption webhook +// subscription. Wire shape is byte-compatible with the pre-migration +// legacy POST body. +message RegisterWebhookRequest { + // HTTPS callback URL. Must not resolve to a private/loopback address at + // registration time (SSRF guard). The delivery worker re-validates the + // resolved IP before each send to mitigate DNS rebinding. + string callback_url = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 8, + (buf.validate.field).string.max_len = 2048 + ]; + // Zero or more chokepoint IDs to subscribe to. Empty list subscribes to + // the entire CHOKEPOINT_REGISTRY. Unknown IDs fail with 400. + repeated string chokepoint_ids = 2; + // Disruption-score threshold for delivery, 0-100. Default 50. + int32 alert_threshold = 3 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = 100 + ]; +} + +// RegisterWebhookResponse wire shape preserved exactly — partners persist the +// `secret` because the server never returns it again except via rotate-secret. +message RegisterWebhookResponse { + // `wh_` prefix + 24 lowercase hex chars (12 random bytes). + string subscriber_id = 1; + // Raw 64-char lowercase hex secret (32 random bytes). No `whsec_` prefix. + string secret = 2; +} diff --git a/proto/worldmonitor/shipping/v2/route_intelligence.proto b/proto/worldmonitor/shipping/v2/route_intelligence.proto new file mode 100644 index 000000000..1ad9a1430 --- /dev/null +++ b/proto/worldmonitor/shipping/v2/route_intelligence.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package worldmonitor.shipping.v2; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// RouteIntelligenceRequest scopes a route-intelligence query by origin and +// destination country. Query-parameter names are preserved verbatim from the +// legacy partner contract (fromIso2/toIso2/cargoType/hs2 — camelCase). +message RouteIntelligenceRequest { + // Origin country, ISO-3166-1 alpha-2 uppercase. + string from_iso2 = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.pattern = "^[A-Z]{2}$", + (sebuf.http.query) = { name: "fromIso2" } + ]; + // Destination country, ISO-3166-1 alpha-2 uppercase. + string to_iso2 = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.pattern = "^[A-Z]{2}$", + (sebuf.http.query) = { name: "toIso2" } + ]; + // Cargo type — one of: container (default), tanker, bulk, roro. + // Empty string defers to the server default. Unknown values are coerced to + // "container" to preserve legacy behavior. + string cargo_type = 3 [(sebuf.http.query) = { name: "cargoType" }]; + // 2-digit HS commodity code (default "27" — mineral fuels). Non-digit + // characters are stripped server-side to match legacy behavior. + string hs2 = 4 [(sebuf.http.query) = { name: "hs2" }]; +} + +// Single chokepoint exposure for a route. +message ChokepointExposure { + string chokepoint_id = 1; + string chokepoint_name = 2; + int32 exposure_pct = 3; +} + +// Single bypass-corridor option around a disrupted chokepoint. +message BypassOption { + string id = 1; + string name = 2; + // Type of bypass (e.g., "maritime_detour", "land_corridor"). + string type = 3; + int32 added_transit_days = 4; + double added_cost_multiplier = 5; + // Enum-like string, e.g., "DISRUPTION_SCORE_60". + string activation_threshold = 6; +} + +// RouteIntelligenceResponse wire shape preserved byte-for-byte from the +// pre-migration JSON at docs/api-shipping-v2.mdx. `fetched_at` is intentionally +// a string (ISO-8601) rather than int64 epoch ms because partners depend on +// the ISO-8601 shape. +message RouteIntelligenceResponse { + string from_iso2 = 1; + string to_iso2 = 2; + string cargo_type = 3; + string hs2 = 4; + string primary_route_id = 5; + repeated ChokepointExposure chokepoint_exposures = 6; + repeated BypassOption bypass_options = 7; + // War-risk tier enum string, e.g., "WAR_RISK_TIER_NORMAL" or "WAR_RISK_TIER_ELEVATED". + string war_risk_tier = 8; + // Disruption score of the primary chokepoint, 0-100. + int32 disruption_score = 9; + // ISO-8601 timestamp of when the response was assembled. + string fetched_at = 10; +} diff --git a/proto/worldmonitor/shipping/v2/service.proto b/proto/worldmonitor/shipping/v2/service.proto new file mode 100644 index 000000000..3dc885d74 --- /dev/null +++ b/proto/worldmonitor/shipping/v2/service.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; + +package worldmonitor.shipping.v2; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/shipping/v2/route_intelligence.proto"; +import "worldmonitor/shipping/v2/register_webhook.proto"; +import "worldmonitor/shipping/v2/list_webhooks.proto"; + +// ShippingV2Service is the partner-facing (vendor) surface for chokepoint +// route intelligence and disruption-alert webhooks. PRO-gated, authenticated +// via X-WorldMonitor-Key (server-to-server; browser origins are NOT exempt). +// +// The base_path intentionally reverses the usual /api/{domain}/v{N} ordering +// because the existing partner contract is /api/v2/shipping/*. The proto does +// not model the path-parameter endpoints that also live on this surface +// (GET /webhooks/{id}, POST /webhooks/{id}/rotate-secret, POST /webhooks/{id}/reactivate); +// those remain on the legacy file layout until sebuf path-params are supported. +service ShippingV2Service { + option (sebuf.http.service_config) = {base_path: "/api/v2/shipping"}; + + // RouteIntelligence scores a country-pair trade route for chokepoint exposure + // and current disruption risk. Partner-facing; wire shape is byte-compatible + // with the pre-migration JSON response documented at docs/api-shipping-v2.mdx. + rpc RouteIntelligence(RouteIntelligenceRequest) returns (RouteIntelligenceResponse) { + option (sebuf.http.config) = {path: "/route-intelligence", method: HTTP_METHOD_GET}; + } + + // RegisterWebhook subscribes a callback URL to chokepoint disruption alerts. + // Returns the subscriberId and the raw HMAC secret — the secret is never + // returned again except via rotate-secret. + rpc RegisterWebhook(RegisterWebhookRequest) returns (RegisterWebhookResponse) { + option (sebuf.http.config) = {path: "/webhooks", method: HTTP_METHOD_POST}; + } + + // ListWebhooks returns the caller's registered webhooks filtered by the + // SHA-256 owner tag of the calling API key. The `secret` is intentionally + // omitted from the response; use rotate-secret to obtain a new one. + rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse) { + option (sebuf.http.config) = {path: "/webhooks", method: HTTP_METHOD_GET}; + } +} diff --git a/proto/worldmonitor/supply_chain/v1/get_country_products.proto b/proto/worldmonitor/supply_chain/v1/get_country_products.proto new file mode 100644 index 000000000..d6c667aa1 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_country_products.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; +package worldmonitor.supply_chain.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +message ProductExporter { + int32 partner_code = 1; + string partner_iso2 = 2; + double value = 3; + double share = 4; +} + +message CountryProduct { + string hs4 = 1; + string description = 2; + double total_value = 3; + repeated ProductExporter top_exporters = 4; + int32 year = 5; +} + +message GetCountryProductsRequest { + string iso2 = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$", + (sebuf.http.query) = {name: "iso2"} + ]; +} + +message GetCountryProductsResponse { + string iso2 = 1; + repeated CountryProduct products = 2; + // ISO timestamp from the seeded payload (empty when no data is cached). + string fetched_at = 3; +} diff --git a/proto/worldmonitor/supply_chain/v1/get_multi_sector_cost_shock.proto b/proto/worldmonitor/supply_chain/v1/get_multi_sector_cost_shock.proto new file mode 100644 index 000000000..aa3d3d21e --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_multi_sector_cost_shock.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; +package worldmonitor.supply_chain.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/supply_chain/v1/supply_chain_data.proto"; + +message MultiSectorCostShock { + // HS2 chapter code (e.g. "27" mineral fuels, "85" electronics). + string hs2 = 1; + // Friendly chapter label (e.g. "Energy", "Electronics"). + string hs2_label = 2; + // Total annual import value (USD) for this sector. + double import_value_annual = 3; + // Bypass-corridor freight uplift fraction (0.10 == +10% per ton). + double freight_added_pct_per_ton = 4; + // War-risk insurance premium (basis points) sourced from the chokepoint tier. + int32 war_risk_premium_bps = 5; + // Bypass-corridor transit penalty (informational). + int32 added_transit_days = 6; + double total_cost_shock_per_day = 7; + double total_cost_shock_30_days = 8; + double total_cost_shock_90_days = 9; + // Cost for the requested closure_days window. + double total_cost_shock = 10; + // Echoes the clamped closure duration used for total_cost_shock (1-365). + int32 closure_days = 11; +} + +message GetMultiSectorCostShockRequest { + string iso2 = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$", + (sebuf.http.query) = {name: "iso2"} + ]; + string chokepoint_id = 2 [ + (buf.validate.field).required = true, + (sebuf.http.query) = {name: "chokepointId"} + ]; + // Closure-window duration in days. Server clamps to [1, 365]. Defaults to 30. + int32 closure_days = 3 [(sebuf.http.query) = {name: "closureDays"}]; +} + +message GetMultiSectorCostShockResponse { + string iso2 = 1; + string chokepoint_id = 2; + // Server-clamped closure-window duration in days (1-365). + int32 closure_days = 3; + WarRiskTier war_risk_tier = 4; + // Per-sector shock entries (10 seeded HS2 codes), sorted by total_cost_shock_per_day desc. + repeated MultiSectorCostShock sectors = 5; + // Sum of total_cost_shock across all sectors. + double total_added_cost = 6; + string fetched_at = 7; + // Populated when no seeded import data is available for the country. + string unavailable_reason = 8; +} diff --git a/proto/worldmonitor/supply_chain/v1/service.proto b/proto/worldmonitor/supply_chain/v1/service.proto index c57a958d9..51f752c45 100644 --- a/proto/worldmonitor/supply_chain/v1/service.proto +++ b/proto/worldmonitor/supply_chain/v1/service.proto @@ -11,6 +11,8 @@ import "worldmonitor/supply_chain/v1/get_shipping_stress.proto"; import "worldmonitor/supply_chain/v1/get_country_chokepoint_index.proto"; import "worldmonitor/supply_chain/v1/get_bypass_options.proto"; import "worldmonitor/supply_chain/v1/get_country_cost_shock.proto"; +import "worldmonitor/supply_chain/v1/get_country_products.proto"; +import "worldmonitor/supply_chain/v1/get_multi_sector_cost_shock.proto"; import "worldmonitor/supply_chain/v1/get_sector_dependency.proto"; import "worldmonitor/supply_chain/v1/get_route_explorer_lane.proto"; import "worldmonitor/supply_chain/v1/get_route_impact.proto"; @@ -57,6 +59,17 @@ service SupplyChainService { option (sebuf.http.config) = {path: "/get-country-cost-shock", method: HTTP_METHOD_GET}; } + // GetCountryProducts returns the seeded bilateral-HS4 import basket for a country. PRO-gated. + rpc GetCountryProducts(GetCountryProductsRequest) returns (GetCountryProductsResponse) { + option (sebuf.http.config) = {path: "/get-country-products", method: HTTP_METHOD_GET}; + } + + // GetMultiSectorCostShock returns per-sector cost-shock estimates for a + // country+chokepoint+closure-window. PRO-gated. + rpc GetMultiSectorCostShock(GetMultiSectorCostShockRequest) returns (GetMultiSectorCostShockResponse) { + option (sebuf.http.config) = {path: "/get-multi-sector-cost-shock", method: HTTP_METHOD_GET}; + } + // GetSectorDependency returns dependency flags and risk profile for a country+HS2 sector. PRO-gated. rpc GetSectorDependency(GetSectorDependencyRequest) returns (GetSectorDependencyResponse) { option (sebuf.http.config) = {path: "/get-sector-dependency", method: HTTP_METHOD_GET}; diff --git a/public/pro/assets/index-BzWOWshY.js b/public/pro/assets/index-BzYxK1gb.js similarity index 98% rename from public/pro/assets/index-BzWOWshY.js rename to public/pro/assets/index-BzYxK1gb.js index 84e6d42ec..71ead8759 100644 --- a/public/pro/assets/index-BzWOWshY.js +++ b/public/pro/assets/index-BzYxK1gb.js @@ -245,4 +245,4 @@ Error generating stack: `+o.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const sO=[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]],rO=we("zap",sO),oO="modulepreload",lO=function(n){return"/pro/"+n},tx={},Ie=function(t,a,r){let l=Promise.resolve();if(a&&a.length>0){let f=function(p){return Promise.all(p.map(v=>Promise.resolve(v).then(x=>({status:"fulfilled",value:x}),x=>({status:"rejected",reason:x}))))};document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),m=(h==null?void 0:h.nonce)||(h==null?void 0:h.getAttribute("nonce"));l=f(a.map(p=>{if(p=lO(p),p in tx)return;tx[p]=!0;const v=p.endsWith(".css"),x=v?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${p}"]${x}`))return;const S=document.createElement("link");if(S.rel=v?"stylesheet":oO,v||(S.as="script"),S.crossOrigin="",S.href=p,m&&S.setAttribute("nonce",m),document.head.appendChild(S),v)return new Promise((w,T)=>{S.addEventListener("load",w),S.addEventListener("error",()=>T(new Error(`Unable to preload CSS for ${p}`)))})}))}function c(f){const h=new Event("vite:preloadError",{cancelable:!0});if(h.payload=f,window.dispatchEvent(h),!h.defaultPrevented)throw f}return l.then(f=>{for(const h of f||[])h.status==="rejected"&&c(h.reason);return t().catch(c)})};var Qf={};const re=n=>typeof n=="string",rr=()=>{let n,t;const a=new Promise((r,l)=>{n=r,t=l});return a.resolve=n,a.reject=t,a},nx=n=>n==null?"":""+n,cO=(n,t,a)=>{n.forEach(r=>{t[r]&&(a[r]=t[r])})},uO=/###/g,ix=n=>n&&n.indexOf("###")>-1?n.replace(uO,"."):n,ax=n=>!n||re(n),mr=(n,t,a)=>{const r=re(t)?t.split("."):t;let l=0;for(;l{const{obj:r,k:l}=mr(n,t,Object);if(r!==void 0||t.length===1){r[l]=a;return}let c=t[t.length-1],f=t.slice(0,t.length-1),h=mr(n,f,Object);for(;h.obj===void 0&&f.length;)c=`${f[f.length-1]}.${c}`,f=f.slice(0,f.length-1),h=mr(n,f,Object),h!=null&&h.obj&&typeof h.obj[`${h.k}.${c}`]<"u"&&(h.obj=void 0);h.obj[`${h.k}.${c}`]=a},fO=(n,t,a,r)=>{const{obj:l,k:c}=mr(n,t,Object);l[c]=l[c]||[],l[c].push(a)},kl=(n,t)=>{const{obj:a,k:r}=mr(n,t);if(a&&Object.prototype.hasOwnProperty.call(a,r))return a[r]},dO=(n,t,a)=>{const r=kl(n,a);return r!==void 0?r:kl(t,a)},fS=(n,t,a)=>{for(const r in t)r!=="__proto__"&&r!=="constructor"&&(r in n?re(n[r])||n[r]instanceof String||re(t[r])||t[r]instanceof String?a&&(n[r]=t[r]):fS(n[r],t[r],a):n[r]=t[r]);return n},Ii=n=>n.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&");var hO={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};const mO=n=>re(n)?n.replace(/[&<>"'\/]/g,t=>hO[t]):n;class pO{constructor(t){this.capacity=t,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(t){const a=this.regExpMap.get(t);if(a!==void 0)return a;const r=new RegExp(t);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(t,r),this.regExpQueue.push(t),r}}const gO=[" ",",","?","!",";"],yO=new pO(20),vO=(n,t,a)=>{t=t||"",a=a||"";const r=gO.filter(f=>t.indexOf(f)<0&&a.indexOf(f)<0);if(r.length===0)return!0;const l=yO.getRegExp(`(${r.map(f=>f==="?"?"\\?":f).join("|")})`);let c=!l.test(n);if(!c){const f=n.indexOf(a);f>0&&!l.test(n.substring(0,f))&&(c=!0)}return c},Pd=(n,t,a=".")=>{if(!n)return;if(n[t])return Object.prototype.hasOwnProperty.call(n,t)?n[t]:void 0;const r=t.split(a);let l=n;for(let c=0;c-1&&mn==null?void 0:n.replace(/_/g,"-"),xO={type:"logger",log(n){this.output("log",n)},warn(n){this.output("warn",n)},error(n){this.output("error",n)},output(n,t){var a,r;(r=(a=console==null?void 0:console[n])==null?void 0:a.apply)==null||r.call(a,console,t)}};class Ll{constructor(t,a={}){this.init(t,a)}init(t,a={}){this.prefix=a.prefix||"i18next:",this.logger=t||xO,this.options=a,this.debug=a.debug}log(...t){return this.forward(t,"log","",!0)}warn(...t){return this.forward(t,"warn","",!0)}error(...t){return this.forward(t,"error","")}deprecate(...t){return this.forward(t,"warn","WARNING DEPRECATED: ",!0)}forward(t,a,r,l){return l&&!this.debug?null:(re(t[0])&&(t[0]=`${r}${this.prefix} ${t[0]}`),this.logger[a](t))}create(t){return new Ll(this.logger,{prefix:`${this.prefix}:${t}:`,...this.options})}clone(t){return t=t||this.options,t.prefix=t.prefix||this.prefix,new Ll(this.logger,t)}}var mn=new Ll;class Wl{constructor(){this.observers={}}on(t,a){return t.split(" ").forEach(r=>{this.observers[r]||(this.observers[r]=new Map);const l=this.observers[r].get(a)||0;this.observers[r].set(a,l+1)}),this}off(t,a){if(this.observers[t]){if(!a){delete this.observers[t];return}this.observers[t].delete(a)}}emit(t,...a){this.observers[t]&&Array.from(this.observers[t].entries()).forEach(([l,c])=>{for(let f=0;f{for(let f=0;f-1&&this.options.ns.splice(a,1)}getResource(t,a,r,l={}){var p,v;const c=l.keySeparator!==void 0?l.keySeparator:this.options.keySeparator,f=l.ignoreJSONStructure!==void 0?l.ignoreJSONStructure:this.options.ignoreJSONStructure;let h;t.indexOf(".")>-1?h=t.split("."):(h=[t,a],r&&(Array.isArray(r)?h.push(...r):re(r)&&c?h.push(...r.split(c)):h.push(r)));const m=kl(this.data,h);return!m&&!a&&!r&&t.indexOf(".")>-1&&(t=h[0],a=h[1],r=h.slice(2).join(".")),m||!f||!re(r)?m:Pd((v=(p=this.data)==null?void 0:p[t])==null?void 0:v[a],r,c)}addResource(t,a,r,l,c={silent:!1}){const f=c.keySeparator!==void 0?c.keySeparator:this.options.keySeparator;let h=[t,a];r&&(h=h.concat(f?r.split(f):r)),t.indexOf(".")>-1&&(h=t.split("."),l=a,a=h[1]),this.addNamespaces(a),sx(this.data,h,l),c.silent||this.emit("added",t,a,r,l)}addResources(t,a,r,l={silent:!1}){for(const c in r)(re(r[c])||Array.isArray(r[c]))&&this.addResource(t,a,c,r[c],{silent:!0});l.silent||this.emit("added",t,a,r)}addResourceBundle(t,a,r,l,c,f={silent:!1,skipCopy:!1}){let h=[t,a];t.indexOf(".")>-1&&(h=t.split("."),l=r,r=a,a=h[1]),this.addNamespaces(a);let m=kl(this.data,h)||{};f.skipCopy||(r=JSON.parse(JSON.stringify(r))),l?fS(m,r,c):m={...m,...r},sx(this.data,h,m),f.silent||this.emit("added",t,a,r)}removeResourceBundle(t,a){this.hasResourceBundle(t,a)&&delete this.data[t][a],this.removeNamespaces(a),this.emit("removed",t,a)}hasResourceBundle(t,a){return this.getResource(t,a)!==void 0}getResourceBundle(t,a){return a||(a=this.options.defaultNS),this.getResource(t,a)}getDataByLanguage(t){return this.data[t]}hasLanguageSomeTranslations(t){const a=this.getDataByLanguage(t);return!!(a&&Object.keys(a)||[]).find(l=>a[l]&&Object.keys(a[l]).length>0)}toJSON(){return this.data}}var dS={processors:{},addPostProcessor(n){this.processors[n.name]=n},handle(n,t,a,r,l){return n.forEach(c=>{var f;t=((f=this.processors[c])==null?void 0:f.process(t,a,r,l))??t}),t}};const hS=Symbol("i18next/PATH_KEY");function bO(){const n=[],t=Object.create(null);let a;return t.get=(r,l)=>{var c;return(c=a==null?void 0:a.revoke)==null||c.call(a),l===hS?n:(n.push(l),a=Proxy.revocable(r,t),a.proxy)},Proxy.revocable(Object.create(null),t).proxy}function Xa(n,t){const{[hS]:a}=n(bO()),r=(t==null?void 0:t.keySeparator)??".",l=(t==null?void 0:t.nsSeparator)??":";if(a.length>1&&l){const c=t==null?void 0:t.ns,f=Array.isArray(c)?c:null;if(f&&f.length>1&&f.slice(1).includes(a[0]))return`${a[0]}${l}${a.slice(1).join(r)}`}return a.join(r)}const ox={},Zf=n=>!re(n)&&typeof n!="boolean"&&typeof n!="number";class zl extends Wl{constructor(t,a={}){super(),cO(["resourceStore","languageUtils","pluralResolver","interpolator","backendConnector","i18nFormat","utils"],t,this),this.options=a,this.options.keySeparator===void 0&&(this.options.keySeparator="."),this.logger=mn.create("translator")}changeLanguage(t){t&&(this.language=t)}exists(t,a={interpolation:{}}){const r={...a};if(t==null)return!1;const l=this.resolve(t,r);if((l==null?void 0:l.res)===void 0)return!1;const c=Zf(l.res);return!(r.returnObjects===!1&&c)}extractFromKey(t,a){let r=a.nsSeparator!==void 0?a.nsSeparator:this.options.nsSeparator;r===void 0&&(r=":");const l=a.keySeparator!==void 0?a.keySeparator:this.options.keySeparator;let c=a.ns||this.options.defaultNS||[];const f=r&&t.indexOf(r)>-1,h=!this.options.userDefinedKeySeparator&&!a.keySeparator&&!this.options.userDefinedNsSeparator&&!a.nsSeparator&&!vO(t,r,l);if(f&&!h){const m=t.match(this.interpolator.nestingRegexp);if(m&&m.length>0)return{key:t,namespaces:re(c)?[c]:c};const p=t.split(r);(r!==l||r===l&&this.options.ns.indexOf(p[0])>-1)&&(c=p.shift()),t=p.join(l)}return{key:t,namespaces:re(c)?[c]:c}}translate(t,a,r){let l=typeof a=="object"?{...a}:a;if(typeof l!="object"&&this.options.overloadTranslationOptionHandler&&(l=this.options.overloadTranslationOptionHandler(arguments)),typeof l=="object"&&(l={...l}),l||(l={}),t==null)return"";typeof t=="function"&&(t=Xa(t,{...this.options,...l})),Array.isArray(t)||(t=[String(t)]),t=t.map(De=>typeof De=="function"?Xa(De,{...this.options,...l}):String(De));const c=l.returnDetails!==void 0?l.returnDetails:this.options.returnDetails,f=l.keySeparator!==void 0?l.keySeparator:this.options.keySeparator,{key:h,namespaces:m}=this.extractFromKey(t[t.length-1],l),p=m[m.length-1];let v=l.nsSeparator!==void 0?l.nsSeparator:this.options.nsSeparator;v===void 0&&(v=":");const x=l.lng||this.language,S=l.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if((x==null?void 0:x.toLowerCase())==="cimode")return S?c?{res:`${p}${v}${h}`,usedKey:h,exactUsedKey:h,usedLng:x,usedNS:p,usedParams:this.getUsedParamsDetails(l)}:`${p}${v}${h}`:c?{res:h,usedKey:h,exactUsedKey:h,usedLng:x,usedNS:p,usedParams:this.getUsedParamsDetails(l)}:h;const w=this.resolve(t,l);let T=w==null?void 0:w.res;const k=(w==null?void 0:w.usedKey)||h,z=(w==null?void 0:w.exactUsedKey)||h,B=["[object Number]","[object Function]","[object RegExp]"],q=l.joinArrays!==void 0?l.joinArrays:this.options.joinArrays,P=!this.i18nFormat||this.i18nFormat.handleAsObject,F=l.count!==void 0&&!re(l.count),X=zl.hasDefaultValue(l),he=F?this.pluralResolver.getSuffix(x,l.count,l):"",W=l.ordinal&&F?this.pluralResolver.getSuffix(x,l.count,{ordinal:!1}):"",te=F&&!l.ordinal&&l.count===0,ue=te&&l[`defaultValue${this.options.pluralSeparator}zero`]||l[`defaultValue${he}`]||l[`defaultValue${W}`]||l.defaultValue;let K=T;P&&!T&&X&&(K=ue);const Se=Zf(K),Ae=Object.prototype.toString.apply(K);if(P&&K&&Se&&B.indexOf(Ae)<0&&!(re(q)&&Array.isArray(K))){if(!l.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn("accessing an object - but returnObjects options is not enabled!");const De=this.options.returnedObjectHandler?this.options.returnedObjectHandler(k,K,{...l,ns:m}):`key '${h} (${this.language})' returned an object instead of string.`;return c?(w.res=De,w.usedParams=this.getUsedParamsDetails(l),w):De}if(f){const De=Array.isArray(K),_e=De?[]:{},Pe=De?z:k;for(const O in K)if(Object.prototype.hasOwnProperty.call(K,O)){const I=`${Pe}${f}${O}`;X&&!T?_e[O]=this.translate(I,{...l,defaultValue:Zf(ue)?ue[O]:void 0,joinArrays:!1,ns:m}):_e[O]=this.translate(I,{...l,joinArrays:!1,ns:m}),_e[O]===I&&(_e[O]=K[O])}T=_e}}else if(P&&re(q)&&Array.isArray(T))T=T.join(q),T&&(T=this.extendTranslation(T,t,l,r));else{let De=!1,_e=!1;!this.isValidLookup(T)&&X&&(De=!0,T=ue),this.isValidLookup(T)||(_e=!0,T=h);const O=(l.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&_e?void 0:T,I=X&&ue!==T&&this.options.updateMissing;if(_e||De||I){if(this.logger.log(I?"updateKey":"missingKey",x,p,h,I?ue:T),f){const A=this.resolve(h,{...l,keySeparator:!1});A&&A.res&&this.logger.warn("Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.")}let Y=[];const se=this.languageUtils.getFallbackCodes(this.options.fallbackLng,l.lng||this.language);if(this.options.saveMissingTo==="fallback"&&se&&se[0])for(let A=0;A{var oe;const $=X&&G!==T?G:O;this.options.missingKeyHandler?this.options.missingKeyHandler(A,p,V,$,I,l):(oe=this.backendConnector)!=null&&oe.saveMissing&&this.backendConnector.saveMissing(A,p,V,$,I,l),this.emit("missingKey",A,p,V,T)};this.options.saveMissing&&(this.options.saveMissingPlurals&&F?Y.forEach(A=>{const V=this.pluralResolver.getSuffixes(A,l);te&&l[`defaultValue${this.options.pluralSeparator}zero`]&&V.indexOf(`${this.options.pluralSeparator}zero`)<0&&V.push(`${this.options.pluralSeparator}zero`),V.forEach(G=>{me([A],h+G,l[`defaultValue${G}`]||ue)})}):me(Y,h,ue))}T=this.extendTranslation(T,t,l,w,r),_e&&T===h&&this.options.appendNamespaceToMissingKey&&(T=`${p}${v}${h}`),(_e||De)&&this.options.parseMissingKeyHandler&&(T=this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${p}${v}${h}`:h,De?T:void 0,l))}return c?(w.res=T,w.usedParams=this.getUsedParamsDetails(l),w):T}extendTranslation(t,a,r,l,c){var m,p;if((m=this.i18nFormat)!=null&&m.parse)t=this.i18nFormat.parse(t,{...this.options.interpolation.defaultVariables,...r},r.lng||this.language||l.usedLng,l.usedNS,l.usedKey,{resolved:l});else if(!r.skipInterpolation){r.interpolation&&this.interpolator.init({...r,interpolation:{...this.options.interpolation,...r.interpolation}});const v=re(t)&&(((p=r==null?void 0:r.interpolation)==null?void 0:p.skipOnVariables)!==void 0?r.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables);let x;if(v){const w=t.match(this.interpolator.nestingRegexp);x=w&&w.length}let S=r.replace&&!re(r.replace)?r.replace:r;if(this.options.interpolation.defaultVariables&&(S={...this.options.interpolation.defaultVariables,...S}),t=this.interpolator.interpolate(t,S,r.lng||this.language||l.usedLng,r),v){const w=t.match(this.interpolator.nestingRegexp),T=w&&w.length;x(c==null?void 0:c[0])===w[0]&&!r.context?(this.logger.warn(`It seems you are nesting recursively key: ${w[0]} in key: ${a[0]}`),null):this.translate(...w,a),r)),r.interpolation&&this.interpolator.reset()}const f=r.postProcess||this.options.postProcess,h=re(f)?[f]:f;return t!=null&&(h!=null&&h.length)&&r.applyPostProcessor!==!1&&(t=dS.handle(h,t,a,this.options&&this.options.postProcessPassResolved?{i18nResolved:{...l,usedParams:this.getUsedParamsDetails(r)},...r}:r,this)),t}resolve(t,a={}){let r,l,c,f,h;return re(t)&&(t=[t]),Array.isArray(t)&&(t=t.map(m=>typeof m=="function"?Xa(m,{...this.options,...a}):m)),t.forEach(m=>{if(this.isValidLookup(r))return;const p=this.extractFromKey(m,a),v=p.key;l=v;let x=p.namespaces;this.options.fallbackNS&&(x=x.concat(this.options.fallbackNS));const S=a.count!==void 0&&!re(a.count),w=S&&!a.ordinal&&a.count===0,T=a.context!==void 0&&(re(a.context)||typeof a.context=="number")&&a.context!=="",k=a.lngs?a.lngs:this.languageUtils.toResolveHierarchy(a.lng||this.language,a.fallbackLng);x.forEach(z=>{var B,q;this.isValidLookup(r)||(h=z,!ox[`${k[0]}-${z}`]&&((B=this.utils)!=null&&B.hasLoadedNamespace)&&!((q=this.utils)!=null&&q.hasLoadedNamespace(h))&&(ox[`${k[0]}-${z}`]=!0,this.logger.warn(`key "${l}" for languages "${k.join(", ")}" won't get resolved as namespace "${h}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!")),k.forEach(P=>{var he;if(this.isValidLookup(r))return;f=P;const F=[v];if((he=this.i18nFormat)!=null&&he.addLookupKeys)this.i18nFormat.addLookupKeys(F,v,P,z,a);else{let W;S&&(W=this.pluralResolver.getSuffix(P,a.count,a));const te=`${this.options.pluralSeparator}zero`,ue=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(S&&(a.ordinal&&W.indexOf(ue)===0&&F.push(v+W.replace(ue,this.options.pluralSeparator)),F.push(v+W),w&&F.push(v+te)),T){const K=`${v}${this.options.contextSeparator||"_"}${a.context}`;F.push(K),S&&(a.ordinal&&W.indexOf(ue)===0&&F.push(K+W.replace(ue,this.options.pluralSeparator)),F.push(K+W),w&&F.push(K+te))}}let X;for(;X=F.pop();)this.isValidLookup(r)||(c=X,r=this.getResource(P,z,X,a))}))})}),{res:r,usedKey:l,exactUsedKey:c,usedLng:f,usedNS:h}}isValidLookup(t){return t!==void 0&&!(!this.options.returnNull&&t===null)&&!(!this.options.returnEmptyString&&t==="")}getResource(t,a,r,l={}){var c;return(c=this.i18nFormat)!=null&&c.getResource?this.i18nFormat.getResource(t,a,r,l):this.resourceStore.getResource(t,a,r,l)}getUsedParamsDetails(t={}){const a=["defaultValue","ordinal","context","replace","lng","lngs","fallbackLng","ns","keySeparator","nsSeparator","returnObjects","returnDetails","joinArrays","postProcess","interpolation"],r=t.replace&&!re(t.replace);let l=r?t.replace:t;if(r&&typeof t.count<"u"&&(l.count=t.count),this.options.interpolation.defaultVariables&&(l={...this.options.interpolation.defaultVariables,...l}),!r){l={...l};for(const c of a)delete l[c]}return l}static hasDefaultValue(t){const a="defaultValue";for(const r in t)if(Object.prototype.hasOwnProperty.call(t,r)&&a===r.substring(0,a.length)&&t[r]!==void 0)return!0;return!1}}class lx{constructor(t){this.options=t,this.supportedLngs=this.options.supportedLngs||!1,this.logger=mn.create("languageUtils")}getScriptPartFromCode(t){if(t=Tr(t),!t||t.indexOf("-")<0)return null;const a=t.split("-");return a.length===2||(a.pop(),a[a.length-1].toLowerCase()==="x")?null:this.formatLanguageCode(a.join("-"))}getLanguagePartFromCode(t){if(t=Tr(t),!t||t.indexOf("-")<0)return t;const a=t.split("-");return this.formatLanguageCode(a[0])}formatLanguageCode(t){if(re(t)&&t.indexOf("-")>-1){let a;try{a=Intl.getCanonicalLocales(t)[0]}catch{}return a&&this.options.lowerCaseLng&&(a=a.toLowerCase()),a||(this.options.lowerCaseLng?t.toLowerCase():t)}return this.options.cleanCode||this.options.lowerCaseLng?t.toLowerCase():t}isSupportedCode(t){return(this.options.load==="languageOnly"||this.options.nonExplicitSupportedLngs)&&(t=this.getLanguagePartFromCode(t)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.indexOf(t)>-1}getBestMatchFromCodes(t){if(!t)return null;let a;return t.forEach(r=>{if(a)return;const l=this.formatLanguageCode(r);(!this.options.supportedLngs||this.isSupportedCode(l))&&(a=l)}),!a&&this.options.supportedLngs&&t.forEach(r=>{if(a)return;const l=this.getScriptPartFromCode(r);if(this.isSupportedCode(l))return a=l;const c=this.getLanguagePartFromCode(r);if(this.isSupportedCode(c))return a=c;a=this.options.supportedLngs.find(f=>{if(f===c)return f;if(!(f.indexOf("-")<0&&c.indexOf("-")<0)&&(f.indexOf("-")>0&&c.indexOf("-")<0&&f.substring(0,f.indexOf("-"))===c||f.indexOf(c)===0&&c.length>1))return f})}),a||(a=this.getFallbackCodes(this.options.fallbackLng)[0]),a}getFallbackCodes(t,a){if(!t)return[];if(typeof t=="function"&&(t=t(a)),re(t)&&(t=[t]),Array.isArray(t))return t;if(!a)return t.default||[];let r=t[a];return r||(r=t[this.getScriptPartFromCode(a)]),r||(r=t[this.formatLanguageCode(a)]),r||(r=t[this.getLanguagePartFromCode(a)]),r||(r=t.default),r||[]}toResolveHierarchy(t,a){const r=this.getFallbackCodes((a===!1?[]:a)||this.options.fallbackLng||[],t),l=[],c=f=>{f&&(this.isSupportedCode(f)?l.push(f):this.logger.warn(`rejecting language code not found in supportedLngs: ${f}`))};return re(t)&&(t.indexOf("-")>-1||t.indexOf("_")>-1)?(this.options.load!=="languageOnly"&&c(this.formatLanguageCode(t)),this.options.load!=="languageOnly"&&this.options.load!=="currentOnly"&&c(this.getScriptPartFromCode(t)),this.options.load!=="currentOnly"&&c(this.getLanguagePartFromCode(t))):re(t)&&c(this.formatLanguageCode(t)),r.forEach(f=>{l.indexOf(f)<0&&c(this.formatLanguageCode(f))}),l}}const cx={zero:0,one:1,two:2,few:3,many:4,other:5},ux={select:n=>n===1?"one":"other",resolvedOptions:()=>({pluralCategories:["one","other"]})};class SO{constructor(t,a={}){this.languageUtils=t,this.options=a,this.logger=mn.create("pluralResolver"),this.pluralRulesCache={}}clearCache(){this.pluralRulesCache={}}getRule(t,a={}){const r=Tr(t==="dev"?"en":t),l=a.ordinal?"ordinal":"cardinal",c=JSON.stringify({cleanedCode:r,type:l});if(c in this.pluralRulesCache)return this.pluralRulesCache[c];let f;try{f=new Intl.PluralRules(r,{type:l})}catch{if(typeof Intl>"u")return this.logger.error("No Intl support, please use an Intl polyfill!"),ux;if(!t.match(/-|_/))return ux;const m=this.languageUtils.getLanguagePartFromCode(t);f=this.getRule(m,a)}return this.pluralRulesCache[c]=f,f}needsPlural(t,a={}){let r=this.getRule(t,a);return r||(r=this.getRule("dev",a)),(r==null?void 0:r.resolvedOptions().pluralCategories.length)>1}getPluralFormsOfKey(t,a,r={}){return this.getSuffixes(t,r).map(l=>`${a}${l}`)}getSuffixes(t,a={}){let r=this.getRule(t,a);return r||(r=this.getRule("dev",a)),r?r.resolvedOptions().pluralCategories.sort((l,c)=>cx[l]-cx[c]).map(l=>`${this.options.prepend}${a.ordinal?`ordinal${this.options.prepend}`:""}${l}`):[]}getSuffix(t,a,r={}){const l=this.getRule(t,r);return l?`${this.options.prepend}${r.ordinal?`ordinal${this.options.prepend}`:""}${l.select(a)}`:(this.logger.warn(`no plural rule found for: ${t}`),this.getSuffix("dev",a,r))}}const fx=(n,t,a,r=".",l=!0)=>{let c=dO(n,t,a);return!c&&l&&re(a)&&(c=Pd(n,a,r),c===void 0&&(c=Pd(t,a,r))),c},Jf=n=>n.replace(/\$/g,"$$$$");class dx{constructor(t={}){var a;this.logger=mn.create("interpolator"),this.options=t,this.format=((a=t==null?void 0:t.interpolation)==null?void 0:a.format)||(r=>r),this.init(t)}init(t={}){t.interpolation||(t.interpolation={escapeValue:!0});const{escape:a,escapeValue:r,useRawValueToEscape:l,prefix:c,prefixEscaped:f,suffix:h,suffixEscaped:m,formatSeparator:p,unescapeSuffix:v,unescapePrefix:x,nestingPrefix:S,nestingPrefixEscaped:w,nestingSuffix:T,nestingSuffixEscaped:k,nestingOptionsSeparator:z,maxReplaces:B,alwaysFormat:q}=t.interpolation;this.escape=a!==void 0?a:mO,this.escapeValue=r!==void 0?r:!0,this.useRawValueToEscape=l!==void 0?l:!1,this.prefix=c?Ii(c):f||"{{",this.suffix=h?Ii(h):m||"}}",this.formatSeparator=p||",",this.unescapePrefix=v?"":x||"-",this.unescapeSuffix=this.unescapePrefix?"":v||"",this.nestingPrefix=S?Ii(S):w||Ii("$t("),this.nestingSuffix=T?Ii(T):k||Ii(")"),this.nestingOptionsSeparator=z||",",this.maxReplaces=B||1e3,this.alwaysFormat=q!==void 0?q:!1,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){const t=(a,r)=>(a==null?void 0:a.source)===r?(a.lastIndex=0,a):new RegExp(r,"g");this.regexp=t(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=t(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=t(this.nestingRegexp,`${this.nestingPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${this.nestingSuffix}`)}interpolate(t,a,r,l){var w;let c,f,h;const m=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},p=T=>{if(T.indexOf(this.formatSeparator)<0){const q=fx(a,m,T,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(q,void 0,r,{...l,...a,interpolationkey:T}):q}const k=T.split(this.formatSeparator),z=k.shift().trim(),B=k.join(this.formatSeparator).trim();return this.format(fx(a,m,z,this.options.keySeparator,this.options.ignoreJSONStructure),B,r,{...l,...a,interpolationkey:z})};this.resetRegExp();const v=(l==null?void 0:l.missingInterpolationHandler)||this.options.missingInterpolationHandler,x=((w=l==null?void 0:l.interpolation)==null?void 0:w.skipOnVariables)!==void 0?l.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:T=>Jf(T)},{regex:this.regexp,safeValue:T=>this.escapeValue?Jf(this.escape(T)):Jf(T)}].forEach(T=>{for(h=0;c=T.regex.exec(t);){const k=c[1].trim();if(f=p(k),f===void 0)if(typeof v=="function"){const B=v(t,c,l);f=re(B)?B:""}else if(l&&Object.prototype.hasOwnProperty.call(l,k))f="";else if(x){f=c[0];continue}else this.logger.warn(`missed to pass in variable ${k} for interpolating ${t}`),f="";else!re(f)&&!this.useRawValueToEscape&&(f=nx(f));const z=T.safeValue(f);if(t=t.replace(c[0],z),x?(T.regex.lastIndex+=f.length,T.regex.lastIndex-=c[0].length):T.regex.lastIndex=0,h++,h>=this.maxReplaces)break}}),t}nest(t,a,r={}){let l,c,f;const h=(m,p)=>{const v=this.nestingOptionsSeparator;if(m.indexOf(v)<0)return m;const x=m.split(new RegExp(`${Ii(v)}[ ]*{`));let S=`{${x[1]}`;m=x[0],S=this.interpolate(S,f);const w=S.match(/'/g),T=S.match(/"/g);(((w==null?void 0:w.length)??0)%2===0&&!T||((T==null?void 0:T.length)??0)%2!==0)&&(S=S.replace(/'/g,'"'));try{f=JSON.parse(S),p&&(f={...p,...f})}catch(k){return this.logger.warn(`failed parsing options string in nesting for key ${m}`,k),`${m}${v}${S}`}return f.defaultValue&&f.defaultValue.indexOf(this.prefix)>-1&&delete f.defaultValue,m};for(;l=this.nestingRegexp.exec(t);){let m=[];f={...r},f=f.replace&&!re(f.replace)?f.replace:f,f.applyPostProcessor=!1,delete f.defaultValue;const p=/{.*}/.test(l[1])?l[1].lastIndexOf("}")+1:l[1].indexOf(this.formatSeparator);if(p!==-1&&(m=l[1].slice(p).split(this.formatSeparator).map(v=>v.trim()).filter(Boolean),l[1]=l[1].slice(0,p)),c=a(h.call(this,l[1].trim(),f),f),c&&l[0]===t&&!re(c))return c;re(c)||(c=nx(c)),c||(this.logger.warn(`missed to resolve ${l[1]} for nesting ${t}`),c=""),m.length&&(c=m.reduce((v,x)=>this.format(v,x,r.lng,{...r,interpolationkey:l[1].trim()}),c.trim())),t=t.replace(l[0],c),this.regexp.lastIndex=0}return t}}const wO=n=>{let t=n.toLowerCase().trim();const a={};if(n.indexOf("(")>-1){const r=n.split("(");t=r[0].toLowerCase().trim();const l=r[1].substring(0,r[1].length-1);t==="currency"&&l.indexOf(":")<0?a.currency||(a.currency=l.trim()):t==="relativetime"&&l.indexOf(":")<0?a.range||(a.range=l.trim()):l.split(";").forEach(f=>{if(f){const[h,...m]=f.split(":"),p=m.join(":").trim().replace(/^'+|'+$/g,""),v=h.trim();a[v]||(a[v]=p),p==="false"&&(a[v]=!1),p==="true"&&(a[v]=!0),isNaN(p)||(a[v]=parseInt(p,10))}})}return{formatName:t,formatOptions:a}},hx=n=>{const t={};return(a,r,l)=>{let c=l;l&&l.interpolationkey&&l.formatParams&&l.formatParams[l.interpolationkey]&&l[l.interpolationkey]&&(c={...c,[l.interpolationkey]:void 0});const f=r+JSON.stringify(c);let h=t[f];return h||(h=n(Tr(r),l),t[f]=h),h(a)}},_O=n=>(t,a,r)=>n(Tr(a),r)(t);class TO{constructor(t={}){this.logger=mn.create("formatter"),this.options=t,this.init(t)}init(t,a={interpolation:{}}){this.formatSeparator=a.interpolation.formatSeparator||",";const r=a.cacheInBuiltFormats?hx:_O;this.formats={number:r((l,c)=>{const f=new Intl.NumberFormat(l,{...c});return h=>f.format(h)}),currency:r((l,c)=>{const f=new Intl.NumberFormat(l,{...c,style:"currency"});return h=>f.format(h)}),datetime:r((l,c)=>{const f=new Intl.DateTimeFormat(l,{...c});return h=>f.format(h)}),relativetime:r((l,c)=>{const f=new Intl.RelativeTimeFormat(l,{...c});return h=>f.format(h,c.range||"day")}),list:r((l,c)=>{const f=new Intl.ListFormat(l,{...c});return h=>f.format(h)})}}add(t,a){this.formats[t.toLowerCase().trim()]=a}addCached(t,a){this.formats[t.toLowerCase().trim()]=hx(a)}format(t,a,r,l={}){const c=a.split(this.formatSeparator);if(c.length>1&&c[0].indexOf("(")>1&&c[0].indexOf(")")<0&&c.find(h=>h.indexOf(")")>-1)){const h=c.findIndex(m=>m.indexOf(")")>-1);c[0]=[c[0],...c.splice(1,h)].join(this.formatSeparator)}return c.reduce((h,m)=>{var x;const{formatName:p,formatOptions:v}=wO(m);if(this.formats[p]){let S=h;try{const w=((x=l==null?void 0:l.formatParams)==null?void 0:x[l.interpolationkey])||{},T=w.locale||w.lng||l.locale||l.lng||r;S=this.formats[p](h,T,{...v,...l,...w})}catch(w){this.logger.warn(w)}return S}else this.logger.warn(`there was no format function for ${p}`);return h},t)}}const EO=(n,t)=>{n.pending[t]!==void 0&&(delete n.pending[t],n.pendingCount--)};class AO extends Wl{constructor(t,a,r,l={}){var c,f;super(),this.backend=t,this.store=a,this.services=r,this.languageUtils=r.languageUtils,this.options=l,this.logger=mn.create("backendConnector"),this.waitingReads=[],this.maxParallelReads=l.maxParallelReads||10,this.readingCalls=0,this.maxRetries=l.maxRetries>=0?l.maxRetries:5,this.retryTimeout=l.retryTimeout>=1?l.retryTimeout:350,this.state={},this.queue=[],(f=(c=this.backend)==null?void 0:c.init)==null||f.call(c,r,l.backend,l)}queueLoad(t,a,r,l){const c={},f={},h={},m={};return t.forEach(p=>{let v=!0;a.forEach(x=>{const S=`${p}|${x}`;!r.reload&&this.store.hasResourceBundle(p,x)?this.state[S]=2:this.state[S]<0||(this.state[S]===1?f[S]===void 0&&(f[S]=!0):(this.state[S]=1,v=!1,f[S]===void 0&&(f[S]=!0),c[S]===void 0&&(c[S]=!0),m[x]===void 0&&(m[x]=!0)))}),v||(h[p]=!0)}),(Object.keys(c).length||Object.keys(f).length)&&this.queue.push({pending:f,pendingCount:Object.keys(f).length,loaded:{},errors:[],callback:l}),{toLoad:Object.keys(c),pending:Object.keys(f),toLoadLanguages:Object.keys(h),toLoadNamespaces:Object.keys(m)}}loaded(t,a,r){const l=t.split("|"),c=l[0],f=l[1];a&&this.emit("failedLoading",c,f,a),!a&&r&&this.store.addResourceBundle(c,f,r,void 0,void 0,{skipCopy:!0}),this.state[t]=a?-1:2,a&&r&&(this.state[t]=0);const h={};this.queue.forEach(m=>{fO(m.loaded,[c],f),EO(m,t),a&&m.errors.push(a),m.pendingCount===0&&!m.done&&(Object.keys(m.loaded).forEach(p=>{h[p]||(h[p]={});const v=m.loaded[p];v.length&&v.forEach(x=>{h[p][x]===void 0&&(h[p][x]=!0)})}),m.done=!0,m.errors.length?m.callback(m.errors):m.callback())}),this.emit("loaded",h),this.queue=this.queue.filter(m=>!m.done)}read(t,a,r,l=0,c=this.retryTimeout,f){if(!t.length)return f(null,{});if(this.readingCalls>=this.maxParallelReads){this.waitingReads.push({lng:t,ns:a,fcName:r,tried:l,wait:c,callback:f});return}this.readingCalls++;const h=(p,v)=>{if(this.readingCalls--,this.waitingReads.length>0){const x=this.waitingReads.shift();this.read(x.lng,x.ns,x.fcName,x.tried,x.wait,x.callback)}if(p&&v&&l{this.read.call(this,t,a,r,l+1,c*2,f)},c);return}f(p,v)},m=this.backend[r].bind(this.backend);if(m.length===2){try{const p=m(t,a);p&&typeof p.then=="function"?p.then(v=>h(null,v)).catch(h):h(null,p)}catch(p){h(p)}return}return m(t,a,h)}prepareLoading(t,a,r={},l){if(!this.backend)return this.logger.warn("No backend was added via i18next.use. Will not load resources."),l&&l();re(t)&&(t=this.languageUtils.toResolveHierarchy(t)),re(a)&&(a=[a]);const c=this.queueLoad(t,a,r,l);if(!c.toLoad.length)return c.pending.length||l(),null;c.toLoad.forEach(f=>{this.loadOne(f)})}load(t,a,r){this.prepareLoading(t,a,{},r)}reload(t,a,r){this.prepareLoading(t,a,{reload:!0},r)}loadOne(t,a=""){const r=t.split("|"),l=r[0],c=r[1];this.read(l,c,"read",void 0,void 0,(f,h)=>{f&&this.logger.warn(`${a}loading namespace ${c} for language ${l} failed`,f),!f&&h&&this.logger.log(`${a}loaded namespace ${c} for language ${l}`,h),this.loaded(t,f,h)})}saveMissing(t,a,r,l,c,f={},h=()=>{}){var m,p,v,x,S;if((p=(m=this.services)==null?void 0:m.utils)!=null&&p.hasLoadedNamespace&&!((x=(v=this.services)==null?void 0:v.utils)!=null&&x.hasLoadedNamespace(a))){this.logger.warn(`did not save key "${r}" as the namespace "${a}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!");return}if(!(r==null||r==="")){if((S=this.backend)!=null&&S.create){const w={...f,isUpdate:c},T=this.backend.create.bind(this.backend);if(T.length<6)try{let k;T.length===5?k=T(t,a,r,l,w):k=T(t,a,r,l),k&&typeof k.then=="function"?k.then(z=>h(null,z)).catch(h):h(null,k)}catch(k){h(k)}else T(t,a,r,l,h,w)}!t||!t[0]||this.store.addResource(t[0],a,r,l)}}}const Wf=()=>({debug:!1,initAsync:!0,ns:["translation"],defaultNS:["translation"],fallbackLng:["dev"],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:"all",preload:!1,simplifyPluralSuffix:!0,keySeparator:".",nsSeparator:":",pluralSeparator:"_",contextSeparator:"_",partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:"fallback",saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:n=>{let t={};if(typeof n[1]=="object"&&(t=n[1]),re(n[1])&&(t.defaultValue=n[1]),re(n[2])&&(t.tDescription=n[2]),typeof n[2]=="object"||typeof n[3]=="object"){const a=n[3]||n[2];Object.keys(a).forEach(r=>{t[r]=a[r]})}return t},interpolation:{escapeValue:!0,format:n=>n,prefix:"{{",suffix:"}}",formatSeparator:",",unescapePrefix:"-",nestingPrefix:"$t(",nestingSuffix:")",nestingOptionsSeparator:",",maxReplaces:1e3,skipOnVariables:!0},cacheInBuiltFormats:!0}),mx=n=>{var t,a;return re(n.ns)&&(n.ns=[n.ns]),re(n.fallbackLng)&&(n.fallbackLng=[n.fallbackLng]),re(n.fallbackNS)&&(n.fallbackNS=[n.fallbackNS]),((a=(t=n.supportedLngs)==null?void 0:t.indexOf)==null?void 0:a.call(t,"cimode"))<0&&(n.supportedLngs=n.supportedLngs.concat(["cimode"])),typeof n.initImmediate=="boolean"&&(n.initAsync=n.initImmediate),n},ol=()=>{},NO=n=>{Object.getOwnPropertyNames(Object.getPrototypeOf(n)).forEach(a=>{typeof n[a]=="function"&&(n[a]=n[a].bind(n))})},mS="__i18next_supportNoticeShown",DO=()=>!!(typeof globalThis<"u"&&globalThis[mS]||typeof process<"u"&&Qf&&Qf.I18NEXT_NO_SUPPORT_NOTICE||typeof process<"u"&&Qf),jO=()=>{typeof globalThis<"u"&&(globalThis[mS]=!0)},CO=n=>{var t,a,r,l,c,f,h,m,p,v,x,S,w;return!!(((r=(a=(t=n==null?void 0:n.modules)==null?void 0:t.backend)==null?void 0:a.name)==null?void 0:r.indexOf("Locize"))>0||((h=(f=(c=(l=n==null?void 0:n.modules)==null?void 0:l.backend)==null?void 0:c.constructor)==null?void 0:f.name)==null?void 0:h.indexOf("Locize"))>0||(p=(m=n==null?void 0:n.options)==null?void 0:m.backend)!=null&&p.backends&&n.options.backend.backends.some(T=>{var k,z,B;return((k=T==null?void 0:T.name)==null?void 0:k.indexOf("Locize"))>0||((B=(z=T==null?void 0:T.constructor)==null?void 0:z.name)==null?void 0:B.indexOf("Locize"))>0})||(x=(v=n==null?void 0:n.options)==null?void 0:v.backend)!=null&&x.projectId||(w=(S=n==null?void 0:n.options)==null?void 0:S.backend)!=null&&w.backendOptions&&n.options.backend.backendOptions.some(T=>T==null?void 0:T.projectId))};class pr extends Wl{constructor(t={},a){if(super(),this.options=mx(t),this.services={},this.logger=mn,this.modules={external:[]},NO(this),a&&!this.isInitialized&&!t.isClone){if(!this.options.initAsync)return this.init(t,a),this;setTimeout(()=>{this.init(t,a)},0)}}init(t={},a){this.isInitializing=!0,typeof t=="function"&&(a=t,t={}),t.defaultNS==null&&t.ns&&(re(t.ns)?t.defaultNS=t.ns:t.ns.indexOf("translation")<0&&(t.defaultNS=t.ns[0]));const r=Wf();this.options={...r,...this.options,...mx(t)},this.options.interpolation={...r.interpolation,...this.options.interpolation},t.keySeparator!==void 0&&(this.options.userDefinedKeySeparator=t.keySeparator),t.nsSeparator!==void 0&&(this.options.userDefinedNsSeparator=t.nsSeparator),typeof this.options.overloadTranslationOptionHandler!="function"&&(this.options.overloadTranslationOptionHandler=r.overloadTranslationOptionHandler),this.options.showSupportNotice!==!1&&!CO(this)&&!DO()&&(typeof console<"u"&&typeof console.info<"u"&&console.info("🌐 i18next is made possible by our own product, Locize — consider powering your project with managed localization (AI, CDN, integrations): https://locize.com 💙"),jO());const l=p=>p?typeof p=="function"?new p:p:null;if(!this.options.isClone){this.modules.logger?mn.init(l(this.modules.logger),this.options):mn.init(null,this.options);let p;this.modules.formatter?p=this.modules.formatter:p=TO;const v=new lx(this.options);this.store=new rx(this.options.resources,this.options);const x=this.services;x.logger=mn,x.resourceStore=this.store,x.languageUtils=v,x.pluralResolver=new SO(v,{prepend:this.options.pluralSeparator,simplifyPluralSuffix:this.options.simplifyPluralSuffix}),this.options.interpolation.format&&this.options.interpolation.format!==r.interpolation.format&&this.logger.deprecate("init: you are still using the legacy format function, please use the new approach: https://www.i18next.com/translation-function/formatting"),p&&(!this.options.interpolation.format||this.options.interpolation.format===r.interpolation.format)&&(x.formatter=l(p),x.formatter.init&&x.formatter.init(x,this.options),this.options.interpolation.format=x.formatter.format.bind(x.formatter)),x.interpolator=new dx(this.options),x.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},x.backendConnector=new AO(l(this.modules.backend),x.resourceStore,x,this.options),x.backendConnector.on("*",(w,...T)=>{this.emit(w,...T)}),this.modules.languageDetector&&(x.languageDetector=l(this.modules.languageDetector),x.languageDetector.init&&x.languageDetector.init(x,this.options.detection,this.options)),this.modules.i18nFormat&&(x.i18nFormat=l(this.modules.i18nFormat),x.i18nFormat.init&&x.i18nFormat.init(this)),this.translator=new zl(this.services,this.options),this.translator.on("*",(w,...T)=>{this.emit(w,...T)}),this.modules.external.forEach(w=>{w.init&&w.init(this)})}if(this.format=this.options.interpolation.format,a||(a=ol),this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){const p=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);p.length>0&&p[0]!=="dev"&&(this.options.lng=p[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn("init: no languageDetector is used and no lng is defined"),["getResource","hasResourceBundle","getResourceBundle","getDataByLanguage"].forEach(p=>{this[p]=(...v)=>this.store[p](...v)}),["addResource","addResources","addResourceBundle","removeResourceBundle"].forEach(p=>{this[p]=(...v)=>(this.store[p](...v),this)});const h=rr(),m=()=>{const p=(v,x)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn("init: i18next is already initialized. You should call init just once!"),this.isInitialized=!0,this.options.isClone||this.logger.log("initialized",this.options),this.emit("initialized",this.options),h.resolve(x),a(v,x)};if(this.languages&&!this.isInitialized)return p(null,this.t.bind(this));this.changeLanguage(this.options.lng,p)};return this.options.resources||!this.options.initAsync?m():setTimeout(m,0),h}loadResources(t,a=ol){var c,f;let r=a;const l=re(t)?t:this.language;if(typeof t=="function"&&(r=t),!this.options.resources||this.options.partialBundledLanguages){if((l==null?void 0:l.toLowerCase())==="cimode"&&(!this.options.preload||this.options.preload.length===0))return r();const h=[],m=p=>{if(!p||p==="cimode")return;this.services.languageUtils.toResolveHierarchy(p).forEach(x=>{x!=="cimode"&&h.indexOf(x)<0&&h.push(x)})};l?m(l):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach(v=>m(v)),(f=(c=this.options.preload)==null?void 0:c.forEach)==null||f.call(c,p=>m(p)),this.services.backendConnector.load(h,this.options.ns,p=>{!p&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),r(p)})}else r(null)}reloadResources(t,a,r){const l=rr();return typeof t=="function"&&(r=t,t=void 0),typeof a=="function"&&(r=a,a=void 0),t||(t=this.languages),a||(a=this.options.ns),r||(r=ol),this.services.backendConnector.reload(t,a,c=>{l.resolve(),r(c)}),l}use(t){if(!t)throw new Error("You are passing an undefined module! Please check the object you are passing to i18next.use()");if(!t.type)throw new Error("You are passing a wrong module! Please check the object you are passing to i18next.use()");return t.type==="backend"&&(this.modules.backend=t),(t.type==="logger"||t.log&&t.warn&&t.error)&&(this.modules.logger=t),t.type==="languageDetector"&&(this.modules.languageDetector=t),t.type==="i18nFormat"&&(this.modules.i18nFormat=t),t.type==="postProcessor"&&dS.addPostProcessor(t),t.type==="formatter"&&(this.modules.formatter=t),t.type==="3rdParty"&&this.modules.external.push(t),this}setResolvedLanguage(t){if(!(!t||!this.languages)&&!(["cimode","dev"].indexOf(t)>-1)){for(let a=0;a-1)&&this.store.hasLanguageSomeTranslations(r)){this.resolvedLanguage=r;break}}!this.resolvedLanguage&&this.languages.indexOf(t)<0&&this.store.hasLanguageSomeTranslations(t)&&(this.resolvedLanguage=t,this.languages.unshift(t))}}changeLanguage(t,a){this.isLanguageChangingTo=t;const r=rr();this.emit("languageChanging",t);const l=h=>{this.language=h,this.languages=this.services.languageUtils.toResolveHierarchy(h),this.resolvedLanguage=void 0,this.setResolvedLanguage(h)},c=(h,m)=>{m?this.isLanguageChangingTo===t&&(l(m),this.translator.changeLanguage(m),this.isLanguageChangingTo=void 0,this.emit("languageChanged",m),this.logger.log("languageChanged",m)):this.isLanguageChangingTo=void 0,r.resolve((...p)=>this.t(...p)),a&&a(h,(...p)=>this.t(...p))},f=h=>{var v,x;!t&&!h&&this.services.languageDetector&&(h=[]);const m=re(h)?h:h&&h[0],p=this.store.hasLanguageSomeTranslations(m)?m:this.services.languageUtils.getBestMatchFromCodes(re(h)?[h]:h);p&&(this.language||l(p),this.translator.language||this.translator.changeLanguage(p),(x=(v=this.services.languageDetector)==null?void 0:v.cacheUserLanguage)==null||x.call(v,p)),this.loadResources(p,S=>{c(S,p)})};return!t&&this.services.languageDetector&&!this.services.languageDetector.async?f(this.services.languageDetector.detect()):!t&&this.services.languageDetector&&this.services.languageDetector.async?this.services.languageDetector.detect.length===0?this.services.languageDetector.detect().then(f):this.services.languageDetector.detect(f):f(t),r}getFixedT(t,a,r){const l=(c,f,...h)=>{let m;typeof f!="object"?m=this.options.overloadTranslationOptionHandler([c,f].concat(h)):m={...f},m.lng=m.lng||l.lng,m.lngs=m.lngs||l.lngs,m.ns=m.ns||l.ns,m.keyPrefix!==""&&(m.keyPrefix=m.keyPrefix||r||l.keyPrefix);const p={...this.options,...m};typeof m.keyPrefix=="function"&&(m.keyPrefix=Xa(m.keyPrefix,p));const v=this.options.keySeparator||".";let x;return m.keyPrefix&&Array.isArray(c)?x=c.map(S=>(typeof S=="function"&&(S=Xa(S,p)),`${m.keyPrefix}${v}${S}`)):(typeof c=="function"&&(c=Xa(c,p)),x=m.keyPrefix?`${m.keyPrefix}${v}${c}`:c),this.t(x,m)};return re(t)?l.lng=t:l.lngs=t,l.ns=a,l.keyPrefix=r,l}t(...t){var a;return(a=this.translator)==null?void 0:a.translate(...t)}exists(...t){var a;return(a=this.translator)==null?void 0:a.exists(...t)}setDefaultNamespace(t){this.options.defaultNS=t}hasLoadedNamespace(t,a={}){if(!this.isInitialized)return this.logger.warn("hasLoadedNamespace: i18next was not initialized",this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn("hasLoadedNamespace: i18n.languages were undefined or empty",this.languages),!1;const r=a.lng||this.resolvedLanguage||this.languages[0],l=this.options?this.options.fallbackLng:!1,c=this.languages[this.languages.length-1];if(r.toLowerCase()==="cimode")return!0;const f=(h,m)=>{const p=this.services.backendConnector.state[`${h}|${m}`];return p===-1||p===0||p===2};if(a.precheck){const h=a.precheck(this,f);if(h!==void 0)return h}return!!(this.hasResourceBundle(r,t)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||f(r,t)&&(!l||f(c,t)))}loadNamespaces(t,a){const r=rr();return this.options.ns?(re(t)&&(t=[t]),t.forEach(l=>{this.options.ns.indexOf(l)<0&&this.options.ns.push(l)}),this.loadResources(l=>{r.resolve(),a&&a(l)}),r):(a&&a(),Promise.resolve())}loadLanguages(t,a){const r=rr();re(t)&&(t=[t]);const l=this.options.preload||[],c=t.filter(f=>l.indexOf(f)<0&&this.services.languageUtils.isSupportedCode(f));return c.length?(this.options.preload=l.concat(c),this.loadResources(f=>{r.resolve(),a&&a(f)}),r):(a&&a(),Promise.resolve())}dir(t){var l,c;if(t||(t=this.resolvedLanguage||(((l=this.languages)==null?void 0:l.length)>0?this.languages[0]:this.language)),!t)return"rtl";try{const f=new Intl.Locale(t);if(f&&f.getTextInfo){const h=f.getTextInfo();if(h&&h.direction)return h.direction}}catch{}const a=["ar","shu","sqr","ssh","xaa","yhd","yud","aao","abh","abv","acm","acq","acw","acx","acy","adf","ads","aeb","aec","afb","ajp","apc","apd","arb","arq","ars","ary","arz","auz","avl","ayh","ayl","ayn","ayp","bbz","pga","he","iw","ps","pbt","pbu","pst","prp","prd","ug","ur","ydd","yds","yih","ji","yi","hbo","men","xmn","fa","jpr","peo","pes","prs","dv","sam","ckb"],r=((c=this.services)==null?void 0:c.languageUtils)||new lx(Wf());return t.toLowerCase().indexOf("-latn")>1?"ltr":a.indexOf(r.getLanguagePartFromCode(t))>-1||t.toLowerCase().indexOf("-arab")>1?"rtl":"ltr"}static createInstance(t={},a){const r=new pr(t,a);return r.createInstance=pr.createInstance,r}cloneInstance(t={},a=ol){const r=t.forkResourceStore;r&&delete t.forkResourceStore;const l={...this.options,...t,isClone:!0},c=new pr(l);if((t.debug!==void 0||t.prefix!==void 0)&&(c.logger=c.logger.clone(t)),["store","services","language"].forEach(h=>{c[h]=this[h]}),c.services={...this.services},c.services.utils={hasLoadedNamespace:c.hasLoadedNamespace.bind(c)},r){const h=Object.keys(this.store.data).reduce((m,p)=>(m[p]={...this.store.data[p]},m[p]=Object.keys(m[p]).reduce((v,x)=>(v[x]={...m[p][x]},v),m[p]),m),{});c.store=new rx(h,l),c.services.resourceStore=c.store}if(t.interpolation){const m={...Wf().interpolation,...this.options.interpolation,...t.interpolation},p={...l,interpolation:m};c.services.interpolator=new dx(p)}return c.translator=new zl(c.services,l),c.translator.on("*",(h,...m)=>{c.emit(h,...m)}),c.init(l,a),c.translator.options=l,c.translator.backendConnector.services.utils={hasLoadedNamespace:c.hasLoadedNamespace.bind(c)},c}toJSON(){return{options:this.options,store:this.store,language:this.language,languages:this.languages,resolvedLanguage:this.resolvedLanguage}}}const Ze=pr.createInstance();Ze.createInstance;Ze.dir;Ze.init;Ze.loadResources;Ze.reloadResources;Ze.use;Ze.changeLanguage;Ze.getFixedT;Ze.t;Ze.exists;Ze.setDefaultNamespace;Ze.hasLoadedNamespace;Ze.loadNamespaces;Ze.loadLanguages;const{slice:MO,forEach:OO}=[];function RO(n){return OO.call(MO.call(arguments,1),t=>{if(t)for(const a in t)n[a]===void 0&&(n[a]=t[a])}),n}function kO(n){return typeof n!="string"?!1:[/<\s*script.*?>/i,/<\s*\/\s*script\s*>/i,/<\s*img.*?on\w+\s*=/i,/<\s*\w+\s*on\w+\s*=.*?>/i,/javascript\s*:/i,/vbscript\s*:/i,/expression\s*\(/i,/eval\s*\(/i,/alert\s*\(/i,/document\.cookie/i,/document\.write\s*\(/i,/window\.location/i,/innerHTML/i].some(a=>a.test(n))}const px=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/,LO=function(n,t){const r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{path:"/"},l=encodeURIComponent(t);let c=`${n}=${l}`;if(r.maxAge>0){const f=r.maxAge-0;if(Number.isNaN(f))throw new Error("maxAge should be a Number");c+=`; Max-Age=${Math.floor(f)}`}if(r.domain){if(!px.test(r.domain))throw new TypeError("option domain is invalid");c+=`; Domain=${r.domain}`}if(r.path){if(!px.test(r.path))throw new TypeError("option path is invalid");c+=`; Path=${r.path}`}if(r.expires){if(typeof r.expires.toUTCString!="function")throw new TypeError("option expires is invalid");c+=`; Expires=${r.expires.toUTCString()}`}if(r.httpOnly&&(c+="; HttpOnly"),r.secure&&(c+="; Secure"),r.sameSite)switch(typeof r.sameSite=="string"?r.sameSite.toLowerCase():r.sameSite){case!0:c+="; SameSite=Strict";break;case"lax":c+="; SameSite=Lax";break;case"strict":c+="; SameSite=Strict";break;case"none":c+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}return r.partitioned&&(c+="; Partitioned"),c},gx={create(n,t,a,r){let l=arguments.length>4&&arguments[4]!==void 0?arguments[4]:{path:"/",sameSite:"strict"};a&&(l.expires=new Date,l.expires.setTime(l.expires.getTime()+a*60*1e3)),r&&(l.domain=r),document.cookie=LO(n,t,l)},read(n){const t=`${n}=`,a=document.cookie.split(";");for(let r=0;r-1&&(l=window.location.hash.substring(window.location.hash.indexOf("?")));const f=l.substring(1).split("&");for(let h=0;h0&&f[h].substring(0,m)===t&&(a=f[h].substring(m+1))}}return a}},BO={name:"hash",lookup(n){var l;let{lookupHash:t,lookupFromHashIndex:a}=n,r;if(typeof window<"u"){const{hash:c}=window.location;if(c&&c.length>2){const f=c.substring(1);if(t){const h=f.split("&");for(let m=0;m0&&h[m].substring(0,p)===t&&(r=h[m].substring(p+1))}}if(r)return r;if(!r&&a>-1){const h=c.match(/\/([a-zA-Z-]*)/g);return Array.isArray(h)?(l=h[typeof a=="number"?a:0])==null?void 0:l.replace("/",""):void 0}}}return r}};let Pa=null;const yx=()=>{if(Pa!==null)return Pa;try{if(Pa=typeof window<"u"&&window.localStorage!==null,!Pa)return!1;const n="i18next.translate.boo";window.localStorage.setItem(n,"foo"),window.localStorage.removeItem(n)}catch{Pa=!1}return Pa};var UO={name:"localStorage",lookup(n){let{lookupLocalStorage:t}=n;if(t&&yx())return window.localStorage.getItem(t)||void 0},cacheUserLanguage(n,t){let{lookupLocalStorage:a}=t;a&&yx()&&window.localStorage.setItem(a,n)}};let qa=null;const vx=()=>{if(qa!==null)return qa;try{if(qa=typeof window<"u"&&window.sessionStorage!==null,!qa)return!1;const n="i18next.translate.boo";window.sessionStorage.setItem(n,"foo"),window.sessionStorage.removeItem(n)}catch{qa=!1}return qa};var HO={name:"sessionStorage",lookup(n){let{lookupSessionStorage:t}=n;if(t&&vx())return window.sessionStorage.getItem(t)||void 0},cacheUserLanguage(n,t){let{lookupSessionStorage:a}=t;a&&vx()&&window.sessionStorage.setItem(a,n)}},PO={name:"navigator",lookup(n){const t=[];if(typeof navigator<"u"){const{languages:a,userLanguage:r,language:l}=navigator;if(a)for(let c=0;c0?t:void 0}},qO={name:"htmlTag",lookup(n){let{htmlTag:t}=n,a;const r=t||(typeof document<"u"?document.documentElement:null);return r&&typeof r.getAttribute=="function"&&(a=r.getAttribute("lang")),a}},IO={name:"path",lookup(n){var l;let{lookupFromPathIndex:t}=n;if(typeof window>"u")return;const a=window.location.pathname.match(/\/([a-zA-Z-]*)/g);return Array.isArray(a)?(l=a[typeof t=="number"?t:0])==null?void 0:l.replace("/",""):void 0}},FO={name:"subdomain",lookup(n){var l,c;let{lookupFromSubdomainIndex:t}=n;const a=typeof t=="number"?t+1:1,r=typeof window<"u"&&((c=(l=window.location)==null?void 0:l.hostname)==null?void 0:c.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i));if(r)return r[a]}};let pS=!1;try{document.cookie,pS=!0}catch{}const gS=["querystring","cookie","localStorage","sessionStorage","navigator","htmlTag"];pS||gS.splice(1,1);const GO=()=>({order:gS,lookupQuerystring:"lng",lookupCookie:"i18next",lookupLocalStorage:"i18nextLng",lookupSessionStorage:"i18nextLng",caches:["localStorage"],excludeCacheFor:["cimode"],convertDetectedLanguage:n=>n});class yS{constructor(t){let a=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};this.type="languageDetector",this.detectors={},this.init(t,a)}init(){let t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{languageUtils:{}},a=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};this.services=t,this.options=RO(a,this.options||{},GO()),typeof this.options.convertDetectedLanguage=="string"&&this.options.convertDetectedLanguage.indexOf("15897")>-1&&(this.options.convertDetectedLanguage=l=>l.replace("-","_")),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=r,this.addDetector(zO),this.addDetector(VO),this.addDetector(UO),this.addDetector(HO),this.addDetector(PO),this.addDetector(qO),this.addDetector(IO),this.addDetector(FO),this.addDetector(BO)}addDetector(t){return this.detectors[t.name]=t,this}detect(){let t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.order,a=[];return t.forEach(r=>{if(this.detectors[r]){let l=this.detectors[r].lookup(this.options);l&&typeof l=="string"&&(l=[l]),l&&(a=a.concat(l))}}),a=a.filter(r=>r!=null&&!kO(r)).map(r=>this.options.convertDetectedLanguage(r)),this.services&&this.services.languageUtils&&this.services.languageUtils.getBestMatchFromCodes?a:a.length>0?a[0]:null}cacheUserLanguage(t){let a=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.options.caches;a&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(t)>-1||a.forEach(r=>{this.detectors[r]&&this.detectors[r].cacheUserLanguage(t,this.options)}))}}yS.type="languageDetector";const YO={free:"Free",pro:"Pro",api:"API",enterprise:"Enterprise",reserveAccess:"Reserve Your Early Access",signIn:"Sign In",upgradeToPro:"Upgrade to Pro"},KO={noiseWord:"Noise",signalWord:"Signal",valueProps:"The intelligence geopolitical AI layer — ask it, subscribe to it, build on it.",reserveEarlyAccess:"Reserve Your Early Access",launchingDate:"Live now",tryFreeDashboard:"Try the free dashboard",emailPlaceholder:"Enter your email",emailAriaLabel:"Email address for waitlist",choosePlan:"Choose Your Plan",signIn:"Sign In"},XO={asFeaturedIn:"As featured in"},$O={proTitle:"World Monitor Pro",proDesc:"Your AI analyst. A personal intelligence desk. A platform you can build on. One key across 30+ live services.",proF1:"WM Analyst chat — query all 30+ services conversationally",proF2:"AI digest — daily, twice-daily, or weekly — to Slack, Discord, Telegram, Email, or webhook",proF3:"Custom Widget Builder — HTML/CSS/JS with AI-assisted modification",proF4:"MCP connectors — plug WorldMonitor into Claude, GPT, custom LLMs",proF5:"Equity research, stock analysis & backtesting",proF6:"Flight search & price comparison",proF7:"Alert rules engine — custom triggers, quiet hours, AES-256 encrypted channels",proF8:"Market watchlist, macro & central bank tracking",proF9:"AI Market Implications & Regional Intelligence (Soon: orbital surveillance, premium map layers, longer history)",proCta:"Reserve Your Early Access",choosePlan:"Choose Your Plan",entTitle:"World Monitor Enterprise",entDesc:"For teams that need shared monitoring, API access, deployment options, TV apps, and direct support.",entF1:"Everything in Pro, plus:",entF2:"Live-edge + satellite imagery & SAR",entF3:"AI agents with investor personas & MCP",entF4:"50,000+ infrastructure assets mapped",entF5:"100+ data connectors (Splunk, Snowflake, Sentinel...)",entF6:"REST API + webhooks + bulk export",entF7:"Team workspaces with SSO/MFA/RBAC",entF8:"White-label & embeddable panels",entF9:"Android TV app for SOC walls & trading floors",entF10:"Cloud, on-prem, or air-gapped deployment",entF11:"Dedicated onboarding & support",entCta:"Talk to Sales"},QO={title:"Why upgrade",noiseTitle:"Know first",noiseDesc:"AIS anomaly → Brent spike → your Slack in under a minute. Signal, not headlines.",fasterTitle:"Ask anything",fasterDesc:"Chat the same 30+ services your dashboard sees. WM Analyst, always on.",controlTitle:"Build anything",controlDesc:"Custom widget builder (HTML/CSS/JS + AI) plus MCP for your own AI workflows.",deeperTitle:"Wake up informed",deeperDesc:"30-item AI digest ranked by your alert rules. Critical, High, Medium — with Assessment and Signals to watch."},ZO={askIt:"Ask it",askItDesc:"WM Analyst chat. All 30+ services queryable in plain English.",subscribeIt:"Subscribe to it",subscribeItDesc:"AI digest on your schedule — daily, twice-daily, or weekly. Slack, Discord, Telegram, Email, webhook.",buildOnIt:"Build on it",buildOnItDesc:"Custom Widget Builder (HTML/CSS/JS + AI) and MCP for Claude, GPT, custom LLMs."},JO={eyebrow:"DELIVERY DESK",title:"Your personal intelligence desk",body:"Up to 30 ranked items per send, deduped across 500+ sources, scored against your watchlist. Choose your cadence — daily, twice-daily, or weekly — with an AI Assessment and Signals to watch delivered to Slack, Discord, Telegram, Email, or webhook.",closer:"Not a newsletter. An analyst.",channels:"Slack · Discord · Telegram · Email · Webhook · AES-256 encrypted · Quiet hours"},WO={windowTitle:"worldmonitor.app — Live Dashboard",openFullScreen:"Open full screen",tryLiveDashboard:"Try the Live Dashboard",iframeTitle:"World Monitor — Live Intelligence Dashboard",description:"3D WebGL globe · 45+ interactive map layers · Real-time market, macro, geopolitical, energy, and infrastructure data"},eR={uniqueVisitors:"Unique visitors",peakDailyUsers:"Peak daily users",countriesReached:"Countries reached",liveDataSources:"Live data sources",quote:"Markets, monetary policy, geopolitics, energy — everything moves together now. I needed something that showed me how these forces connect in real time, not just the headlines but the underlying drivers.",ceo:"CEO of",asToldTo:"as told to"},tR={title:"Built for people who need signal fast",investorsTitle:"Investors & portfolio managers",investorsDesc:"Track global equities, analyst targets, valuation metrics, and macro indicators alongside geopolitical risk signals.",tradersTitle:"Energy & commodities traders",tradersDesc:"Track vessel movements, cargo inference, supply chain disruptions, and market-moving geopolitical signals.",researchersTitle:"Researchers & analysts",researchersDesc:"Equity research, economy analytics, and geopolitical frameworks for deeper analysis and reporting.",journalistsTitle:"Journalists & media",journalistsDesc:"Follow fast-moving developments across markets and regions without stitching sources together manually.",govTitle:"Government & institutions",govDesc:"Macro policy tracking, central bank monitoring, and situational awareness across geopolitical and infrastructure signals.",teamsTitle:"Teams & organizations",teamsDesc:"Move from individual use to shared workflows, API access, TV apps, and managed deployments."},nR={title:"What World Monitor Tracks",subtitle:"30+ service domains ingested simultaneously. Markets, macro, geopolitics, energy, infrastructure — everything normalized and rendered on a WebGL globe.",markets:"Financial Markets & Equities",marketsDesc:"Global stock analysis, commodities, crypto, ETF flows, analyst targets, and FRED macro data",economy:"Economy & Central Banks",economyDesc:"GDP, inflation, interest rates, growth cycles, and monetary policy tracking across major economies",geopolitical:"Geopolitical Analysis",geopoliticalDesc:"ACLED & UCDP events with escalation scoring, risk frameworks, and trend analysis",maritime:"Maritime & Trade",maritimeDesc:"Ship movements, vessel detection, port activity, and cargo inference",aviation:"Aviation Tracking",aviationDesc:"ADS-B transponder tracking of global flight patterns",infra:"Critical Infrastructure",infraDesc:"Nuclear sites, power grids, pipelines, refineries — 50K+ mapped assets",fire:"Satellite Fire Detection",fireDesc:"NASA FIRMS near-real-time fire and hotspot data",cables:"Submarine Cables",cablesDesc:"Undersea cable routes and landing stations",internet:"Internet & GPS",internetDesc:"Outage detection, BGP anomalies, GPS jamming zones",cyber:"Cyber Threats",cyberDesc:"Ransomware feeds, BGP hijacks, DDoS detection",gdelt:"GDELT & News",gdeltDesc:"500+ RSS feeds, AI-scored GDELT events, live broadcasts",seismology:"Seismology & Natural",seismologyDesc:"USGS earthquakes, volcanic activity, severe weather"},iR={free:"Free",freeTagline:"See everything",freeDesc:"The open-source dashboard",freeF1:"5-15 min refresh",freeF2:"500+ feeds, 45 map layers",freeF3:"BYOK for AI",freeF4:"Free forever",openDashboard:"Open Dashboard",pro:"Pro",proTagline:"Markets, macro & geopolitics",proDesc:"Your AI analyst",proF1:"Equity research & stock analysis",proF2:"+ daily briefs, economy analytics",proF3:"AI included, 1 key",proF4:"Priority data refresh (Soon)",priceMonthly:"$39.99 / month",priceAnnual:"$399.99 / year",annualSavingsNote:"2 months free",enterprise:"Enterprise",enterpriseTagline:"Act before anyone else",enterpriseDesc:"The intelligence platform",entF1:"Live-edge + satellite imagery",entF2:"+ AI agents, 50K+ infra, SAR",entF3:"Custom AI, investor personas",entF4:"Contact us",contactSales:"Contact Sales"},aR={proTier:"PRO TIER",title:"Your AI Analyst That Never Sleeps",subtitle:"The free dashboard shows you the world. Pro gives you an analyst to ask, a digest you subscribe to, and primitives to build on. Stocks, macro, geopolitical risk — and the connections between them.",equityResearch:"Equity Research",equityResearchDesc:"Global stock analysis with financials visualization, analyst price targets, and valuation metrics. Track what moves markets.",geopoliticalAnalysis:"Geopolitical Analysis",geopoliticalAnalysisDesc:"Grand Chessboard strategic framework, Prisoners of Geography models, and central bank & monetary policy tracking.",economyAnalytics:"Economy Analytics",economyAnalyticsDesc:"GDP, inflation, interest rates, and growth cycles. Macro data correlated with market signals and geopolitical events.",riskMonitoring:"Risk Monitoring & Scenarios",riskMonitoringDesc:"Global risk scoring, scenario analysis, and geopolitical risk assessment. Convergence detection across market and political signals.",orbitalSurveillance:"Orbital Surveillance",orbitalSurveillanceDesc:"(Soon) Overhead pass predictions, revisit frequency analysis, and imaging window alerts. Know when intelligence satellites are watching your areas of interest.",morningBriefs:"Personal Intelligence Desk",morningBriefsDesc:"Up to 30 ranked stories per digest, deduped across 500+ sources. Pick daily, twice-daily, or weekly cadence — or real-time alerts for critical events. AI Assessment and Signals to watch delivered to Slack, Discord, Telegram, Email, or webhook. Not a newsletter — an analyst.",oneKey:"30+ Services, 1 Key",oneKeyDesc:"Finnhub, FRED, ACLED, UCDP, NASA FIRMS, AISStream, OpenSky, and more — all active, no separate registrations.",deliveryLabel:"Choose how intelligence finds you"},sR={morningBrief:"Morning Brief",markets:"Markets",marketsText:"S&P 500 futures -1.2% pre-market. Fed Chair testimony at 10am EST — rate-sensitive sectors under pressure. Analyst consensus shifting on Q2 earnings.",elevated:"Macro",elevatedText:"ECB holds rates at 3.75%. Euro area GDP revised up to 1.1%. Central bank divergence widening — USD/EUR at 3-month high.",watch:"Geopolitical",watchText:"Brent +2.3% on Hormuz AIS anomaly. 4 dark ships in 6h. Commodity supply chain risk elevated — energy sector correlations spiking."},rR={apiTier:"API TIER",title:"Programmatic Intelligence",subtitle:"For developers, analysts, and teams building on World Monitor data. Separate from Pro — use both or either.",restApi:"REST API across all 30+ service domains",authenticated:"Authenticated per-key, rate-limited per tier",structured:"Structured JSON with cache headers and OpenAPI 3.1 docs",starter:"Starter",starterReqs:"1,000 req/day",starterWebhooks:"5 webhook rules",business:"Business",businessReqs:"50,000 req/day",businessWebhooks:"Unlimited webhooks + SLA",feedData:"Feed data into your dashboards, automate alerting via Zapier/n8n/Make, build custom scoring models on CII/risk data."},oR={enterpriseTier:"ENTERPRISE TIER",title:"Intelligence Infrastructure",subtitle:"For governments, institutions, trading desks, and organizations that need the full platform with maximum security, AI agents, TV apps, and data depth.",security:"Government-Grade Security",securityDesc:"Air-gapped deployment, on-premises Docker, dedicated cloud tenant, SOC 2 Type II path, SSO/MFA, and full audit trail.",aiAgents:"AI Agents & MCP",aiAgentsDesc:"Autonomous intelligence agents with investor personas. Connect World Monitor as a tool to Claude, GPT, or custom LLMs via MCP.",dataLayers:"Expanded Data Layers",dataLayersDesc:"Tens of thousands of infrastructure assets mapped globally. Satellite imagery integration with change detection and SAR.",connectors:"100+ Data Connectors",connectorsDesc:"PostgreSQL, Snowflake, Splunk, Sentinel, Jira, Slack, Teams, and more. Export to PDF, PowerPoint, CSV, GeoJSON.",whiteLabel:"White-Label, TV & Embeddable",whiteLabelDesc:"Your brand, your domain, your desktop app. Android TV app for SOC walls and trading floors. Embeddable iframe panels.",financial:"Financial Intelligence",financialDesc:"Earnings calendar, energy grid data, enhanced commodity tracking with cargo inference, sanctions screening with AIS correlation.",commodity:"Commodity Trading",commodityDesc:"Vessel tracking + cargo inference + supply chain graph. Know before the market moves.",government:"Government & Institutions",governmentDesc:"Air-gapped, AI agents, full situational awareness, MCP. No data leaves your network.",risk:"Risk Consultancies",riskDesc:"Scenario simulation, investor personas, branded PDF/PowerPoint reports on demand.",soc:"SOCs & CERT",socDesc:"Cyber threat layer, SIEM integration, BGP anomaly monitoring, ransomware feeds.",talkToSales:"Talk to Sales",contactFormTitle:"Talk to our team",contactFormSubtitle:"Tell us about your organization and we'll get back to you within one business day.",namePlaceholder:"Your name",emailPlaceholder:"Work email",orgPlaceholder:"Company *",phonePlaceholder:"Phone number *",messagePlaceholder:"What are you looking for?",workEmailRequired:"Please use your work email address",submitContact:"Send Message",contactSending:"Sending...",contactSent:"Message sent. We'll be in touch.",contactFailed:"Failed to send. Please email enterprise@worldmonitor.app"},lR={title:"Compare Tiers",feature:"Feature",freeHeader:"Free ($0)",proHeader:"Pro ($39.99)",apiHeader:"API ($99.99)",entHeader:"Enterprise (Contact)",dataRefresh:"Data refresh",dashboard:"Dashboard",ai:"AI",briefsAlerts:"Briefs & alerts",delivery:"Delivery",apiRow:"API",infraLayers:"Infrastructure layers",satellite:"Orbital Surveillance",connectorsRow:"Connectors",deployment:"Deployment",securityRow:"Security",f5_15min:"5-15 min",fLt60s:"<60 seconds",fPerRequest:"Per-request",fLiveEdge:"Live-edge",f50panels:"50+ panels",fWhiteLabel:"White-label",fBYOK:"BYOK",fIncluded:"Included",fAgentsPersonas:"Agents + personas",fDailyFlash:"Daily + flash",fTeamDist:"Team distribution",fSlackTgWa:"Slack/Discord/TG/Email/Webhook",fWebhook:"Webhook",fSiemMcp:"+ SIEM/MCP",fRestWebhook:"REST + webhook",fMcpBulk:"+ MCP + bulk",f45:"45",fTensOfThousands:"+ tens of thousands",fLiveTracking:"Live tracking",fPassAlerts:"Pass alerts + analysis",fImagerySar:"Imagery + SAR",f100plus:"100+",fCloud:"Cloud",fCloudOnPrem:"Cloud/on-prem/air-gap",fStandard:"Standard",fKeyAuth:"Key auth",fSsoMfa:"SSO/MFA/RBAC/audit",noteBelow:"The core platform remains free. Paid plans unlock equity research, macro analytics, AI briefings, and organizational use."},cR={title:"Frequently Asked Questions",q1:"Is World Monitor still free?",a1:"Yes. The core platform remains free. Pro adds equity research, macro analytics, and AI briefings. Enterprise adds team deployments and TV apps.",q2:"Why pay for Pro?",a2:"Pro is for investors, analysts, and professionals who want stock monitoring, geopolitical analysis, economy analytics, and AI-powered daily briefings — all under one key.",q3:"Who is Enterprise for?",a3:"Enterprise is for teams that need shared use, APIs, integrations, deployment options, and direct support.",q4:"Can I start with Pro and upgrade later?",a4:"Yes. Pro works for serious individuals. Enterprise is there when team and deployment needs grow.",q5:"Is this only for conflict monitoring?",a5:"No. World Monitor is primarily a global intelligence platform covering stock markets, macroeconomics, geopolitical analysis, energy, infrastructure, and more. Conflict tracking is one of many capabilities — not the focus.",q6:"Why keep the core platform free?",a6:"Because public access matters. Paid plans fund deeper workflows for serious users and organizations.",q7:"Can I still use my own API keys?",a7:"Yes. Bring-your-own-keys always works. Pro simply means you don't have to register for 20+ separate services.",q8:"What's MCP?",a8:"MCP lets AI agents — Claude, GPT, custom LLMs — use WorldMonitor as a tool, querying all 30+ services. Included in Pro. Enterprise adds private MCP servers and custom deployments.",q9:"Can I build my own panels?",a9:"Yes. Pro includes the Custom Widget Builder — build panels from HTML, CSS, and JavaScript, with AI-assisted modification.",q10:"Can I connect Claude or GPT to WorldMonitor?",a10:"Yes. MCP is included in Pro — plug WorldMonitor into Claude, GPT, or any MCP-compatible LLM as a live tool.",q11:"How personalized is the digest?",a11:"Pick your cadence — daily, twice-daily, or weekly. We re-score every tracked story against your alert rules and watchlist, dedupe across 500+ sources, and send up to 30 ranked items with an AI Assessment and Signals to watch written to your context. Real-time alerts are also available for critical events.",q12:"What's the refresh rate?",a12:"Near real time for Pro. 5–15 minutes on Free.",q13:"Where does my data go?",a13:"Notification channels are AES-256 encrypted at rest. Digests never leave our pipeline unredacted."},uR={title:"Start with Pro. Scale to Enterprise.",subtitle:"Keep using World Monitor for free, or upgrade for equity research, macro analytics, and AI briefings. If your organization needs team access, TV apps, or API support, talk to us.",getPro:"Reserve Your Early Access",talkToSales:"Talk to Sales"},fR={beFirstInLine:"Be first in line.",lookingForEnterprise:"Looking for Enterprise?",contactUs:"Contact us",wiredArticle:"WIRED Article"},dR={submitting:"Submitting...",joinWaitlist:"Reserve Your Early Access",tooManyRequests:"Too many requests",failedTryAgain:"Failed — try again"},hR={alreadyOnList:"You're already on the list.",shareHint:"Share your link to move up the line. Each friend who joins bumps you closer to the front.",copied:"Copied!",shareOnX:"Share on X",linkedin:"LinkedIn",whatsapp:"WhatsApp",telegram:"Telegram",shareText:"I just joined the World Monitor Pro waitlist — stock monitoring, geopolitical analysis, and AI daily briefings in one platform. Join me:",joinWaitlistShare:"Join the World Monitor Pro waitlist:",youreIn:"You're in!",invitedBanner:"You've been invited — join the waitlist"},mR="Soon",vS={nav:YO,hero:KO,wired:XO,twoPath:$O,whyUpgrade:QO,pillars:ZO,deliveryDesk:JO,livePreview:WO,socialProof:eR,audience:tR,dataCoverage:nR,tiers:iR,proShowcase:aR,slackMock:sR,apiSection:rR,enterpriseShowcase:oR,pricingTable:lR,faq:cR,finalCta:uR,footer:fR,form:dR,referral:hR,soonBadge:mR},xS=["en","ar","bg","cs","de","el","es","fr","it","ja","ko","nl","pl","pt","ro","ru","sv","th","tr","vi","zh"],pR=new Set(xS),xx=new Set(["en"]),gR=new Set(["ar"]),yR=Object.assign({"./locales/ar.json":()=>Ie(()=>import("./ar-Cm8L16fJ.js"),[]).then(n=>n.default),"./locales/bg.json":()=>Ie(()=>import("./bg-meSd4JsJ.js"),[]).then(n=>n.default),"./locales/cs.json":()=>Ie(()=>import("./cs-ptRTyzJj.js"),[]).then(n=>n.default),"./locales/de.json":()=>Ie(()=>import("./de-C3_MVNE9.js"),[]).then(n=>n.default),"./locales/el.json":()=>Ie(()=>import("./el-B9-X35aF.js"),[]).then(n=>n.default),"./locales/es.json":()=>Ie(()=>import("./es-DKuPMUhm.js"),[]).then(n=>n.default),"./locales/fr.json":()=>Ie(()=>import("./fr-CqZfnoPg.js"),[]).then(n=>n.default),"./locales/it.json":()=>Ie(()=>import("./it-xRd9wXeo.js"),[]).then(n=>n.default),"./locales/ja.json":()=>Ie(()=>import("./ja-BvG2yjL7.js"),[]).then(n=>n.default),"./locales/ko.json":()=>Ie(()=>import("./ko-Bp1BAWvm.js"),[]).then(n=>n.default),"./locales/nl.json":()=>Ie(()=>import("./nl-CIy0NOIy.js"),[]).then(n=>n.default),"./locales/pl.json":()=>Ie(()=>import("./pl-P7FWM5y7.js"),[]).then(n=>n.default),"./locales/pt.json":()=>Ie(()=>import("./pt-RlnECMQU.js"),[]).then(n=>n.default),"./locales/ro.json":()=>Ie(()=>import("./ro-OfGDlDfm.js"),[]).then(n=>n.default),"./locales/ru.json":()=>Ie(()=>import("./ru-BgqyPHlN.js"),[]).then(n=>n.default),"./locales/sv.json":()=>Ie(()=>import("./sv-DuX3Lsqd.js"),[]).then(n=>n.default),"./locales/th.json":()=>Ie(()=>import("./th-CD3FOyKH.js"),[]).then(n=>n.default),"./locales/tr.json":()=>Ie(()=>import("./tr-F4p4sScu.js"),[]).then(n=>n.default),"./locales/vi.json":()=>Ie(()=>import("./vi-D1texoPw.js"),[]).then(n=>n.default),"./locales/zh.json":()=>Ie(()=>import("./zh-BxyDCIra.js"),[]).then(n=>n.default)});function vR(n){var a;const t=((a=(n||"en").split("-")[0])==null?void 0:a.toLowerCase())||"en";return pR.has(t)?t:"en"}async function xR(n){const t=vR(n);if(xx.has(t))return t;const a=yR[`./locales/${t}.json`],r=a?await a():vS;return Ze.addResourceBundle(t,"translation",r,!0,!0),xx.add(t),t}async function bR(){if(Ze.isInitialized)return;await Ze.use(yS).init({resources:{en:{translation:vS}},supportedLngs:[...xS],nonExplicitSupportedLngs:!0,fallbackLng:"en",interpolation:{escapeValue:!1},detection:{order:["querystring","localStorage","navigator"],lookupQuerystring:"lang",caches:["localStorage"]}});const n=await xR(Ze.language||"en");n!=="en"&&await Ze.changeLanguage(n);const t=(Ze.language||n).split("-")[0]||"en";document.documentElement.setAttribute("lang",t==="zh"?"zh-CN":t),gR.has(t)&&document.documentElement.setAttribute("dir","rtl")}function _(n,t){return Ze.t(n,t)}const bS="https://api.worldmonitor.app/api",bx="https://customer.dodopayments.com",SR="ACTIVE_SUBSCRIPTION_EXISTS",Sx="'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace";let nt=null,gr=null,Vl=null,bl=!1,or=null;async function Er(){return nt||or||(or=wR().catch(n=>{throw or=null,n}),or)}async function wR(){const{Clerk:n}=await Ie(async()=>{const{Clerk:r}=await import("./clerk-PNSFEZs8.js");return{Clerk:r}},[]),t="pk_live_Y2xlcmsud29ybGRtb25pdG9yLmFwcCQ",a=new n(t);return await a.load({appearance:{variables:{colorBackground:"#0f0f0f",colorInputBackground:"#141414",colorInputText:"#e8e8e8",colorText:"#e8e8e8",colorTextSecondary:"#aaaaaa",colorPrimary:"#44ff88",colorNeutral:"#e8e8e8",colorDanger:"#ff4444",borderRadius:"4px",fontFamily:Sx,fontFamilyButtons:Sx},elements:{card:{backgroundColor:"#111111",border:"1px solid #2a2a2a",boxShadow:"0 8px 32px rgba(0,0,0,0.6)"},formButtonPrimary:{color:"#000000",fontWeight:"600"},footerActionLink:{color:"#44ff88"},socialButtonsBlockButton:{borderColor:"#2a2a2a",color:"#e8e8e8",backgroundColor:"#141414"}}}}),nt=a,nt.addListener(()=>{if(nt!=null&&nt.user&&gr){const r=gr,l=Vl;gr=null,Vl=null,SS(r,l??{})}}),nt}function _R(n){Ie(async()=>{const{DodoPayments:t}=await import("./index.esm-BiNDwt_v.js");return{DodoPayments:t}},[]).then(({DodoPayments:t})=>{t.Initialize({mode:"test",displayType:"overlay",onEvent:a=>{var r,l,c;a.event_type==="checkout.status"&&(((r=a.data)==null?void 0:r.status)??((c=(l=a.data)==null?void 0:l.message)==null?void 0:c.status))==="succeeded"&&(n==null||n())}})}).catch(t=>{console.error("[checkout] Failed to load Dodo overlay SDK:",t)})}async function TR(n,t){if(bl)return!1;let a;try{a=await Er()}catch(r){return console.error("[checkout] Failed to load Clerk:",r),Ja(r,{tags:{surface:"pro-marketing",action:"load-clerk"}}),!1}if(!a.user){gr=n,Vl=t??null;try{a.openSignIn()}catch(r){console.error("[checkout] Failed to open sign in:",r),Ja(r,{tags:{surface:"pro-marketing",action:"checkout-sign-in"}}),gr=null,Vl=null}return!1}return SS(n,t??{})}async function SS(n,t){if(bl)return!1;bl=!0;try{const a=await ER();if(!a)return console.error("[checkout] No auth token after retry"),!1;const r=await fetch(`${bS}/create-checkout`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({productId:n,returnUrl:"https://worldmonitor.app",discountCode:t.discountCode,referralCode:t.referralCode}),signal:AbortSignal.timeout(15e3)});if(!r.ok){const f=await r.json().catch(()=>({}));return console.error("[checkout] Edge error:",r.status,f),r.status===409&&(f==null?void 0:f.error)===SR&&await AR(a,f==null?void 0:f.message),!1}const l=await r.json();if(!(l!=null&&l.checkout_url))return console.error("[checkout] No checkout_url in response"),!1;const{DodoPayments:c}=await Ie(async()=>{const{DodoPayments:f}=await import("./index.esm-BiNDwt_v.js");return{DodoPayments:f}},[]);return c.Checkout.open({checkoutUrl:l.checkout_url,options:{manualRedirect:!0,themeConfig:{dark:{bgPrimary:"#0d0d0d",bgSecondary:"#1a1a1a",borderPrimary:"#323232",textPrimary:"#ffffff",textSecondary:"#909090",buttonPrimary:"#22c55e",buttonPrimaryHover:"#16a34a",buttonTextPrimary:"#0d0d0d"},light:{bgPrimary:"#ffffff",bgSecondary:"#f8f9fa",borderPrimary:"#d4d4d4",textPrimary:"#1a1a1a",textSecondary:"#555555",buttonPrimary:"#16a34a",buttonPrimaryHover:"#15803d",buttonTextPrimary:"#ffffff"},radius:"4px"}}}),!0}catch(a){return console.error("[checkout] Failed:",a),!1}finally{bl=!1}}async function ER(){var t,a,r,l;let n=await((t=nt==null?void 0:nt.session)==null?void 0:t.getToken({template:"convex"}).catch(()=>null))??await((a=nt==null?void 0:nt.session)==null?void 0:a.getToken().catch(()=>null));return n||(await new Promise(c=>setTimeout(c,2e3)),n=await((r=nt==null?void 0:nt.session)==null?void 0:r.getToken({template:"convex"}).catch(()=>null))??await((l=nt==null?void 0:nt.session)==null?void 0:l.getToken().catch(()=>null))),n}async function AR(n,t){t&&console.warn("[checkout] Redirecting to billing portal:",t);try{const a=await fetch(`${bS}/customer-portal`,{method:"POST",headers:{Authorization:`Bearer ${n}`},signal:AbortSignal.timeout(15e3)}),r=await a.json().catch(()=>({})),l=typeof(r==null?void 0:r.portal_url)=="string"?r.portal_url:bx;a.ok||console.error("[checkout] Customer portal error:",a.status,r),window.location.assign(l)}catch(a){console.error("[checkout] Failed to open billing portal:",a),window.location.assign(bx)}}const NR=[{name:"Free",price:0,period:"forever",description:"Get started with the essentials",features:["Core dashboard panels","Global news feed","Earthquake & weather alerts","Basic map view"],cta:"Get Started",href:"https://worldmonitor.app",highlighted:!1},{name:"Pro",monthlyPrice:39.99,annualPrice:399.99,description:"Full intelligence dashboard",features:["Everything in Free","AI stock analysis & backtesting","Daily market briefs","Military & geopolitical tracking","Custom widget builder","MCP data connectors","Priority data refresh"],monthlyProductId:"pdt_0Nbtt71uObulf7fGXhQup",annualProductId:"pdt_0NbttMIfjLWC10jHQWYgJ",highlighted:!0},{name:"API",monthlyPrice:99.99,annualPrice:999,description:"Programmatic access to intelligence data",features:["REST API access","Real-time data streams","1,000 requests/day","Webhook notifications","Custom data exports"],monthlyProductId:"pdt_0NbttVmG1SERrxhygbbUq",annualProductId:"pdt_0Nbu2lawHYE3dv2THgSEV",highlighted:!1},{name:"Enterprise",price:null,description:"Custom solutions for organizations",features:["Everything in Pro + API","Unlimited API requests","Dedicated support","Custom integrations","SLA guarantee","On-premise option"],cta:"Contact Sales",href:"mailto:enterprise@worldmonitor.app",highlighted:!1}],DR="https://api.worldmonitor.app/api/product-catalog";function jR(){const[n,t]=Z.useState(NR);return Z.useEffect(()=>{let a=!1;return fetch(DR,{signal:AbortSignal.timeout(5e3)}).then(r=>r.ok?r.json():null).then(r=>{var l;!a&&((l=r==null?void 0:r.tiers)!=null&&l.length)&&t(r.tiers)}).catch(()=>{}),()=>{a=!0}},[]),n}function CR(n,t){return n.price===0?{amount:"$0",suffix:"forever"}:n.price===null&&n.monthlyPrice===void 0?{amount:"Custom",suffix:"tailored to you"}:n.annualPrice===null&&n.monthlyPrice!==void 0?{amount:`$${n.monthlyPrice}`,suffix:"/mo"}:t==="annual"&&n.annualPrice!=null?{amount:`$${n.annualPrice}`,suffix:"/yr"}:{amount:`$${n.monthlyPrice}`,suffix:"/mo"}}function MR(n,t){return n.cta&&n.href&&n.price===0?{type:"link",label:n.cta,href:n.href,external:!0}:n.cta&&n.href&&n.price===null?{type:"link",label:n.cta,href:n.href,external:!0}:n.monthlyProductId?{type:"checkout",label:"Get Started",productId:t==="annual"&&n.annualProductId?n.annualProductId:n.monthlyProductId}:{type:"link",label:"Learn More",href:"#",external:!1}}function OR({refCode:n}){const[t,a]=Z.useState("monthly"),r=jR(),l=Z.useCallback(c=>{TR(c,{referralCode:n})},[n]);return g.jsx("section",{id:"pricing",className:"py-24 px-6 border-t border-wm-border bg-[#060606]",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsxs("div",{className:"text-center mb-16",children:[g.jsx(Ka.h2,{className:"text-3xl md:text-5xl font-display font-bold mb-4",initial:{opacity:0,y:20},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5},children:"Choose Your Plan"}),g.jsx(Ka.p,{className:"text-wm-muted max-w-xl mx-auto mb-8",initial:{opacity:0,y:10},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5,delay:.1},children:"From real-time monitoring to full intelligence infrastructure. Pick the tier that fits your mission."}),g.jsxs(Ka.div,{className:"inline-flex items-center gap-3 bg-wm-card border border-wm-border rounded-sm p-1",initial:{opacity:0,y:10},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5,delay:.2},children:[g.jsx("button",{onClick:()=>a("monthly"),className:`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors ${t==="monthly"?"bg-wm-green text-wm-bg font-bold":"text-wm-muted hover:text-wm-text"}`,children:"Monthly"}),g.jsxs("button",{onClick:()=>a("annual"),className:`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors flex items-center gap-2 ${t==="annual"?"bg-wm-green text-wm-bg font-bold":"text-wm-muted hover:text-wm-text"}`,children:["Annual",g.jsx("span",{className:`text-[10px] px-1.5 py-0.5 rounded-sm ${t==="annual"?"bg-wm-bg/20 text-wm-bg":"bg-wm-green/10 text-wm-green"}`,children:"Save 17%"})]})]})]}),g.jsx("div",{className:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6",children:r.map((c,f)=>{const h=CR(c,t),m=MR(c,t);return g.jsxs(Ka.div,{className:`relative bg-zinc-900 rounded-lg p-6 flex flex-col ${c.highlighted?"border-2 border-wm-green shadow-lg shadow-wm-green/10":"border border-wm-border"}`,initial:{opacity:0,y:30},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5,delay:f*.1},children:[c.highlighted&&g.jsxs("div",{className:"absolute -top-3 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 bg-wm-green text-wm-bg px-3 py-1 rounded-full text-xs font-mono font-bold uppercase tracking-wider",children:[g.jsx(rO,{className:"w-3 h-3","aria-hidden":"true"}),"Most Popular"]}),g.jsx("h3",{className:`font-display text-lg font-bold mb-1 ${c.highlighted?"text-wm-green":"text-wm-text"}`,children:c.name}),g.jsx("p",{className:"text-xs text-wm-muted mb-4",children:c.description}),g.jsxs("div",{className:"mb-6",children:[g.jsx("span",{className:"text-4xl font-display font-bold",children:h.amount}),g.jsxs("span",{className:"text-sm text-wm-muted ml-1",children:["/",h.suffix]})]}),g.jsx("ul",{className:"space-y-3 mb-8 flex-1",children:c.features.map((p,v)=>g.jsxs("li",{className:"flex items-start gap-2 text-sm",children:[g.jsx(Hd,{className:`w-4 h-4 shrink-0 mt-0.5 ${c.highlighted?"text-wm-green":"text-wm-muted"}`,"aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted",children:p})]},v))}),m.type==="link"?g.jsxs("a",{href:m.href,target:m.external?"_blank":void 0,rel:m.external?"noreferrer":void 0,className:`block text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors ${c.highlighted?"bg-wm-green text-wm-bg hover:bg-green-400":"border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text"}`,children:[m.label," ",g.jsx(Wi,{className:"w-3.5 h-3.5 inline-block ml-1","aria-hidden":"true"})]}):g.jsxs("button",{onClick:()=>l(m.productId),className:`block w-full text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors cursor-pointer ${c.highlighted?"bg-wm-green text-wm-bg hover:bg-green-400":"border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text"}`,children:[m.label," ",g.jsx(Wi,{className:"w-3.5 h-3.5 inline-block ml-1","aria-hidden":"true"})]})]},c.name)})}),g.jsx("p",{className:"text-center text-xs text-wm-muted font-mono mt-8",children:"Have a promo code? Enter it during checkout."})]})})}function RR(){return g.jsx("span",{style:{display:"inline-block",padding:"2px 8px",marginLeft:"6px",fontSize:"10px",fontWeight:600,letterSpacing:"0.04em",textTransform:"uppercase",color:"#fbbf24",background:"rgba(251,191,36,0.12)",border:"1px solid rgba(251,191,36,0.3)",borderRadius:"4px",verticalAlign:"middle"},children:_("soonBadge")})}const kR="/pro/assets/worldmonitor-7-mar-2026-CtI5YvxO.jpg",LR="data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0.11%2010.99%20124.78%2024.98'%3e%3cpath%20d='M105.375%2014.875v17.25h8.5c2.375%200%203.75-.375%204.75-1.25%201.25-1.125%201.875-3.125%201.875-7.375s-.625-6.25-1.875-7.375c-1-.875-2.375-1.25-4.75-1.25zM117%2023.5c0%203.75-.25%204.625-1%205.125-.5.375-1.125.5-2.375.5h-4.75V17.75h4.75c1.25%200%201.875%200%202.375.5.75.625%201%201.5%201%205.25zm7.875%2012.438H99.937V11h24.938zM79.563%2017.75v-2.875h14.75v5.5h-3.126V17.75h-6v4.125h4.75v2.75h-4.75v4.625h6.126v-3h3.124v5.875H79.564V29.25h2.374v-11.5zM66.188%2027.625c0%201.875.124%203.25.374%204.375h3.376c-.126-.875-.25-2.5-.25-4.625-.126-2.5-.876-2.875-2.626-3.25%202-.375%202.876-1.25%202.876-4.375%200-2.5-.376-3.5-1.126-4.125-.5-.5-1.374-.75-2.75-.75h-10.5v17.25h3.5v-6.75h4.876c1%200%201.374.125%201.75.375s.5.625.5%201.875zm-7.126-5v-4.75h5.626c.75%200%201%20.125%201.124.25.25.25.5.625.5%202.125s-.25%202-.5%202.25c-.124.125-.374.25-1.124.25zm15.876%2013.313h-25V11h24.937v24.938zM43.438%2029.25v2.875H31.562V29.25h4.25v-11.5h-4.25v-2.875h11.875v2.875h-4.25v11.5zM23.375%2014.875h-3.25L17.75%2028.5%2015%2015.875c-.125-.875-.5-1-1.25-1H12c-.75%200-1.125.25-1.25%201L8%2028.5%205.625%2014.875h-3.5L5.5%2031.25c.125.75.375.875%201.25.875h2.375c.75%200%201-.125%201.25-.875L13%2019.375l2.625%2011.875c.125.75.375.875%201.25.875h2.25c.75%200%201.125-.125%201.25-.875zm1.75%2021.063h-25V11h24.938v24.938z'%3e%3c/path%3e%3c/svg%3e",zR="https://api.worldmonitor.app/api",VR="0x4AAAAAACnaYgHIyxclu8Tj";function BR(){if(!window.turnstile)return 0;let n=0;return document.querySelectorAll(".cf-turnstile:not([data-rendered])").forEach(t=>{const a=window.turnstile.render(t,{sitekey:VR,size:"flexible",callback:r=>{t.dataset.token=r},"expired-callback":()=>{delete t.dataset.token},"error-callback":()=>{delete t.dataset.token}});t.dataset.rendered="true",t.dataset.widgetId=String(a),n++}),n}function UR(){return new URLSearchParams(window.location.search).get("ref")||void 0}function wS(){Er().then(n=>n.openSignIn()).catch(n=>{console.error("[auth] Failed to open sign in:",n),Ja(n,{tags:{surface:"pro-marketing",action:"open-sign-in"}})})}function _S(){const[n,t]=Z.useState(null),[a,r]=Z.useState(!1);return Z.useEffect(()=>{let l=!0,c;return Er().then(f=>{l&&(t(f.user??null),r(!0),c=f.addListener(()=>{l&&t(f.user??null)}))}).catch(f=>{console.error("[auth] Failed to load Clerk for nav auth state:",f),Ja(f,{tags:{surface:"pro-marketing",action:"load-clerk-for-nav"}}),l&&r(!0)}),()=>{l=!1,c==null||c()}},[]),{user:n,isLoaded:a}}function HR(){const n=Z.useRef(null);return Z.useEffect(()=>{if(!n.current)return;const t=n.current;let a=!1;return Er().then(r=>{a||!t||r.mountUserButton(t,{afterSignOutUrl:"https://www.worldmonitor.app/pro"})}).catch(r=>{console.error("[auth] Failed to mount user button:",r),Ja(r,{tags:{surface:"pro-marketing",action:"mount-user-button"}})}),()=>{a=!0,Er().then(r=>{t&&r.unmountUserButton(t)}).catch(()=>{})}},[]),g.jsx("div",{ref:n,className:"flex items-center"})}const PR=()=>g.jsx("svg",{viewBox:"0 0 24 24",className:"w-5 h-5",fill:"currentColor","aria-hidden":"true",children:g.jsx("path",{d:"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"})}),TS=()=>g.jsxs("a",{href:"https://worldmonitor.app",className:"flex items-center gap-2 hover:opacity-80 transition-opacity","aria-label":"World Monitor — Home",children:[g.jsxs("div",{className:"relative w-8 h-8 rounded-full bg-wm-card border border-wm-border flex items-center justify-center overflow-hidden",children:[g.jsx(Rl,{className:"w-5 h-5 text-wm-blue opacity-50 absolute","aria-hidden":"true"}),g.jsx(l3,{className:"w-6 h-6 text-wm-green absolute z-10","aria-hidden":"true"})]}),g.jsxs("div",{className:"flex flex-col",children:[g.jsx("span",{className:"font-display font-bold text-sm leading-none tracking-tight",children:"WORLD MONITOR"}),g.jsx("span",{className:"text-[9px] text-wm-muted font-mono uppercase tracking-widest leading-none mt-1",children:"by Someone.ceo"})]})]}),qR=()=>{const{user:n,isLoaded:t}=_S();return g.jsx("nav",{className:"fixed top-0 left-0 right-0 z-50 glass-panel border-b-0 border-x-0 rounded-none","aria-label":"Main navigation",children:g.jsxs("div",{className:"max-w-7xl mx-auto px-6 h-16 flex items-center justify-between",children:[g.jsx(TS,{}),g.jsxs("div",{className:"hidden md:flex items-center gap-8 text-sm font-mono text-wm-muted",children:[g.jsx("a",{href:"#tiers",className:"hover:text-wm-text transition-colors",children:_("nav.free")}),g.jsx("a",{href:"#pro",className:"hover:text-wm-green transition-colors",children:_("nav.pro")}),g.jsx("a",{href:"#api",className:"hover:text-wm-text transition-colors",children:_("nav.api")}),g.jsx("a",{href:"#enterprise",className:"hover:text-wm-text transition-colors",children:_("nav.enterprise")})]}),g.jsxs("div",{className:"flex items-center gap-2",children:[t&&(n?g.jsx(HR,{}):g.jsx("button",{type:"button",onClick:wS,className:"border border-wm-border text-wm-text px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:border-wm-text transition-colors",children:_("nav.signIn")})),g.jsx("a",{href:"#pricing",className:"bg-wm-green text-wm-bg px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:_("nav.upgradeToPro")})]})]})})},IR=()=>g.jsxs("a",{href:"https://www.wired.me/story/the-music-streaming-ceo-who-built-a-global-war-map",target:"_blank",rel:"noreferrer",className:"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-wm-border bg-wm-card/50 text-wm-muted text-xs font-mono hover:border-wm-green/30 hover:text-wm-text transition-colors",children:[_("wired.asFeaturedIn")," ",g.jsx("span",{className:"text-wm-text font-bold",children:"WIRED"})," ",g.jsx(rS,{className:"w-3 h-3","aria-hidden":"true"})]}),FR=()=>g.jsxs("div",{className:"relative my-4 md:my-8 -mx-6",children:[g.jsx("div",{className:"absolute inset-0 flex items-center justify-center pointer-events-none",children:g.jsx("div",{className:"w-64 h-40 md:w-96 md:h-56 bg-wm-green/8 rounded-full blur-[80px]"})}),g.jsx("div",{className:"flex items-end justify-center gap-[3px] md:gap-1 h-28 md:h-44 relative px-4","aria-hidden":"true",children:Array.from({length:60}).map((r,l)=>{const c=Math.abs(l-30),f=c<=8,h=f?1-c/8:0,m=60+h*110,p=Math.max(8,35-c*.8);return g.jsx(Ka.div,{className:`flex-1 max-w-2 md:max-w-3 rounded-sm ${f?"bg-wm-green":"bg-wm-muted/20"}`,style:f?{boxShadow:`0 0 ${6+h*12}px rgba(74,222,128,${h*.5})`}:void 0,initial:{height:f?m*.3:p*.5,opacity:f?.4:.08},animate:f?{height:[m*.5,m,m*.65,m*.9],opacity:[.6+h*.3,1,.75+h*.2,.95]}:{height:[p,p*.3,p*.7,p*.15,p*.5],opacity:[.2,.06,.15,.04,.12]},transition:{duration:f?2.5+h*.5:1+Math.random()*.6,repeat:1/0,repeatType:"reverse",delay:f?c*.07:Math.random()*.6,ease:"easeInOut"}},l)})})]}),GR=()=>{const{user:n,isLoaded:t}=_S(),a=t&&!n;return g.jsxs("section",{className:"pt-28 pb-12 px-6 relative overflow-hidden",children:[g.jsx("div",{className:"absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(74,222,128,0.08)_0%,transparent_50%)] pointer-events-none"}),g.jsx("div",{className:"max-w-4xl mx-auto text-center relative z-10",children:g.jsxs(Ka.div,{initial:{opacity:0,y:20},animate:{opacity:1,y:0},transition:{duration:.6},children:[g.jsx("div",{className:"mb-4",children:g.jsx(IR,{})}),g.jsxs("h1",{className:"text-6xl md:text-8xl font-display font-bold tracking-tighter leading-[0.95]",children:[g.jsx("span",{className:"text-wm-muted/40",children:_("hero.noiseWord")}),g.jsx("span",{className:"mx-3 md:mx-5 text-wm-border/50",children:"→"}),g.jsx("span",{className:"text-transparent bg-clip-text bg-gradient-to-r from-wm-green to-emerald-300 text-glow",children:_("hero.signalWord")})]}),g.jsx(FR,{}),g.jsx("p",{className:"text-lg md:text-xl text-wm-muted max-w-xl mx-auto font-light leading-relaxed",children:_("hero.valueProps")}),g.jsxs("div",{className:"flex flex-col sm:flex-row gap-3 justify-center mt-8",children:[g.jsxs("a",{href:"#pricing",className:"bg-wm-green text-wm-bg px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors flex items-center justify-center gap-2",children:[_("hero.choosePlan")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]}),a&&g.jsx("button",{type:"button",onClick:wS,className:"border border-wm-border text-wm-text px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:border-wm-text transition-colors",children:_("hero.signIn")})]}),g.jsx("div",{className:"flex items-center justify-center mt-4",children:g.jsxs("a",{href:"https://worldmonitor.app",className:"text-xs text-wm-green font-mono hover:text-green-300 transition-colors flex items-center gap-1",children:[_("hero.tryFreeDashboard")," ",g.jsx(Wi,{className:"w-3 h-3","aria-hidden":"true"})]})})]})})]})},YR=()=>g.jsx("section",{className:"border-y border-wm-border bg-wm-card/30 py-16 px-6",children:g.jsxs("div",{className:"max-w-5xl mx-auto",children:[g.jsx("div",{className:"grid grid-cols-2 md:grid-cols-4 gap-8 text-center mb-12",children:[{value:"2M+",label:_("socialProof.uniqueVisitors")},{value:"421K",label:_("socialProof.peakDailyUsers")},{value:"190+",label:_("socialProof.countriesReached")},{value:"500+",label:_("socialProof.liveDataSources")}].map((n,t)=>g.jsxs("div",{children:[g.jsx("p",{className:"text-3xl md:text-4xl font-display font-bold text-wm-green",children:n.value}),g.jsx("p",{className:"text-xs font-mono text-wm-muted uppercase tracking-widest mt-1",children:n.label})]},t))}),g.jsxs("blockquote",{className:"max-w-3xl mx-auto text-center",children:[g.jsxs("p",{className:"text-lg md:text-xl text-wm-muted italic leading-relaxed",children:['"',_("socialProof.quote"),'"']}),g.jsx("footer",{className:"mt-6 flex items-center justify-center gap-3",children:g.jsx("a",{href:"https://www.wired.me/story/the-music-streaming-ceo-who-built-a-global-war-map",target:"_blank",rel:"noreferrer",className:"inline-flex items-center gap-2 text-wm-muted hover:text-wm-text transition-colors",children:g.jsx("img",{src:LR,alt:"WIRED",className:"h-5 brightness-0 invert opacity-60 hover:opacity-100 transition-opacity"})})})]})]})}),KR=()=>g.jsxs("section",{className:"py-24 px-6 max-w-5xl mx-auto",id:"tiers",children:[g.jsx("h2",{className:"sr-only",children:"Plans"}),g.jsxs("div",{className:"grid md:grid-cols-2 gap-8",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-green p-8 relative border-glow",children:[g.jsx("div",{className:"absolute top-0 left-0 w-full h-1 bg-wm-green"}),g.jsx("h3",{className:"font-display text-2xl font-bold mb-2",children:_("twoPath.proTitle")}),g.jsx("p",{className:"text-sm text-wm-muted mb-6",children:_("twoPath.proDesc")}),g.jsx("ul",{className:"space-y-3 mb-8",children:[_("twoPath.proF1"),_("twoPath.proF2"),_("twoPath.proF3"),_("twoPath.proF4"),_("twoPath.proF5"),_("twoPath.proF6"),_("twoPath.proF7"),_("twoPath.proF8"),_("twoPath.proF9")].map((n,t)=>g.jsxs("li",{className:"flex items-start gap-3 text-sm",children:[g.jsx(Hd,{className:"w-4 h-4 shrink-0 mt-0.5 text-wm-green","aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted",children:n})]},t))}),g.jsx("a",{href:"#pricing",className:"block text-center py-2.5 rounded-sm font-mono text-xs uppercase tracking-wider font-bold bg-wm-green text-wm-bg hover:bg-green-400 transition-colors",children:_("twoPath.choosePlan")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-8",children:[g.jsx("h3",{className:"font-display text-2xl font-bold mb-2",children:_("twoPath.entTitle")}),g.jsx("p",{className:"text-sm text-wm-muted mb-6",children:_("twoPath.entDesc")}),g.jsxs("ul",{className:"space-y-3 mb-8",children:[g.jsx("li",{className:"text-xs font-mono text-wm-green uppercase tracking-wider mb-1",children:_("twoPath.entF1")}),[_("twoPath.entF2"),_("twoPath.entF3"),_("twoPath.entF4"),_("twoPath.entF5"),_("twoPath.entF6"),_("twoPath.entF7"),_("twoPath.entF8"),_("twoPath.entF9"),_("twoPath.entF10"),_("twoPath.entF11")].map((n,t)=>g.jsxs("li",{className:"flex items-start gap-3 text-sm",children:[g.jsx(Hd,{className:"w-4 h-4 shrink-0 mt-0.5 text-wm-muted","aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted",children:n})]},t))]}),g.jsx("a",{href:"#enterprise",className:"block text-center py-2.5 rounded-sm font-mono text-xs uppercase tracking-wider font-bold border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text transition-colors",children:_("twoPath.entCta")})]})]})]}),XR=()=>{const n=[{icon:g.jsx(M3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.noiseTitle"),desc:_("whyUpgrade.noiseDesc")},{icon:g.jsx(uS,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.fasterTitle"),desc:_("whyUpgrade.fasterDesc")},{icon:g.jsx(eO,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.controlTitle"),desc:_("whyUpgrade.controlDesc")},{icon:g.jsx(cS,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.deeperTitle"),desc:_("whyUpgrade.deeperDesc")}];return g.jsx("section",{className:"py-24 px-6 border-t border-wm-border bg-wm-card/20",children:g.jsxs("div",{className:"max-w-5xl mx-auto",children:[g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-16 text-center",children:_("whyUpgrade.title")}),g.jsx("div",{className:"grid md:grid-cols-2 gap-8",children:n.map((t,a)=>g.jsxs("div",{className:"flex gap-5",children:[g.jsx("div",{className:"text-wm-green shrink-0 mt-1",children:t.icon}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold text-lg mb-2",children:t.title}),g.jsx("p",{className:"text-sm text-wm-muted leading-relaxed",children:t.desc})]})]},a))})]})})},$R=()=>{const n=[{icon:g.jsx(h3,{className:"w-7 h-7","aria-hidden":"true"}),title:_("pillars.askIt"),desc:_("pillars.askItDesc")},{icon:g.jsx(f3,{className:"w-7 h-7","aria-hidden":"true"}),title:_("pillars.subscribeIt"),desc:_("pillars.subscribeItDesc")},{icon:g.jsx(Jl,{className:"w-7 h-7","aria-hidden":"true"}),title:_("pillars.buildOnIt"),desc:_("pillars.buildOnItDesc")}];return g.jsx("section",{className:"py-20 px-6 border-t border-wm-border",children:g.jsx("div",{className:"max-w-5xl mx-auto",children:g.jsx("div",{className:"grid md:grid-cols-3 gap-6",children:n.map((t,a)=>g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6 hover:border-wm-green/30 transition-colors",children:[g.jsx("div",{className:"text-wm-green mb-4",children:t.icon}),g.jsx("h3",{className:"font-display text-xl font-bold mb-2",children:t.title}),g.jsx("p",{className:"text-sm text-wm-muted leading-relaxed",children:t.desc})]},a))})})})},QR=()=>g.jsx("section",{className:"py-24 px-6 border-t border-wm-border bg-wm-card/20",children:g.jsxs("div",{className:"max-w-4xl mx-auto text-center",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-green/30 bg-wm-green/10 text-wm-green text-xs font-mono mb-6",children:_("deliveryDesk.eyebrow")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("deliveryDesk.title")}),g.jsx("p",{className:"text-lg text-wm-muted leading-relaxed mb-6",children:_("deliveryDesk.body")}),g.jsx("p",{className:"text-xl md:text-2xl font-display font-bold text-wm-green mb-8",children:_("deliveryDesk.closer")}),g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest",children:_("deliveryDesk.channels")})]})}),ZR=()=>g.jsx("section",{className:"px-6 py-16",children:g.jsxs("div",{className:"max-w-6xl mx-auto",children:[g.jsxs("div",{className:"relative rounded-lg overflow-hidden border border-wm-border shadow-2xl shadow-wm-green/5",children:[g.jsxs("div",{className:"bg-wm-card px-4 py-2 border-b border-wm-border flex items-center gap-3",children:[g.jsxs("div",{className:"flex gap-1.5",children:[g.jsx("div",{className:"w-3 h-3 rounded-full bg-red-500/70"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-yellow-500/70"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-green-500/70"})]}),g.jsx("span",{className:"font-mono text-xs text-wm-muted ml-2",children:_("livePreview.windowTitle")}),g.jsxs("a",{href:"https://worldmonitor.app",target:"_blank",rel:"noreferrer",className:"ml-auto text-xs text-wm-green font-mono hover:text-green-300 transition-colors flex items-center gap-1",children:[_("livePreview.openFullScreen")," ",g.jsx(rS,{className:"w-3 h-3","aria-hidden":"true"})]})]}),g.jsxs("div",{className:"relative aspect-[16/9] bg-black",children:[g.jsx("img",{src:kR,alt:"World Monitor Dashboard",className:"absolute inset-0 w-full h-full object-cover"}),g.jsx("iframe",{src:"https://worldmonitor.app?embed=pro-preview",title:_("livePreview.iframeTitle"),className:"relative w-full h-full border-0",loading:"lazy",sandbox:"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"}),g.jsx("div",{className:"absolute inset-0 pointer-events-none bg-gradient-to-t from-wm-bg/80 via-transparent to-transparent"}),g.jsx("div",{className:"absolute bottom-4 left-0 right-0 text-center pointer-events-auto",children:g.jsxs("a",{href:"https://worldmonitor.app",target:"_blank",rel:"noreferrer",className:"inline-flex items-center gap-2 bg-wm-green text-wm-bg px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:[_("livePreview.tryLiveDashboard")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]})})]})]}),g.jsx("p",{className:"text-center text-xs text-wm-muted font-mono mt-4",children:_("livePreview.description")})]})}),JR=()=>{const t=["Finnhub","FRED","Bloomberg","CNBC","Nikkei","CoinGecko","Polymarket","Reuters","ACLED","UCDP","GDELT","NASA FIRMS","USGS","OpenSky","AISStream","Cloudflare Radar","BGPStream","GPSJam","NOAA","Copernicus","IAEA","Al Jazeera","Sky News","Euronews","DW News","France 24","OilPrice","Rigzone","Maritime Executive","Hellenic Shipping News","Defense One","Jane's","The War Zone","TechCrunch","Ars Technica","The Verge","Wired","Krebs on Security","BleepingComputer","The Record"].join(" · ");return g.jsx("section",{className:"border-y border-wm-border bg-wm-card/20 overflow-hidden py-4","aria-label":"Data sources",children:g.jsxs("div",{className:"marquee-track whitespace-nowrap font-mono text-xs text-wm-muted uppercase tracking-widest",children:[g.jsxs("span",{className:"inline-block px-4",children:[t," · "]}),g.jsxs("span",{className:"inline-block px-4",children:[t," · "]})]})})},WR=()=>g.jsx("section",{className:"py-24 px-6 border-t border-wm-border bg-wm-card/30",id:"pro",children:g.jsxs("div",{className:"max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-start",children:[g.jsxs("div",{children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-green/30 bg-wm-green/10 text-wm-green text-xs font-mono mb-6",children:_("proShowcase.proTier")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("proShowcase.title")}),g.jsx("p",{className:"text-wm-muted mb-8",children:_("proShowcase.subtitle")}),g.jsxs("div",{className:"space-y-6",children:[g.jsxs("div",{className:"flex gap-4",children:[g.jsx(uS,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.equityResearch")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.equityResearchDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Rl,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.geopoliticalAnalysis")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.geopoliticalAnalysisDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Ch,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.economyAnalytics")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.economyAnalyticsDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Mh,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.riskMonitoring")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.riskMonitoringDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(cS,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsxs("h4",{className:"font-bold mb-1",children:[_("proShowcase.orbitalSurveillance"),g.jsx(RR,{})]}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.orbitalSurveillanceDesc").replace(/^\(Soon\)\s*/,"")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(_3,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.morningBriefs")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.morningBriefsDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(k3,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.oneKey")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.oneKeyDesc")})]})]})]}),g.jsxs("div",{className:"mt-10 pt-8 border-t border-wm-border",children:[g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-4",children:_("proShowcase.deliveryLabel")}),g.jsx("div",{className:"flex gap-6",children:[{icon:g.jsx(PR,{}),label:"Slack"},{icon:g.jsx(I3,{className:"w-5 h-5","aria-hidden":"true"}),label:"Discord"},{icon:g.jsx($3,{className:"w-5 h-5","aria-hidden":"true"}),label:"Telegram"},{icon:g.jsx(P3,{className:"w-5 h-5","aria-hidden":"true"}),label:"Email"},{icon:g.jsx(Jl,{className:"w-5 h-5","aria-hidden":"true"}),label:"Webhook"}].map((n,t)=>g.jsxs("div",{className:"flex flex-col items-center gap-1.5 text-wm-muted hover:text-wm-text transition-colors cursor-pointer",children:[n.icon,g.jsx("span",{className:"text-[10px] font-mono",children:n.label})]},t))})]})]}),g.jsxs("div",{className:"bg-[#1a1d21] rounded-lg border border-[#35373b] overflow-hidden shadow-2xl sticky top-24",children:[g.jsxs("div",{className:"bg-[#222529] px-4 py-3 border-b border-[#35373b] flex items-center gap-3",children:[g.jsx("div",{className:"w-3 h-3 rounded-full bg-red-500"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-yellow-500"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-green-500"}),g.jsx("span",{className:"ml-2 font-mono text-xs text-gray-400",children:"#world-monitor-alerts"})]}),g.jsx("div",{className:"p-6 space-y-6 font-sans text-sm",children:g.jsxs("div",{className:"flex gap-4",children:[g.jsx("div",{className:"w-10 h-10 rounded bg-wm-green/20 flex items-center justify-center shrink-0",children:g.jsx(Rl,{className:"w-6 h-6 text-wm-green","aria-hidden":"true"})}),g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2 mb-1",children:[g.jsx("span",{className:"font-bold text-gray-200",children:"World Monitor"}),g.jsx("span",{className:"text-xs text-gray-500 bg-gray-800 px-1 rounded",children:"APP"}),g.jsx("span",{className:"text-xs text-gray-500",children:"8:00 AM"})]}),g.jsxs("p",{className:"text-gray-300 font-bold mb-3",children:[_("slackMock.morningBrief")," · Mar 6"]}),g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"pl-3 border-l-2 border-blue-500",children:[g.jsx("span",{className:"text-blue-400 font-bold text-xs uppercase tracking-wider",children:_("slackMock.markets")}),g.jsx("p",{className:"text-gray-300 mt-1",children:_("slackMock.marketsText")})]}),g.jsxs("div",{className:"pl-3 border-l-2 border-orange-500",children:[g.jsx("span",{className:"text-orange-400 font-bold text-xs uppercase tracking-wider",children:_("slackMock.elevated")}),g.jsx("p",{className:"text-gray-300 mt-1",children:_("slackMock.elevatedText")})]}),g.jsxs("div",{className:"pl-3 border-l-2 border-yellow-500",children:[g.jsx("span",{className:"text-yellow-400 font-bold text-xs uppercase tracking-wider",children:_("slackMock.watch")}),g.jsx("p",{className:"text-gray-300 mt-1",children:_("slackMock.watchText")})]})]})]})]})})]})]})}),e4=()=>{const n=[{icon:g.jsx(v3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.investorsTitle"),desc:_("audience.investorsDesc")},{icon:g.jsx(j3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.tradersTitle"),desc:_("audience.tradersDesc")},{icon:g.jsx(K3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.researchersTitle"),desc:_("audience.researchersDesc")},{icon:g.jsx(Rl,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.journalistsTitle"),desc:_("audience.journalistsDesc")},{icon:g.jsx(z3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.govTitle"),desc:_("audience.govDesc")},{icon:g.jsx(p3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.teamsTitle"),desc:_("audience.teamsDesc")}];return g.jsx("section",{className:"py-24 px-6",children:g.jsxs("div",{className:"max-w-5xl mx-auto",children:[g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-16 text-center",children:_("audience.title")}),g.jsx("div",{className:"grid md:grid-cols-3 gap-6",children:n.map((t,a)=>g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6 hover:border-wm-green/30 transition-colors",children:[g.jsx("div",{className:"text-wm-green mb-4",children:t.icon}),g.jsx("h3",{className:"font-bold mb-2",children:t.title}),g.jsx("p",{className:"text-sm text-wm-muted",children:t.desc})]},a))})]})})},t4=()=>g.jsx("section",{className:"py-24 px-6 border-y border-wm-border bg-[#0a0a0a]",id:"api",children:g.jsxs("div",{className:"max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-center",children:[g.jsx("div",{className:"order-2 lg:order-1",children:g.jsxs("div",{className:"bg-black border border-wm-border rounded-lg overflow-hidden font-mono text-sm",children:[g.jsxs("div",{className:"bg-wm-card px-4 py-2 border-b border-wm-border flex items-center gap-2",children:[g.jsx(iO,{className:"w-4 h-4 text-wm-muted","aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted text-xs",children:"api.worldmonitor.app"})]}),g.jsx("div",{className:"p-6 text-gray-300 overflow-x-auto",children:g.jsx("pre",{children:g.jsxs("code",{children:[g.jsx("span",{className:"text-wm-blue",children:"curl"})," \\",g.jsx("br",{}),g.jsx("span",{className:"text-wm-green",children:'"https://api.worldmonitor.app/v1/intelligence/convergence?region=MENA&time_window=6h"'})," \\",g.jsx("br",{}),"-H ",g.jsx("span",{className:"text-wm-green",children:'"Authorization: Bearer wm_live_xxx"'}),g.jsx("br",{}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"{"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"status"'}),": ",g.jsx("span",{className:"text-wm-green",children:'"success"'}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"data"'}),": ",g.jsx("span",{className:"text-wm-muted",children:"["}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"{"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"type"'}),": ",g.jsx("span",{className:"text-wm-green",children:'"multi_signal_convergence"'}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"signals"'}),": ",g.jsx("span",{className:"text-wm-muted",children:'["military_flights", "ais_dark_ships", "oref_sirens"]'}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"confidence"'}),": ",g.jsx("span",{className:"text-orange-400",children:"0.92"}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"location"'}),": ",g.jsx("span",{className:"text-wm-muted",children:"{"})," ",g.jsx("span",{className:"text-wm-blue",children:'"lat"'}),": ",g.jsx("span",{className:"text-orange-400",children:"34.05"}),", ",g.jsx("span",{className:"text-wm-blue",children:'"lng"'}),": ",g.jsx("span",{className:"text-orange-400",children:"35.12"})," ",g.jsx("span",{className:"text-wm-muted",children:"}"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"}"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"]"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"}"})]})})})]})}),g.jsxs("div",{className:"order-1 lg:order-2",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-border bg-wm-card text-wm-muted text-xs font-mono mb-6",children:_("apiSection.apiTier")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("apiSection.title")}),g.jsx("p",{className:"text-wm-muted mb-8",children:_("apiSection.subtitle")}),g.jsxs("ul",{className:"space-y-4 mb-8",children:[g.jsxs("li",{className:"flex items-start gap-3",children:[g.jsx(Z3,{className:"w-5 h-5 text-wm-muted shrink-0","aria-hidden":"true"}),g.jsx("span",{className:"text-sm",children:_("apiSection.restApi")})]}),g.jsxs("li",{className:"flex items-start gap-3",children:[g.jsx(U3,{className:"w-5 h-5 text-wm-muted shrink-0","aria-hidden":"true"}),g.jsx("span",{className:"text-sm",children:_("apiSection.authenticated")})]}),g.jsxs("li",{className:"flex items-start gap-3",children:[g.jsx(A3,{className:"w-5 h-5 text-wm-muted shrink-0","aria-hidden":"true"}),g.jsx("span",{className:"text-sm",children:_("apiSection.structured")})]})]}),g.jsxs("div",{className:"grid grid-cols-2 gap-4 mb-8 p-4 bg-wm-card border border-wm-border rounded-sm",children:[g.jsxs("div",{children:[g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("apiSection.starter")}),g.jsx("p",{className:"text-sm font-bold",children:_("apiSection.starterReqs")}),g.jsx("p",{className:"text-xs text-wm-muted",children:_("apiSection.starterWebhooks")})]}),g.jsxs("div",{children:[g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("apiSection.business")}),g.jsx("p",{className:"text-sm font-bold",children:_("apiSection.businessReqs")}),g.jsx("p",{className:"text-xs text-wm-muted",children:_("apiSection.businessWebhooks")})]})]}),g.jsx("p",{className:"text-sm text-wm-muted border-l-2 border-wm-border pl-4",children:_("apiSection.feedData")})]})]})}),n4=()=>g.jsx("section",{className:"py-24 px-6",id:"enterprise",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsxs("div",{className:"text-center mb-16",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-border bg-wm-card text-wm-muted text-xs font-mono mb-6",children:_("enterpriseShowcase.enterpriseTier")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("enterpriseShowcase.title")}),g.jsx("p",{className:"text-wm-muted max-w-2xl mx-auto",children:_("enterpriseShowcase.subtitle")})]}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-6",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Mh,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.security")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.securityDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(sS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.aiAgents")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.aiAgentsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(oS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.dataLayers")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.dataLayersDesc")})]})]}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-12",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Jl,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.connectors")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.connectorsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(lS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.whiteLabel")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.whiteLabelDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Ch,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.financial")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.financialDesc")})]})]}),g.jsxs("div",{className:"data-grid mb-12",children:[g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.commodity")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.commodityDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.government")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.governmentDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.risk")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.riskDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.soc")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.socDesc")})]})]}),g.jsx("div",{className:"text-center mt-12",children:g.jsxs("a",{href:"#enterprise-contact","aria-label":"Talk to sales about Enterprise plans",className:"inline-flex items-center gap-2 bg-wm-green text-wm-bg px-8 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:[_("enterpriseShowcase.talkToSales")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]})})]})}),i4=()=>{const n=[{feature:_("pricingTable.dataRefresh"),free:_("pricingTable.f5_15min"),pro:_("pricingTable.fLt60s"),api:_("pricingTable.fPerRequest"),ent:_("pricingTable.fLiveEdge")},{feature:_("pricingTable.dashboard"),free:_("pricingTable.f50panels"),pro:_("pricingTable.f50panels"),api:"—",ent:_("pricingTable.fWhiteLabel")},{feature:_("pricingTable.ai"),free:_("pricingTable.fBYOK"),pro:_("pricingTable.fIncluded"),api:"—",ent:_("pricingTable.fAgentsPersonas")},{feature:_("pricingTable.briefsAlerts"),free:"—",pro:_("pricingTable.fDailyFlash"),api:"—",ent:_("pricingTable.fTeamDist")},{feature:_("pricingTable.delivery"),free:"—",pro:_("pricingTable.fSlackTgWa"),api:_("pricingTable.fWebhook"),ent:_("pricingTable.fSiemMcp")},{feature:_("pricingTable.apiRow"),free:"—",pro:"—",api:_("pricingTable.fRestWebhook"),ent:_("pricingTable.fMcpBulk")},{feature:_("pricingTable.infraLayers"),free:_("pricingTable.f45"),pro:_("pricingTable.f45"),api:"—",ent:_("pricingTable.fTensOfThousands")},{feature:_("pricingTable.satellite"),free:_("pricingTable.fLiveTracking"),pro:_("pricingTable.fPassAlerts"),api:"—",ent:_("pricingTable.fImagerySar")},{feature:_("pricingTable.connectorsRow"),free:"—",pro:"—",api:"—",ent:_("pricingTable.f100plus")},{feature:_("pricingTable.deployment"),free:_("pricingTable.fCloud"),pro:_("pricingTable.fCloud"),api:_("pricingTable.fCloud"),ent:_("pricingTable.fCloudOnPrem")},{feature:_("pricingTable.securityRow"),free:_("pricingTable.fStandard"),pro:_("pricingTable.fStandard"),api:_("pricingTable.fKeyAuth"),ent:_("pricingTable.fSsoMfa")}];return g.jsxs("section",{className:"py-24 px-6 max-w-7xl mx-auto",children:[g.jsx("div",{className:"text-center mb-16",children:g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("pricingTable.title")})}),g.jsxs("div",{className:"hidden md:block",children:[g.jsxs("div",{className:"grid grid-cols-5 gap-4 mb-4 pb-4 border-b border-wm-border font-mono text-xs uppercase tracking-widest text-wm-muted",children:[g.jsx("div",{children:_("pricingTable.feature")}),g.jsx("div",{children:_("pricingTable.freeHeader")}),g.jsx("div",{className:"text-wm-green",children:_("pricingTable.proHeader")}),g.jsx("div",{children:_("pricingTable.apiHeader")}),g.jsx("div",{children:_("pricingTable.entHeader")})]}),n.map((t,a)=>g.jsxs("div",{className:"grid grid-cols-5 gap-4 py-4 border-b border-wm-border/50 text-sm hover:bg-wm-card/50 transition-colors",children:[g.jsx("div",{className:"font-medium",children:t.feature}),g.jsx("div",{className:"text-wm-muted",children:t.free}),g.jsx("div",{className:"text-wm-green",children:t.pro}),g.jsx("div",{className:"text-wm-muted",children:t.api}),g.jsx("div",{className:"text-wm-muted",children:t.ent})]},a))]}),g.jsx("div",{className:"md:hidden space-y-4",children:n.map((t,a)=>g.jsxs("div",{className:"bg-wm-card border border-wm-border p-4 rounded-sm",children:[g.jsx("p",{className:"font-medium text-sm mb-3",children:t.feature}),g.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-muted",children:[_("tiers.free"),":"]})," ",t.free]}),g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-green",children:[_("tiers.pro"),":"]})," ",g.jsx("span",{className:"text-wm-green",children:t.pro})]}),g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-muted",children:[_("nav.api"),":"]})," ",t.api]}),g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-muted",children:[_("tiers.enterprise"),":"]})," ",t.ent]})]})]},a))}),g.jsx("p",{className:"text-center text-sm text-wm-muted mt-8",children:_("pricingTable.noteBelow")})]})},a4=()=>{const n=[{q:_("faq.q1"),a:_("faq.a1"),open:!0},{q:_("faq.q2"),a:_("faq.a2")},{q:_("faq.q3"),a:_("faq.a3")},{q:_("faq.q4"),a:_("faq.a4")},{q:_("faq.q5"),a:_("faq.a5")},{q:_("faq.q6"),a:_("faq.a6")},{q:_("faq.q7"),a:_("faq.a7")},{q:_("faq.q8"),a:_("faq.a8")},{q:_("faq.q9"),a:_("faq.a9")},{q:_("faq.q10"),a:_("faq.a10")},{q:_("faq.q11"),a:_("faq.a11")},{q:_("faq.q12"),a:_("faq.a12")},{q:_("faq.q13"),a:_("faq.a13")}];return g.jsxs("section",{className:"py-24 px-6 max-w-3xl mx-auto",children:[g.jsx("h2",{className:"text-3xl font-display font-bold mb-12 text-center",children:_("faq.title")}),g.jsx("div",{className:"space-y-4",children:n.map((t,a)=>g.jsxs("details",{open:t.open,className:"group bg-wm-card border border-wm-border rounded-sm [&_summary::-webkit-details-marker]:hidden",children:[g.jsxs("summary",{className:"flex items-center justify-between p-6 cursor-pointer font-medium",children:[t.q,g.jsx(S3,{className:"w-5 h-5 text-wm-muted group-open:rotate-180 transition-transform","aria-hidden":"true"})]}),g.jsx("div",{className:"px-6 pb-6 text-wm-muted text-sm border-t border-wm-border pt-4 mt-2",children:t.a})]},a))})]})},s4=()=>g.jsx("footer",{className:"border-t border-wm-border bg-[#020202] pt-8 pb-12 px-6 text-center",children:g.jsxs("div",{className:"flex flex-col md:flex-row items-center justify-between max-w-7xl mx-auto text-xs text-wm-muted font-mono",children:[g.jsxs("div",{className:"flex items-center gap-3 mb-4 md:mb-0",children:[g.jsx("img",{src:"/favico/favicon-32x32.png",alt:"",width:"28",height:"28",className:"rounded-full"}),g.jsxs("div",{className:"flex flex-col",children:[g.jsx("span",{className:"font-display font-bold text-sm leading-none tracking-tight text-wm-text",children:"WORLD MONITOR"}),g.jsx("span",{className:"text-[9px] uppercase tracking-[2px] opacity-60 mt-0.5",children:"by Someone.ceo"})]})]}),g.jsxs("div",{className:"flex items-center gap-6",children:[g.jsx("a",{href:"/",className:"hover:text-wm-text transition-colors",children:"Dashboard"}),g.jsx("a",{href:"https://www.worldmonitor.app/blog/",className:"hover:text-wm-text transition-colors",children:"Blog"}),g.jsx("a",{href:"https://www.worldmonitor.app/docs",className:"hover:text-wm-text transition-colors",children:"Docs"}),g.jsx("a",{href:"https://status.worldmonitor.app/",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Status"}),g.jsx("a",{href:"https://github.com/koala73/worldmonitor",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"GitHub"}),g.jsx("a",{href:"https://discord.gg/re63kWKxaz",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Discord"}),g.jsx("a",{href:"https://x.com/worldmonitorai",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"X"})]}),g.jsxs("span",{className:"text-[10px] opacity-40 mt-4 md:mt-0",children:["© ",new Date().getFullYear()," WorldMonitor"]})]})}),r4=()=>g.jsxs("div",{className:"min-h-screen selection:bg-wm-green/30 selection:text-wm-green",children:[g.jsx("nav",{className:"fixed top-0 left-0 right-0 z-50 glass-panel border-b-0 border-x-0 rounded-none","aria-label":"Main navigation",children:g.jsxs("div",{className:"max-w-7xl mx-auto px-6 h-16 flex items-center justify-between",children:[g.jsx("a",{href:"#",onClick:n=>{n.preventDefault(),window.location.hash=""},children:g.jsx(TS,{})}),g.jsxs("div",{className:"hidden md:flex items-center gap-8 text-sm font-mono text-wm-muted",children:[g.jsx("a",{href:"#",onClick:n=>{n.preventDefault(),window.location.hash=""},className:"hover:text-wm-text transition-colors",children:_("nav.pro")}),g.jsx("a",{href:"#enterprise",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("features"))==null||t.scrollIntoView({behavior:"smooth"})},className:"hover:text-wm-text transition-colors",children:_("nav.enterprise")}),g.jsx("a",{href:"#enterprise-contact",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("contact"))==null||t.scrollIntoView({behavior:"smooth"})},className:"hover:text-wm-green transition-colors",children:_("enterpriseShowcase.talkToSales")})]}),g.jsx("a",{href:"#enterprise-contact",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("contact"))==null||t.scrollIntoView({behavior:"smooth"})},className:"bg-wm-green text-wm-bg px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:_("enterpriseShowcase.talkToSales")})]})}),g.jsxs("main",{className:"pt-24",children:[g.jsx("section",{className:"py-24 px-6 text-center",children:g.jsxs("div",{className:"max-w-4xl mx-auto",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-border bg-wm-card text-wm-muted text-xs font-mono mb-6",children:_("enterpriseShowcase.enterpriseTier")}),g.jsx("h2",{className:"text-4xl md:text-6xl font-display font-bold mb-6",children:_("enterpriseShowcase.title")}),g.jsx("p",{className:"text-lg text-wm-muted max-w-2xl mx-auto mb-10",children:_("enterpriseShowcase.subtitle")}),g.jsxs("a",{href:"#enterprise-contact",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("contact"))==null||t.scrollIntoView({behavior:"smooth"})},className:"inline-flex items-center gap-2 bg-wm-green text-wm-bg px-8 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:[_("enterpriseShowcase.talkToSales")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]})]})}),g.jsx("section",{className:"py-24 px-6",id:"features",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsx("h2",{className:"sr-only",children:"Enterprise Features"}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-6",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Mh,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.security")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.securityDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(sS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.aiAgents")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.aiAgentsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(oS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.dataLayers")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.dataLayersDesc")})]})]}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-12",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Jl,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.connectors")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.connectorsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(lS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.whiteLabel")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.whiteLabelDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Ch,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.financial")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.financialDesc")})]})]})]})}),g.jsx("section",{className:"py-24 px-6 border-t border-wm-border",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsx("h2",{className:"text-3xl font-display font-bold mb-12 text-center",children:_("enterpriseShowcase.title")}),g.jsxs("div",{className:"data-grid",children:[g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.commodity")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.commodityDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.government")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.governmentDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.risk")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.riskDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.soc")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.socDesc")})]})]})]})}),g.jsx("section",{className:"py-24 px-6 border-t border-wm-border",id:"contact",children:g.jsxs("div",{className:"max-w-xl mx-auto",children:[g.jsx("h2",{className:"font-display text-3xl font-bold mb-2 text-center",children:_("enterpriseShowcase.contactFormTitle")}),g.jsx("p",{className:"text-sm text-wm-muted mb-10 text-center",children:_("enterpriseShowcase.contactFormSubtitle")}),g.jsxs("form",{className:"space-y-4",onSubmit:async n=>{var m;n.preventDefault();const t=n.currentTarget,a=t.querySelector('button[type="submit"]'),r=a.textContent;a.disabled=!0,a.textContent=_("enterpriseShowcase.contactSending");const l=new FormData(t),c=((m=t.querySelector('input[name="website"]'))==null?void 0:m.value)||"",f=t.querySelector(".cf-turnstile"),h=(f==null?void 0:f.dataset.token)||"";try{const p=await fetch(`${zR}/contact`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:l.get("email"),name:l.get("name"),organization:l.get("organization"),phone:l.get("phone"),message:l.get("message"),source:"enterprise-contact",website:c,turnstileToken:h})}),v=t.querySelector("[data-form-error]");if(!p.ok){const x=await p.json().catch(()=>({}));if(p.status===422&&v){v.textContent=x.error||_("enterpriseShowcase.workEmailRequired"),v.classList.remove("hidden"),a.textContent=r,a.disabled=!1;return}throw new Error}v&&v.classList.add("hidden"),a.textContent=_("enterpriseShowcase.contactSent"),a.className=a.className.replace("bg-wm-green","bg-wm-card border border-wm-green text-wm-green")}catch{a.textContent=_("enterpriseShowcase.contactFailed"),a.disabled=!1,f!=null&&f.dataset.widgetId&&window.turnstile&&(window.turnstile.reset(f.dataset.widgetId),delete f.dataset.token),setTimeout(()=>{a.textContent=r},4e3)}},children:[g.jsx("input",{type:"text",name:"website",autoComplete:"off",tabIndex:-1,"aria-hidden":"true",className:"absolute opacity-0 h-0 w-0 pointer-events-none"}),g.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[g.jsx("input",{type:"text",name:"name",placeholder:_("enterpriseShowcase.namePlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"}),g.jsx("input",{type:"email",name:"email",placeholder:_("enterpriseShowcase.emailPlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"})]}),g.jsx("span",{"data-form-error":!0,className:"hidden text-red-400 text-xs font-mono block"}),g.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[g.jsx("input",{type:"text",name:"organization",placeholder:_("enterpriseShowcase.orgPlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"}),g.jsx("input",{type:"tel",name:"phone",placeholder:_("enterpriseShowcase.phonePlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"})]}),g.jsx("textarea",{name:"message",placeholder:_("enterpriseShowcase.messagePlaceholder"),rows:4,className:"w-full bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono resize-none"}),g.jsx("div",{className:"cf-turnstile mx-auto"}),g.jsx("button",{type:"submit",className:"w-full bg-wm-green text-wm-bg py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:_("enterpriseShowcase.submitContact")})]})]})})]}),g.jsx("footer",{className:"border-t border-wm-border bg-[#020202] py-8 px-6 text-center",children:g.jsxs("div",{className:"flex flex-col md:flex-row items-center justify-between max-w-7xl mx-auto text-xs text-wm-muted font-mono",children:[g.jsxs("div",{className:"flex items-center gap-3 mb-4 md:mb-0",children:[g.jsx("img",{src:"/favico/favicon-32x32.png",alt:"",width:"28",height:"28",className:"rounded-full"}),g.jsxs("div",{className:"flex flex-col",children:[g.jsx("span",{className:"font-display font-bold text-sm leading-none tracking-tight text-wm-text",children:"WORLD MONITOR"}),g.jsx("span",{className:"text-[9px] uppercase tracking-[2px] opacity-60 mt-0.5",children:"by Someone.ceo"})]})]}),g.jsxs("div",{className:"flex items-center gap-6",children:[g.jsx("a",{href:"/",className:"hover:text-wm-text transition-colors",children:"Dashboard"}),g.jsx("a",{href:"https://www.worldmonitor.app/blog/",className:"hover:text-wm-text transition-colors",children:"Blog"}),g.jsx("a",{href:"https://www.worldmonitor.app/docs",className:"hover:text-wm-text transition-colors",children:"Docs"}),g.jsx("a",{href:"https://status.worldmonitor.app/",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Status"}),g.jsx("a",{href:"https://github.com/koala73/worldmonitor",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"GitHub"}),g.jsx("a",{href:"https://discord.gg/re63kWKxaz",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Discord"}),g.jsx("a",{href:"https://x.com/worldmonitorai",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"X"})]}),g.jsxs("span",{className:"text-[10px] opacity-40 mt-4 md:mt-0",children:["© ",new Date().getFullYear()," WorldMonitor"]})]})})]});function o4(){const[n,t]=Z.useState(()=>window.location.hash.startsWith("#enterprise")?"enterprise":"home");return Z.useEffect(()=>{_R(()=>{const a=document.createElement("div");Object.assign(a.style,{position:"fixed",top:"0",left:"0",right:"0",zIndex:"99999",padding:"14px 20px",background:"linear-gradient(135deg, #16a34a, #22c55e)",color:"#fff",fontWeight:"600",fontSize:"14px",textAlign:"center",boxShadow:"0 2px 12px rgba(0,0,0,0.3)",transition:"opacity 0.4s ease, transform 0.4s ease",transform:"translateY(-100%)",opacity:"0"}),a.textContent="Payment received! Unlocking your premium features...",document.body.appendChild(a),requestAnimationFrame(()=>{a.style.transform="translateY(0)",a.style.opacity="1"}),setTimeout(()=>{window.location.href="https://worldmonitor.app"},3e3)})},[]),Z.useEffect(()=>{const a=()=>{const r=window.location.hash,l=r.startsWith("#enterprise")?"enterprise":"home",c=n==="enterprise";t(l),l==="enterprise"&&!c&&window.scrollTo(0,0),r==="#enterprise-contact"&&setTimeout(()=>{var f;(f=document.getElementById("contact"))==null||f.scrollIntoView({behavior:"smooth"})},c?0:100)};return window.addEventListener("hashchange",a),()=>window.removeEventListener("hashchange",a)},[n]),Z.useEffect(()=>{n==="enterprise"&&window.location.hash==="#enterprise-contact"&&setTimeout(()=>{var a;(a=document.getElementById("contact"))==null||a.scrollIntoView({behavior:"smooth"})},100)},[]),n==="enterprise"?g.jsx(r4,{}):g.jsxs("div",{className:"min-h-screen selection:bg-wm-green/30 selection:text-wm-green",children:[g.jsx(qR,{}),g.jsxs("main",{children:[g.jsx(GR,{}),g.jsx(JR,{}),g.jsx($R,{}),g.jsx(XR,{}),g.jsx(KR,{}),g.jsx(WR,{}),g.jsx(QR,{}),g.jsx(e4,{}),g.jsx(YR,{}),g.jsx(ZR,{}),g.jsx(OR,{refCode:UR()}),g.jsx(i4,{}),g.jsx(t4,{}),g.jsx(n4,{}),g.jsx(a4,{})]}),g.jsx(s4,{})]})}const l4=void 0;JN({dsn:void 0,environment:location.hostname==="worldmonitor.app"||location.hostname.endsWith(".worldmonitor.app")?"production":location.hostname.includes("vercel.app")?"preview":"development",enabled:!!l4&&!location.hostname.startsWith("localhost"),allowUrls:[/https?:\/\/(www\.|tech\.|finance\.|commodity\.|happy\.)?worldmonitor\.app/,/https?:\/\/.*\.vercel\.app/],tracesSampleRate:.1,ignoreErrors:[/ResizeObserver loop/,/^TypeError: Load failed/,/^TypeError: Failed to fetch/,/^TypeError: NetworkError/,/Non-Error promise rejection captured with value:/]});const c4='script[src^="https://challenges.cloudflare.com/turnstile/v0/api.js"]';bR().then(()=>{sD.createRoot(document.getElementById("root")).render(g.jsx(Z.StrictMode,{children:g.jsx(o4,{})}));const n=()=>window.turnstile?BR()>0:!1,t=document.querySelector(c4);if(t==null||t.addEventListener("load",()=>{n()},{once:!0}),!n()){let a=0;const r=window.setInterval(()=>{(n()||++a>=20)&&window.clearInterval(r)},500)}window.addEventListener("hashchange",()=>{let a=0;const r=()=>{n()||++a>=10||setTimeout(r,200)};setTimeout(r,100)})}); + */const sO=[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]],rO=we("zap",sO),oO="modulepreload",lO=function(n){return"/pro/"+n},tx={},Ie=function(t,a,r){let l=Promise.resolve();if(a&&a.length>0){let f=function(p){return Promise.all(p.map(v=>Promise.resolve(v).then(x=>({status:"fulfilled",value:x}),x=>({status:"rejected",reason:x}))))};document.getElementsByTagName("link");const h=document.querySelector("meta[property=csp-nonce]"),m=(h==null?void 0:h.nonce)||(h==null?void 0:h.getAttribute("nonce"));l=f(a.map(p=>{if(p=lO(p),p in tx)return;tx[p]=!0;const v=p.endsWith(".css"),x=v?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${p}"]${x}`))return;const S=document.createElement("link");if(S.rel=v?"stylesheet":oO,v||(S.as="script"),S.crossOrigin="",S.href=p,m&&S.setAttribute("nonce",m),document.head.appendChild(S),v)return new Promise((w,T)=>{S.addEventListener("load",w),S.addEventListener("error",()=>T(new Error(`Unable to preload CSS for ${p}`)))})}))}function c(f){const h=new Event("vite:preloadError",{cancelable:!0});if(h.payload=f,window.dispatchEvent(h),!h.defaultPrevented)throw f}return l.then(f=>{for(const h of f||[])h.status==="rejected"&&c(h.reason);return t().catch(c)})};var Qf={};const re=n=>typeof n=="string",rr=()=>{let n,t;const a=new Promise((r,l)=>{n=r,t=l});return a.resolve=n,a.reject=t,a},nx=n=>n==null?"":""+n,cO=(n,t,a)=>{n.forEach(r=>{t[r]&&(a[r]=t[r])})},uO=/###/g,ix=n=>n&&n.indexOf("###")>-1?n.replace(uO,"."):n,ax=n=>!n||re(n),mr=(n,t,a)=>{const r=re(t)?t.split("."):t;let l=0;for(;l{const{obj:r,k:l}=mr(n,t,Object);if(r!==void 0||t.length===1){r[l]=a;return}let c=t[t.length-1],f=t.slice(0,t.length-1),h=mr(n,f,Object);for(;h.obj===void 0&&f.length;)c=`${f[f.length-1]}.${c}`,f=f.slice(0,f.length-1),h=mr(n,f,Object),h!=null&&h.obj&&typeof h.obj[`${h.k}.${c}`]<"u"&&(h.obj=void 0);h.obj[`${h.k}.${c}`]=a},fO=(n,t,a,r)=>{const{obj:l,k:c}=mr(n,t,Object);l[c]=l[c]||[],l[c].push(a)},kl=(n,t)=>{const{obj:a,k:r}=mr(n,t);if(a&&Object.prototype.hasOwnProperty.call(a,r))return a[r]},dO=(n,t,a)=>{const r=kl(n,a);return r!==void 0?r:kl(t,a)},fS=(n,t,a)=>{for(const r in t)r!=="__proto__"&&r!=="constructor"&&(r in n?re(n[r])||n[r]instanceof String||re(t[r])||t[r]instanceof String?a&&(n[r]=t[r]):fS(n[r],t[r],a):n[r]=t[r]);return n},Ii=n=>n.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&");var hO={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};const mO=n=>re(n)?n.replace(/[&<>"'\/]/g,t=>hO[t]):n;class pO{constructor(t){this.capacity=t,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(t){const a=this.regExpMap.get(t);if(a!==void 0)return a;const r=new RegExp(t);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(t,r),this.regExpQueue.push(t),r}}const gO=[" ",",","?","!",";"],yO=new pO(20),vO=(n,t,a)=>{t=t||"",a=a||"";const r=gO.filter(f=>t.indexOf(f)<0&&a.indexOf(f)<0);if(r.length===0)return!0;const l=yO.getRegExp(`(${r.map(f=>f==="?"?"\\?":f).join("|")})`);let c=!l.test(n);if(!c){const f=n.indexOf(a);f>0&&!l.test(n.substring(0,f))&&(c=!0)}return c},Pd=(n,t,a=".")=>{if(!n)return;if(n[t])return Object.prototype.hasOwnProperty.call(n,t)?n[t]:void 0;const r=t.split(a);let l=n;for(let c=0;c-1&&mn==null?void 0:n.replace(/_/g,"-"),xO={type:"logger",log(n){this.output("log",n)},warn(n){this.output("warn",n)},error(n){this.output("error",n)},output(n,t){var a,r;(r=(a=console==null?void 0:console[n])==null?void 0:a.apply)==null||r.call(a,console,t)}};class Ll{constructor(t,a={}){this.init(t,a)}init(t,a={}){this.prefix=a.prefix||"i18next:",this.logger=t||xO,this.options=a,this.debug=a.debug}log(...t){return this.forward(t,"log","",!0)}warn(...t){return this.forward(t,"warn","",!0)}error(...t){return this.forward(t,"error","")}deprecate(...t){return this.forward(t,"warn","WARNING DEPRECATED: ",!0)}forward(t,a,r,l){return l&&!this.debug?null:(re(t[0])&&(t[0]=`${r}${this.prefix} ${t[0]}`),this.logger[a](t))}create(t){return new Ll(this.logger,{prefix:`${this.prefix}:${t}:`,...this.options})}clone(t){return t=t||this.options,t.prefix=t.prefix||this.prefix,new Ll(this.logger,t)}}var mn=new Ll;class Wl{constructor(){this.observers={}}on(t,a){return t.split(" ").forEach(r=>{this.observers[r]||(this.observers[r]=new Map);const l=this.observers[r].get(a)||0;this.observers[r].set(a,l+1)}),this}off(t,a){if(this.observers[t]){if(!a){delete this.observers[t];return}this.observers[t].delete(a)}}emit(t,...a){this.observers[t]&&Array.from(this.observers[t].entries()).forEach(([l,c])=>{for(let f=0;f{for(let f=0;f-1&&this.options.ns.splice(a,1)}getResource(t,a,r,l={}){var p,v;const c=l.keySeparator!==void 0?l.keySeparator:this.options.keySeparator,f=l.ignoreJSONStructure!==void 0?l.ignoreJSONStructure:this.options.ignoreJSONStructure;let h;t.indexOf(".")>-1?h=t.split("."):(h=[t,a],r&&(Array.isArray(r)?h.push(...r):re(r)&&c?h.push(...r.split(c)):h.push(r)));const m=kl(this.data,h);return!m&&!a&&!r&&t.indexOf(".")>-1&&(t=h[0],a=h[1],r=h.slice(2).join(".")),m||!f||!re(r)?m:Pd((v=(p=this.data)==null?void 0:p[t])==null?void 0:v[a],r,c)}addResource(t,a,r,l,c={silent:!1}){const f=c.keySeparator!==void 0?c.keySeparator:this.options.keySeparator;let h=[t,a];r&&(h=h.concat(f?r.split(f):r)),t.indexOf(".")>-1&&(h=t.split("."),l=a,a=h[1]),this.addNamespaces(a),sx(this.data,h,l),c.silent||this.emit("added",t,a,r,l)}addResources(t,a,r,l={silent:!1}){for(const c in r)(re(r[c])||Array.isArray(r[c]))&&this.addResource(t,a,c,r[c],{silent:!0});l.silent||this.emit("added",t,a,r)}addResourceBundle(t,a,r,l,c,f={silent:!1,skipCopy:!1}){let h=[t,a];t.indexOf(".")>-1&&(h=t.split("."),l=r,r=a,a=h[1]),this.addNamespaces(a);let m=kl(this.data,h)||{};f.skipCopy||(r=JSON.parse(JSON.stringify(r))),l?fS(m,r,c):m={...m,...r},sx(this.data,h,m),f.silent||this.emit("added",t,a,r)}removeResourceBundle(t,a){this.hasResourceBundle(t,a)&&delete this.data[t][a],this.removeNamespaces(a),this.emit("removed",t,a)}hasResourceBundle(t,a){return this.getResource(t,a)!==void 0}getResourceBundle(t,a){return a||(a=this.options.defaultNS),this.getResource(t,a)}getDataByLanguage(t){return this.data[t]}hasLanguageSomeTranslations(t){const a=this.getDataByLanguage(t);return!!(a&&Object.keys(a)||[]).find(l=>a[l]&&Object.keys(a[l]).length>0)}toJSON(){return this.data}}var dS={processors:{},addPostProcessor(n){this.processors[n.name]=n},handle(n,t,a,r,l){return n.forEach(c=>{var f;t=((f=this.processors[c])==null?void 0:f.process(t,a,r,l))??t}),t}};const hS=Symbol("i18next/PATH_KEY");function bO(){const n=[],t=Object.create(null);let a;return t.get=(r,l)=>{var c;return(c=a==null?void 0:a.revoke)==null||c.call(a),l===hS?n:(n.push(l),a=Proxy.revocable(r,t),a.proxy)},Proxy.revocable(Object.create(null),t).proxy}function Xa(n,t){const{[hS]:a}=n(bO()),r=(t==null?void 0:t.keySeparator)??".",l=(t==null?void 0:t.nsSeparator)??":";if(a.length>1&&l){const c=t==null?void 0:t.ns,f=Array.isArray(c)?c:null;if(f&&f.length>1&&f.slice(1).includes(a[0]))return`${a[0]}${l}${a.slice(1).join(r)}`}return a.join(r)}const ox={},Zf=n=>!re(n)&&typeof n!="boolean"&&typeof n!="number";class zl extends Wl{constructor(t,a={}){super(),cO(["resourceStore","languageUtils","pluralResolver","interpolator","backendConnector","i18nFormat","utils"],t,this),this.options=a,this.options.keySeparator===void 0&&(this.options.keySeparator="."),this.logger=mn.create("translator")}changeLanguage(t){t&&(this.language=t)}exists(t,a={interpolation:{}}){const r={...a};if(t==null)return!1;const l=this.resolve(t,r);if((l==null?void 0:l.res)===void 0)return!1;const c=Zf(l.res);return!(r.returnObjects===!1&&c)}extractFromKey(t,a){let r=a.nsSeparator!==void 0?a.nsSeparator:this.options.nsSeparator;r===void 0&&(r=":");const l=a.keySeparator!==void 0?a.keySeparator:this.options.keySeparator;let c=a.ns||this.options.defaultNS||[];const f=r&&t.indexOf(r)>-1,h=!this.options.userDefinedKeySeparator&&!a.keySeparator&&!this.options.userDefinedNsSeparator&&!a.nsSeparator&&!vO(t,r,l);if(f&&!h){const m=t.match(this.interpolator.nestingRegexp);if(m&&m.length>0)return{key:t,namespaces:re(c)?[c]:c};const p=t.split(r);(r!==l||r===l&&this.options.ns.indexOf(p[0])>-1)&&(c=p.shift()),t=p.join(l)}return{key:t,namespaces:re(c)?[c]:c}}translate(t,a,r){let l=typeof a=="object"?{...a}:a;if(typeof l!="object"&&this.options.overloadTranslationOptionHandler&&(l=this.options.overloadTranslationOptionHandler(arguments)),typeof l=="object"&&(l={...l}),l||(l={}),t==null)return"";typeof t=="function"&&(t=Xa(t,{...this.options,...l})),Array.isArray(t)||(t=[String(t)]),t=t.map(De=>typeof De=="function"?Xa(De,{...this.options,...l}):String(De));const c=l.returnDetails!==void 0?l.returnDetails:this.options.returnDetails,f=l.keySeparator!==void 0?l.keySeparator:this.options.keySeparator,{key:h,namespaces:m}=this.extractFromKey(t[t.length-1],l),p=m[m.length-1];let v=l.nsSeparator!==void 0?l.nsSeparator:this.options.nsSeparator;v===void 0&&(v=":");const x=l.lng||this.language,S=l.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if((x==null?void 0:x.toLowerCase())==="cimode")return S?c?{res:`${p}${v}${h}`,usedKey:h,exactUsedKey:h,usedLng:x,usedNS:p,usedParams:this.getUsedParamsDetails(l)}:`${p}${v}${h}`:c?{res:h,usedKey:h,exactUsedKey:h,usedLng:x,usedNS:p,usedParams:this.getUsedParamsDetails(l)}:h;const w=this.resolve(t,l);let T=w==null?void 0:w.res;const k=(w==null?void 0:w.usedKey)||h,z=(w==null?void 0:w.exactUsedKey)||h,B=["[object Number]","[object Function]","[object RegExp]"],q=l.joinArrays!==void 0?l.joinArrays:this.options.joinArrays,P=!this.i18nFormat||this.i18nFormat.handleAsObject,F=l.count!==void 0&&!re(l.count),X=zl.hasDefaultValue(l),he=F?this.pluralResolver.getSuffix(x,l.count,l):"",W=l.ordinal&&F?this.pluralResolver.getSuffix(x,l.count,{ordinal:!1}):"",te=F&&!l.ordinal&&l.count===0,ue=te&&l[`defaultValue${this.options.pluralSeparator}zero`]||l[`defaultValue${he}`]||l[`defaultValue${W}`]||l.defaultValue;let K=T;P&&!T&&X&&(K=ue);const Se=Zf(K),Ae=Object.prototype.toString.apply(K);if(P&&K&&Se&&B.indexOf(Ae)<0&&!(re(q)&&Array.isArray(K))){if(!l.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn("accessing an object - but returnObjects options is not enabled!");const De=this.options.returnedObjectHandler?this.options.returnedObjectHandler(k,K,{...l,ns:m}):`key '${h} (${this.language})' returned an object instead of string.`;return c?(w.res=De,w.usedParams=this.getUsedParamsDetails(l),w):De}if(f){const De=Array.isArray(K),_e=De?[]:{},Pe=De?z:k;for(const O in K)if(Object.prototype.hasOwnProperty.call(K,O)){const I=`${Pe}${f}${O}`;X&&!T?_e[O]=this.translate(I,{...l,defaultValue:Zf(ue)?ue[O]:void 0,joinArrays:!1,ns:m}):_e[O]=this.translate(I,{...l,joinArrays:!1,ns:m}),_e[O]===I&&(_e[O]=K[O])}T=_e}}else if(P&&re(q)&&Array.isArray(T))T=T.join(q),T&&(T=this.extendTranslation(T,t,l,r));else{let De=!1,_e=!1;!this.isValidLookup(T)&&X&&(De=!0,T=ue),this.isValidLookup(T)||(_e=!0,T=h);const O=(l.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&_e?void 0:T,I=X&&ue!==T&&this.options.updateMissing;if(_e||De||I){if(this.logger.log(I?"updateKey":"missingKey",x,p,h,I?ue:T),f){const A=this.resolve(h,{...l,keySeparator:!1});A&&A.res&&this.logger.warn("Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.")}let Y=[];const se=this.languageUtils.getFallbackCodes(this.options.fallbackLng,l.lng||this.language);if(this.options.saveMissingTo==="fallback"&&se&&se[0])for(let A=0;A{var oe;const $=X&&G!==T?G:O;this.options.missingKeyHandler?this.options.missingKeyHandler(A,p,V,$,I,l):(oe=this.backendConnector)!=null&&oe.saveMissing&&this.backendConnector.saveMissing(A,p,V,$,I,l),this.emit("missingKey",A,p,V,T)};this.options.saveMissing&&(this.options.saveMissingPlurals&&F?Y.forEach(A=>{const V=this.pluralResolver.getSuffixes(A,l);te&&l[`defaultValue${this.options.pluralSeparator}zero`]&&V.indexOf(`${this.options.pluralSeparator}zero`)<0&&V.push(`${this.options.pluralSeparator}zero`),V.forEach(G=>{me([A],h+G,l[`defaultValue${G}`]||ue)})}):me(Y,h,ue))}T=this.extendTranslation(T,t,l,w,r),_e&&T===h&&this.options.appendNamespaceToMissingKey&&(T=`${p}${v}${h}`),(_e||De)&&this.options.parseMissingKeyHandler&&(T=this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${p}${v}${h}`:h,De?T:void 0,l))}return c?(w.res=T,w.usedParams=this.getUsedParamsDetails(l),w):T}extendTranslation(t,a,r,l,c){var m,p;if((m=this.i18nFormat)!=null&&m.parse)t=this.i18nFormat.parse(t,{...this.options.interpolation.defaultVariables,...r},r.lng||this.language||l.usedLng,l.usedNS,l.usedKey,{resolved:l});else if(!r.skipInterpolation){r.interpolation&&this.interpolator.init({...r,interpolation:{...this.options.interpolation,...r.interpolation}});const v=re(t)&&(((p=r==null?void 0:r.interpolation)==null?void 0:p.skipOnVariables)!==void 0?r.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables);let x;if(v){const w=t.match(this.interpolator.nestingRegexp);x=w&&w.length}let S=r.replace&&!re(r.replace)?r.replace:r;if(this.options.interpolation.defaultVariables&&(S={...this.options.interpolation.defaultVariables,...S}),t=this.interpolator.interpolate(t,S,r.lng||this.language||l.usedLng,r),v){const w=t.match(this.interpolator.nestingRegexp),T=w&&w.length;x(c==null?void 0:c[0])===w[0]&&!r.context?(this.logger.warn(`It seems you are nesting recursively key: ${w[0]} in key: ${a[0]}`),null):this.translate(...w,a),r)),r.interpolation&&this.interpolator.reset()}const f=r.postProcess||this.options.postProcess,h=re(f)?[f]:f;return t!=null&&(h!=null&&h.length)&&r.applyPostProcessor!==!1&&(t=dS.handle(h,t,a,this.options&&this.options.postProcessPassResolved?{i18nResolved:{...l,usedParams:this.getUsedParamsDetails(r)},...r}:r,this)),t}resolve(t,a={}){let r,l,c,f,h;return re(t)&&(t=[t]),Array.isArray(t)&&(t=t.map(m=>typeof m=="function"?Xa(m,{...this.options,...a}):m)),t.forEach(m=>{if(this.isValidLookup(r))return;const p=this.extractFromKey(m,a),v=p.key;l=v;let x=p.namespaces;this.options.fallbackNS&&(x=x.concat(this.options.fallbackNS));const S=a.count!==void 0&&!re(a.count),w=S&&!a.ordinal&&a.count===0,T=a.context!==void 0&&(re(a.context)||typeof a.context=="number")&&a.context!=="",k=a.lngs?a.lngs:this.languageUtils.toResolveHierarchy(a.lng||this.language,a.fallbackLng);x.forEach(z=>{var B,q;this.isValidLookup(r)||(h=z,!ox[`${k[0]}-${z}`]&&((B=this.utils)!=null&&B.hasLoadedNamespace)&&!((q=this.utils)!=null&&q.hasLoadedNamespace(h))&&(ox[`${k[0]}-${z}`]=!0,this.logger.warn(`key "${l}" for languages "${k.join(", ")}" won't get resolved as namespace "${h}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!")),k.forEach(P=>{var he;if(this.isValidLookup(r))return;f=P;const F=[v];if((he=this.i18nFormat)!=null&&he.addLookupKeys)this.i18nFormat.addLookupKeys(F,v,P,z,a);else{let W;S&&(W=this.pluralResolver.getSuffix(P,a.count,a));const te=`${this.options.pluralSeparator}zero`,ue=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(S&&(a.ordinal&&W.indexOf(ue)===0&&F.push(v+W.replace(ue,this.options.pluralSeparator)),F.push(v+W),w&&F.push(v+te)),T){const K=`${v}${this.options.contextSeparator||"_"}${a.context}`;F.push(K),S&&(a.ordinal&&W.indexOf(ue)===0&&F.push(K+W.replace(ue,this.options.pluralSeparator)),F.push(K+W),w&&F.push(K+te))}}let X;for(;X=F.pop();)this.isValidLookup(r)||(c=X,r=this.getResource(P,z,X,a))}))})}),{res:r,usedKey:l,exactUsedKey:c,usedLng:f,usedNS:h}}isValidLookup(t){return t!==void 0&&!(!this.options.returnNull&&t===null)&&!(!this.options.returnEmptyString&&t==="")}getResource(t,a,r,l={}){var c;return(c=this.i18nFormat)!=null&&c.getResource?this.i18nFormat.getResource(t,a,r,l):this.resourceStore.getResource(t,a,r,l)}getUsedParamsDetails(t={}){const a=["defaultValue","ordinal","context","replace","lng","lngs","fallbackLng","ns","keySeparator","nsSeparator","returnObjects","returnDetails","joinArrays","postProcess","interpolation"],r=t.replace&&!re(t.replace);let l=r?t.replace:t;if(r&&typeof t.count<"u"&&(l.count=t.count),this.options.interpolation.defaultVariables&&(l={...this.options.interpolation.defaultVariables,...l}),!r){l={...l};for(const c of a)delete l[c]}return l}static hasDefaultValue(t){const a="defaultValue";for(const r in t)if(Object.prototype.hasOwnProperty.call(t,r)&&a===r.substring(0,a.length)&&t[r]!==void 0)return!0;return!1}}class lx{constructor(t){this.options=t,this.supportedLngs=this.options.supportedLngs||!1,this.logger=mn.create("languageUtils")}getScriptPartFromCode(t){if(t=Tr(t),!t||t.indexOf("-")<0)return null;const a=t.split("-");return a.length===2||(a.pop(),a[a.length-1].toLowerCase()==="x")?null:this.formatLanguageCode(a.join("-"))}getLanguagePartFromCode(t){if(t=Tr(t),!t||t.indexOf("-")<0)return t;const a=t.split("-");return this.formatLanguageCode(a[0])}formatLanguageCode(t){if(re(t)&&t.indexOf("-")>-1){let a;try{a=Intl.getCanonicalLocales(t)[0]}catch{}return a&&this.options.lowerCaseLng&&(a=a.toLowerCase()),a||(this.options.lowerCaseLng?t.toLowerCase():t)}return this.options.cleanCode||this.options.lowerCaseLng?t.toLowerCase():t}isSupportedCode(t){return(this.options.load==="languageOnly"||this.options.nonExplicitSupportedLngs)&&(t=this.getLanguagePartFromCode(t)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.indexOf(t)>-1}getBestMatchFromCodes(t){if(!t)return null;let a;return t.forEach(r=>{if(a)return;const l=this.formatLanguageCode(r);(!this.options.supportedLngs||this.isSupportedCode(l))&&(a=l)}),!a&&this.options.supportedLngs&&t.forEach(r=>{if(a)return;const l=this.getScriptPartFromCode(r);if(this.isSupportedCode(l))return a=l;const c=this.getLanguagePartFromCode(r);if(this.isSupportedCode(c))return a=c;a=this.options.supportedLngs.find(f=>{if(f===c)return f;if(!(f.indexOf("-")<0&&c.indexOf("-")<0)&&(f.indexOf("-")>0&&c.indexOf("-")<0&&f.substring(0,f.indexOf("-"))===c||f.indexOf(c)===0&&c.length>1))return f})}),a||(a=this.getFallbackCodes(this.options.fallbackLng)[0]),a}getFallbackCodes(t,a){if(!t)return[];if(typeof t=="function"&&(t=t(a)),re(t)&&(t=[t]),Array.isArray(t))return t;if(!a)return t.default||[];let r=t[a];return r||(r=t[this.getScriptPartFromCode(a)]),r||(r=t[this.formatLanguageCode(a)]),r||(r=t[this.getLanguagePartFromCode(a)]),r||(r=t.default),r||[]}toResolveHierarchy(t,a){const r=this.getFallbackCodes((a===!1?[]:a)||this.options.fallbackLng||[],t),l=[],c=f=>{f&&(this.isSupportedCode(f)?l.push(f):this.logger.warn(`rejecting language code not found in supportedLngs: ${f}`))};return re(t)&&(t.indexOf("-")>-1||t.indexOf("_")>-1)?(this.options.load!=="languageOnly"&&c(this.formatLanguageCode(t)),this.options.load!=="languageOnly"&&this.options.load!=="currentOnly"&&c(this.getScriptPartFromCode(t)),this.options.load!=="currentOnly"&&c(this.getLanguagePartFromCode(t))):re(t)&&c(this.formatLanguageCode(t)),r.forEach(f=>{l.indexOf(f)<0&&c(this.formatLanguageCode(f))}),l}}const cx={zero:0,one:1,two:2,few:3,many:4,other:5},ux={select:n=>n===1?"one":"other",resolvedOptions:()=>({pluralCategories:["one","other"]})};class SO{constructor(t,a={}){this.languageUtils=t,this.options=a,this.logger=mn.create("pluralResolver"),this.pluralRulesCache={}}clearCache(){this.pluralRulesCache={}}getRule(t,a={}){const r=Tr(t==="dev"?"en":t),l=a.ordinal?"ordinal":"cardinal",c=JSON.stringify({cleanedCode:r,type:l});if(c in this.pluralRulesCache)return this.pluralRulesCache[c];let f;try{f=new Intl.PluralRules(r,{type:l})}catch{if(typeof Intl>"u")return this.logger.error("No Intl support, please use an Intl polyfill!"),ux;if(!t.match(/-|_/))return ux;const m=this.languageUtils.getLanguagePartFromCode(t);f=this.getRule(m,a)}return this.pluralRulesCache[c]=f,f}needsPlural(t,a={}){let r=this.getRule(t,a);return r||(r=this.getRule("dev",a)),(r==null?void 0:r.resolvedOptions().pluralCategories.length)>1}getPluralFormsOfKey(t,a,r={}){return this.getSuffixes(t,r).map(l=>`${a}${l}`)}getSuffixes(t,a={}){let r=this.getRule(t,a);return r||(r=this.getRule("dev",a)),r?r.resolvedOptions().pluralCategories.sort((l,c)=>cx[l]-cx[c]).map(l=>`${this.options.prepend}${a.ordinal?`ordinal${this.options.prepend}`:""}${l}`):[]}getSuffix(t,a,r={}){const l=this.getRule(t,r);return l?`${this.options.prepend}${r.ordinal?`ordinal${this.options.prepend}`:""}${l.select(a)}`:(this.logger.warn(`no plural rule found for: ${t}`),this.getSuffix("dev",a,r))}}const fx=(n,t,a,r=".",l=!0)=>{let c=dO(n,t,a);return!c&&l&&re(a)&&(c=Pd(n,a,r),c===void 0&&(c=Pd(t,a,r))),c},Jf=n=>n.replace(/\$/g,"$$$$");class dx{constructor(t={}){var a;this.logger=mn.create("interpolator"),this.options=t,this.format=((a=t==null?void 0:t.interpolation)==null?void 0:a.format)||(r=>r),this.init(t)}init(t={}){t.interpolation||(t.interpolation={escapeValue:!0});const{escape:a,escapeValue:r,useRawValueToEscape:l,prefix:c,prefixEscaped:f,suffix:h,suffixEscaped:m,formatSeparator:p,unescapeSuffix:v,unescapePrefix:x,nestingPrefix:S,nestingPrefixEscaped:w,nestingSuffix:T,nestingSuffixEscaped:k,nestingOptionsSeparator:z,maxReplaces:B,alwaysFormat:q}=t.interpolation;this.escape=a!==void 0?a:mO,this.escapeValue=r!==void 0?r:!0,this.useRawValueToEscape=l!==void 0?l:!1,this.prefix=c?Ii(c):f||"{{",this.suffix=h?Ii(h):m||"}}",this.formatSeparator=p||",",this.unescapePrefix=v?"":x||"-",this.unescapeSuffix=this.unescapePrefix?"":v||"",this.nestingPrefix=S?Ii(S):w||Ii("$t("),this.nestingSuffix=T?Ii(T):k||Ii(")"),this.nestingOptionsSeparator=z||",",this.maxReplaces=B||1e3,this.alwaysFormat=q!==void 0?q:!1,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){const t=(a,r)=>(a==null?void 0:a.source)===r?(a.lastIndex=0,a):new RegExp(r,"g");this.regexp=t(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=t(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=t(this.nestingRegexp,`${this.nestingPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${this.nestingSuffix}`)}interpolate(t,a,r,l){var w;let c,f,h;const m=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},p=T=>{if(T.indexOf(this.formatSeparator)<0){const q=fx(a,m,T,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(q,void 0,r,{...l,...a,interpolationkey:T}):q}const k=T.split(this.formatSeparator),z=k.shift().trim(),B=k.join(this.formatSeparator).trim();return this.format(fx(a,m,z,this.options.keySeparator,this.options.ignoreJSONStructure),B,r,{...l,...a,interpolationkey:z})};this.resetRegExp();const v=(l==null?void 0:l.missingInterpolationHandler)||this.options.missingInterpolationHandler,x=((w=l==null?void 0:l.interpolation)==null?void 0:w.skipOnVariables)!==void 0?l.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:T=>Jf(T)},{regex:this.regexp,safeValue:T=>this.escapeValue?Jf(this.escape(T)):Jf(T)}].forEach(T=>{for(h=0;c=T.regex.exec(t);){const k=c[1].trim();if(f=p(k),f===void 0)if(typeof v=="function"){const B=v(t,c,l);f=re(B)?B:""}else if(l&&Object.prototype.hasOwnProperty.call(l,k))f="";else if(x){f=c[0];continue}else this.logger.warn(`missed to pass in variable ${k} for interpolating ${t}`),f="";else!re(f)&&!this.useRawValueToEscape&&(f=nx(f));const z=T.safeValue(f);if(t=t.replace(c[0],z),x?(T.regex.lastIndex+=f.length,T.regex.lastIndex-=c[0].length):T.regex.lastIndex=0,h++,h>=this.maxReplaces)break}}),t}nest(t,a,r={}){let l,c,f;const h=(m,p)=>{const v=this.nestingOptionsSeparator;if(m.indexOf(v)<0)return m;const x=m.split(new RegExp(`${Ii(v)}[ ]*{`));let S=`{${x[1]}`;m=x[0],S=this.interpolate(S,f);const w=S.match(/'/g),T=S.match(/"/g);(((w==null?void 0:w.length)??0)%2===0&&!T||((T==null?void 0:T.length)??0)%2!==0)&&(S=S.replace(/'/g,'"'));try{f=JSON.parse(S),p&&(f={...p,...f})}catch(k){return this.logger.warn(`failed parsing options string in nesting for key ${m}`,k),`${m}${v}${S}`}return f.defaultValue&&f.defaultValue.indexOf(this.prefix)>-1&&delete f.defaultValue,m};for(;l=this.nestingRegexp.exec(t);){let m=[];f={...r},f=f.replace&&!re(f.replace)?f.replace:f,f.applyPostProcessor=!1,delete f.defaultValue;const p=/{.*}/.test(l[1])?l[1].lastIndexOf("}")+1:l[1].indexOf(this.formatSeparator);if(p!==-1&&(m=l[1].slice(p).split(this.formatSeparator).map(v=>v.trim()).filter(Boolean),l[1]=l[1].slice(0,p)),c=a(h.call(this,l[1].trim(),f),f),c&&l[0]===t&&!re(c))return c;re(c)||(c=nx(c)),c||(this.logger.warn(`missed to resolve ${l[1]} for nesting ${t}`),c=""),m.length&&(c=m.reduce((v,x)=>this.format(v,x,r.lng,{...r,interpolationkey:l[1].trim()}),c.trim())),t=t.replace(l[0],c),this.regexp.lastIndex=0}return t}}const wO=n=>{let t=n.toLowerCase().trim();const a={};if(n.indexOf("(")>-1){const r=n.split("(");t=r[0].toLowerCase().trim();const l=r[1].substring(0,r[1].length-1);t==="currency"&&l.indexOf(":")<0?a.currency||(a.currency=l.trim()):t==="relativetime"&&l.indexOf(":")<0?a.range||(a.range=l.trim()):l.split(";").forEach(f=>{if(f){const[h,...m]=f.split(":"),p=m.join(":").trim().replace(/^'+|'+$/g,""),v=h.trim();a[v]||(a[v]=p),p==="false"&&(a[v]=!1),p==="true"&&(a[v]=!0),isNaN(p)||(a[v]=parseInt(p,10))}})}return{formatName:t,formatOptions:a}},hx=n=>{const t={};return(a,r,l)=>{let c=l;l&&l.interpolationkey&&l.formatParams&&l.formatParams[l.interpolationkey]&&l[l.interpolationkey]&&(c={...c,[l.interpolationkey]:void 0});const f=r+JSON.stringify(c);let h=t[f];return h||(h=n(Tr(r),l),t[f]=h),h(a)}},_O=n=>(t,a,r)=>n(Tr(a),r)(t);class TO{constructor(t={}){this.logger=mn.create("formatter"),this.options=t,this.init(t)}init(t,a={interpolation:{}}){this.formatSeparator=a.interpolation.formatSeparator||",";const r=a.cacheInBuiltFormats?hx:_O;this.formats={number:r((l,c)=>{const f=new Intl.NumberFormat(l,{...c});return h=>f.format(h)}),currency:r((l,c)=>{const f=new Intl.NumberFormat(l,{...c,style:"currency"});return h=>f.format(h)}),datetime:r((l,c)=>{const f=new Intl.DateTimeFormat(l,{...c});return h=>f.format(h)}),relativetime:r((l,c)=>{const f=new Intl.RelativeTimeFormat(l,{...c});return h=>f.format(h,c.range||"day")}),list:r((l,c)=>{const f=new Intl.ListFormat(l,{...c});return h=>f.format(h)})}}add(t,a){this.formats[t.toLowerCase().trim()]=a}addCached(t,a){this.formats[t.toLowerCase().trim()]=hx(a)}format(t,a,r,l={}){const c=a.split(this.formatSeparator);if(c.length>1&&c[0].indexOf("(")>1&&c[0].indexOf(")")<0&&c.find(h=>h.indexOf(")")>-1)){const h=c.findIndex(m=>m.indexOf(")")>-1);c[0]=[c[0],...c.splice(1,h)].join(this.formatSeparator)}return c.reduce((h,m)=>{var x;const{formatName:p,formatOptions:v}=wO(m);if(this.formats[p]){let S=h;try{const w=((x=l==null?void 0:l.formatParams)==null?void 0:x[l.interpolationkey])||{},T=w.locale||w.lng||l.locale||l.lng||r;S=this.formats[p](h,T,{...v,...l,...w})}catch(w){this.logger.warn(w)}return S}else this.logger.warn(`there was no format function for ${p}`);return h},t)}}const EO=(n,t)=>{n.pending[t]!==void 0&&(delete n.pending[t],n.pendingCount--)};class AO extends Wl{constructor(t,a,r,l={}){var c,f;super(),this.backend=t,this.store=a,this.services=r,this.languageUtils=r.languageUtils,this.options=l,this.logger=mn.create("backendConnector"),this.waitingReads=[],this.maxParallelReads=l.maxParallelReads||10,this.readingCalls=0,this.maxRetries=l.maxRetries>=0?l.maxRetries:5,this.retryTimeout=l.retryTimeout>=1?l.retryTimeout:350,this.state={},this.queue=[],(f=(c=this.backend)==null?void 0:c.init)==null||f.call(c,r,l.backend,l)}queueLoad(t,a,r,l){const c={},f={},h={},m={};return t.forEach(p=>{let v=!0;a.forEach(x=>{const S=`${p}|${x}`;!r.reload&&this.store.hasResourceBundle(p,x)?this.state[S]=2:this.state[S]<0||(this.state[S]===1?f[S]===void 0&&(f[S]=!0):(this.state[S]=1,v=!1,f[S]===void 0&&(f[S]=!0),c[S]===void 0&&(c[S]=!0),m[x]===void 0&&(m[x]=!0)))}),v||(h[p]=!0)}),(Object.keys(c).length||Object.keys(f).length)&&this.queue.push({pending:f,pendingCount:Object.keys(f).length,loaded:{},errors:[],callback:l}),{toLoad:Object.keys(c),pending:Object.keys(f),toLoadLanguages:Object.keys(h),toLoadNamespaces:Object.keys(m)}}loaded(t,a,r){const l=t.split("|"),c=l[0],f=l[1];a&&this.emit("failedLoading",c,f,a),!a&&r&&this.store.addResourceBundle(c,f,r,void 0,void 0,{skipCopy:!0}),this.state[t]=a?-1:2,a&&r&&(this.state[t]=0);const h={};this.queue.forEach(m=>{fO(m.loaded,[c],f),EO(m,t),a&&m.errors.push(a),m.pendingCount===0&&!m.done&&(Object.keys(m.loaded).forEach(p=>{h[p]||(h[p]={});const v=m.loaded[p];v.length&&v.forEach(x=>{h[p][x]===void 0&&(h[p][x]=!0)})}),m.done=!0,m.errors.length?m.callback(m.errors):m.callback())}),this.emit("loaded",h),this.queue=this.queue.filter(m=>!m.done)}read(t,a,r,l=0,c=this.retryTimeout,f){if(!t.length)return f(null,{});if(this.readingCalls>=this.maxParallelReads){this.waitingReads.push({lng:t,ns:a,fcName:r,tried:l,wait:c,callback:f});return}this.readingCalls++;const h=(p,v)=>{if(this.readingCalls--,this.waitingReads.length>0){const x=this.waitingReads.shift();this.read(x.lng,x.ns,x.fcName,x.tried,x.wait,x.callback)}if(p&&v&&l{this.read.call(this,t,a,r,l+1,c*2,f)},c);return}f(p,v)},m=this.backend[r].bind(this.backend);if(m.length===2){try{const p=m(t,a);p&&typeof p.then=="function"?p.then(v=>h(null,v)).catch(h):h(null,p)}catch(p){h(p)}return}return m(t,a,h)}prepareLoading(t,a,r={},l){if(!this.backend)return this.logger.warn("No backend was added via i18next.use. Will not load resources."),l&&l();re(t)&&(t=this.languageUtils.toResolveHierarchy(t)),re(a)&&(a=[a]);const c=this.queueLoad(t,a,r,l);if(!c.toLoad.length)return c.pending.length||l(),null;c.toLoad.forEach(f=>{this.loadOne(f)})}load(t,a,r){this.prepareLoading(t,a,{},r)}reload(t,a,r){this.prepareLoading(t,a,{reload:!0},r)}loadOne(t,a=""){const r=t.split("|"),l=r[0],c=r[1];this.read(l,c,"read",void 0,void 0,(f,h)=>{f&&this.logger.warn(`${a}loading namespace ${c} for language ${l} failed`,f),!f&&h&&this.logger.log(`${a}loaded namespace ${c} for language ${l}`,h),this.loaded(t,f,h)})}saveMissing(t,a,r,l,c,f={},h=()=>{}){var m,p,v,x,S;if((p=(m=this.services)==null?void 0:m.utils)!=null&&p.hasLoadedNamespace&&!((x=(v=this.services)==null?void 0:v.utils)!=null&&x.hasLoadedNamespace(a))){this.logger.warn(`did not save key "${r}" as the namespace "${a}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!");return}if(!(r==null||r==="")){if((S=this.backend)!=null&&S.create){const w={...f,isUpdate:c},T=this.backend.create.bind(this.backend);if(T.length<6)try{let k;T.length===5?k=T(t,a,r,l,w):k=T(t,a,r,l),k&&typeof k.then=="function"?k.then(z=>h(null,z)).catch(h):h(null,k)}catch(k){h(k)}else T(t,a,r,l,h,w)}!t||!t[0]||this.store.addResource(t[0],a,r,l)}}}const Wf=()=>({debug:!1,initAsync:!0,ns:["translation"],defaultNS:["translation"],fallbackLng:["dev"],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:"all",preload:!1,simplifyPluralSuffix:!0,keySeparator:".",nsSeparator:":",pluralSeparator:"_",contextSeparator:"_",partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:"fallback",saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:n=>{let t={};if(typeof n[1]=="object"&&(t=n[1]),re(n[1])&&(t.defaultValue=n[1]),re(n[2])&&(t.tDescription=n[2]),typeof n[2]=="object"||typeof n[3]=="object"){const a=n[3]||n[2];Object.keys(a).forEach(r=>{t[r]=a[r]})}return t},interpolation:{escapeValue:!0,format:n=>n,prefix:"{{",suffix:"}}",formatSeparator:",",unescapePrefix:"-",nestingPrefix:"$t(",nestingSuffix:")",nestingOptionsSeparator:",",maxReplaces:1e3,skipOnVariables:!0},cacheInBuiltFormats:!0}),mx=n=>{var t,a;return re(n.ns)&&(n.ns=[n.ns]),re(n.fallbackLng)&&(n.fallbackLng=[n.fallbackLng]),re(n.fallbackNS)&&(n.fallbackNS=[n.fallbackNS]),((a=(t=n.supportedLngs)==null?void 0:t.indexOf)==null?void 0:a.call(t,"cimode"))<0&&(n.supportedLngs=n.supportedLngs.concat(["cimode"])),typeof n.initImmediate=="boolean"&&(n.initAsync=n.initImmediate),n},ol=()=>{},NO=n=>{Object.getOwnPropertyNames(Object.getPrototypeOf(n)).forEach(a=>{typeof n[a]=="function"&&(n[a]=n[a].bind(n))})},mS="__i18next_supportNoticeShown",DO=()=>!!(typeof globalThis<"u"&&globalThis[mS]||typeof process<"u"&&Qf&&Qf.I18NEXT_NO_SUPPORT_NOTICE||typeof process<"u"&&Qf),jO=()=>{typeof globalThis<"u"&&(globalThis[mS]=!0)},CO=n=>{var t,a,r,l,c,f,h,m,p,v,x,S,w;return!!(((r=(a=(t=n==null?void 0:n.modules)==null?void 0:t.backend)==null?void 0:a.name)==null?void 0:r.indexOf("Locize"))>0||((h=(f=(c=(l=n==null?void 0:n.modules)==null?void 0:l.backend)==null?void 0:c.constructor)==null?void 0:f.name)==null?void 0:h.indexOf("Locize"))>0||(p=(m=n==null?void 0:n.options)==null?void 0:m.backend)!=null&&p.backends&&n.options.backend.backends.some(T=>{var k,z,B;return((k=T==null?void 0:T.name)==null?void 0:k.indexOf("Locize"))>0||((B=(z=T==null?void 0:T.constructor)==null?void 0:z.name)==null?void 0:B.indexOf("Locize"))>0})||(x=(v=n==null?void 0:n.options)==null?void 0:v.backend)!=null&&x.projectId||(w=(S=n==null?void 0:n.options)==null?void 0:S.backend)!=null&&w.backendOptions&&n.options.backend.backendOptions.some(T=>T==null?void 0:T.projectId))};class pr extends Wl{constructor(t={},a){if(super(),this.options=mx(t),this.services={},this.logger=mn,this.modules={external:[]},NO(this),a&&!this.isInitialized&&!t.isClone){if(!this.options.initAsync)return this.init(t,a),this;setTimeout(()=>{this.init(t,a)},0)}}init(t={},a){this.isInitializing=!0,typeof t=="function"&&(a=t,t={}),t.defaultNS==null&&t.ns&&(re(t.ns)?t.defaultNS=t.ns:t.ns.indexOf("translation")<0&&(t.defaultNS=t.ns[0]));const r=Wf();this.options={...r,...this.options,...mx(t)},this.options.interpolation={...r.interpolation,...this.options.interpolation},t.keySeparator!==void 0&&(this.options.userDefinedKeySeparator=t.keySeparator),t.nsSeparator!==void 0&&(this.options.userDefinedNsSeparator=t.nsSeparator),typeof this.options.overloadTranslationOptionHandler!="function"&&(this.options.overloadTranslationOptionHandler=r.overloadTranslationOptionHandler),this.options.showSupportNotice!==!1&&!CO(this)&&!DO()&&(typeof console<"u"&&typeof console.info<"u"&&console.info("🌐 i18next is made possible by our own product, Locize — consider powering your project with managed localization (AI, CDN, integrations): https://locize.com 💙"),jO());const l=p=>p?typeof p=="function"?new p:p:null;if(!this.options.isClone){this.modules.logger?mn.init(l(this.modules.logger),this.options):mn.init(null,this.options);let p;this.modules.formatter?p=this.modules.formatter:p=TO;const v=new lx(this.options);this.store=new rx(this.options.resources,this.options);const x=this.services;x.logger=mn,x.resourceStore=this.store,x.languageUtils=v,x.pluralResolver=new SO(v,{prepend:this.options.pluralSeparator,simplifyPluralSuffix:this.options.simplifyPluralSuffix}),this.options.interpolation.format&&this.options.interpolation.format!==r.interpolation.format&&this.logger.deprecate("init: you are still using the legacy format function, please use the new approach: https://www.i18next.com/translation-function/formatting"),p&&(!this.options.interpolation.format||this.options.interpolation.format===r.interpolation.format)&&(x.formatter=l(p),x.formatter.init&&x.formatter.init(x,this.options),this.options.interpolation.format=x.formatter.format.bind(x.formatter)),x.interpolator=new dx(this.options),x.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},x.backendConnector=new AO(l(this.modules.backend),x.resourceStore,x,this.options),x.backendConnector.on("*",(w,...T)=>{this.emit(w,...T)}),this.modules.languageDetector&&(x.languageDetector=l(this.modules.languageDetector),x.languageDetector.init&&x.languageDetector.init(x,this.options.detection,this.options)),this.modules.i18nFormat&&(x.i18nFormat=l(this.modules.i18nFormat),x.i18nFormat.init&&x.i18nFormat.init(this)),this.translator=new zl(this.services,this.options),this.translator.on("*",(w,...T)=>{this.emit(w,...T)}),this.modules.external.forEach(w=>{w.init&&w.init(this)})}if(this.format=this.options.interpolation.format,a||(a=ol),this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){const p=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);p.length>0&&p[0]!=="dev"&&(this.options.lng=p[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn("init: no languageDetector is used and no lng is defined"),["getResource","hasResourceBundle","getResourceBundle","getDataByLanguage"].forEach(p=>{this[p]=(...v)=>this.store[p](...v)}),["addResource","addResources","addResourceBundle","removeResourceBundle"].forEach(p=>{this[p]=(...v)=>(this.store[p](...v),this)});const h=rr(),m=()=>{const p=(v,x)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn("init: i18next is already initialized. You should call init just once!"),this.isInitialized=!0,this.options.isClone||this.logger.log("initialized",this.options),this.emit("initialized",this.options),h.resolve(x),a(v,x)};if(this.languages&&!this.isInitialized)return p(null,this.t.bind(this));this.changeLanguage(this.options.lng,p)};return this.options.resources||!this.options.initAsync?m():setTimeout(m,0),h}loadResources(t,a=ol){var c,f;let r=a;const l=re(t)?t:this.language;if(typeof t=="function"&&(r=t),!this.options.resources||this.options.partialBundledLanguages){if((l==null?void 0:l.toLowerCase())==="cimode"&&(!this.options.preload||this.options.preload.length===0))return r();const h=[],m=p=>{if(!p||p==="cimode")return;this.services.languageUtils.toResolveHierarchy(p).forEach(x=>{x!=="cimode"&&h.indexOf(x)<0&&h.push(x)})};l?m(l):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach(v=>m(v)),(f=(c=this.options.preload)==null?void 0:c.forEach)==null||f.call(c,p=>m(p)),this.services.backendConnector.load(h,this.options.ns,p=>{!p&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),r(p)})}else r(null)}reloadResources(t,a,r){const l=rr();return typeof t=="function"&&(r=t,t=void 0),typeof a=="function"&&(r=a,a=void 0),t||(t=this.languages),a||(a=this.options.ns),r||(r=ol),this.services.backendConnector.reload(t,a,c=>{l.resolve(),r(c)}),l}use(t){if(!t)throw new Error("You are passing an undefined module! Please check the object you are passing to i18next.use()");if(!t.type)throw new Error("You are passing a wrong module! Please check the object you are passing to i18next.use()");return t.type==="backend"&&(this.modules.backend=t),(t.type==="logger"||t.log&&t.warn&&t.error)&&(this.modules.logger=t),t.type==="languageDetector"&&(this.modules.languageDetector=t),t.type==="i18nFormat"&&(this.modules.i18nFormat=t),t.type==="postProcessor"&&dS.addPostProcessor(t),t.type==="formatter"&&(this.modules.formatter=t),t.type==="3rdParty"&&this.modules.external.push(t),this}setResolvedLanguage(t){if(!(!t||!this.languages)&&!(["cimode","dev"].indexOf(t)>-1)){for(let a=0;a-1)&&this.store.hasLanguageSomeTranslations(r)){this.resolvedLanguage=r;break}}!this.resolvedLanguage&&this.languages.indexOf(t)<0&&this.store.hasLanguageSomeTranslations(t)&&(this.resolvedLanguage=t,this.languages.unshift(t))}}changeLanguage(t,a){this.isLanguageChangingTo=t;const r=rr();this.emit("languageChanging",t);const l=h=>{this.language=h,this.languages=this.services.languageUtils.toResolveHierarchy(h),this.resolvedLanguage=void 0,this.setResolvedLanguage(h)},c=(h,m)=>{m?this.isLanguageChangingTo===t&&(l(m),this.translator.changeLanguage(m),this.isLanguageChangingTo=void 0,this.emit("languageChanged",m),this.logger.log("languageChanged",m)):this.isLanguageChangingTo=void 0,r.resolve((...p)=>this.t(...p)),a&&a(h,(...p)=>this.t(...p))},f=h=>{var v,x;!t&&!h&&this.services.languageDetector&&(h=[]);const m=re(h)?h:h&&h[0],p=this.store.hasLanguageSomeTranslations(m)?m:this.services.languageUtils.getBestMatchFromCodes(re(h)?[h]:h);p&&(this.language||l(p),this.translator.language||this.translator.changeLanguage(p),(x=(v=this.services.languageDetector)==null?void 0:v.cacheUserLanguage)==null||x.call(v,p)),this.loadResources(p,S=>{c(S,p)})};return!t&&this.services.languageDetector&&!this.services.languageDetector.async?f(this.services.languageDetector.detect()):!t&&this.services.languageDetector&&this.services.languageDetector.async?this.services.languageDetector.detect.length===0?this.services.languageDetector.detect().then(f):this.services.languageDetector.detect(f):f(t),r}getFixedT(t,a,r){const l=(c,f,...h)=>{let m;typeof f!="object"?m=this.options.overloadTranslationOptionHandler([c,f].concat(h)):m={...f},m.lng=m.lng||l.lng,m.lngs=m.lngs||l.lngs,m.ns=m.ns||l.ns,m.keyPrefix!==""&&(m.keyPrefix=m.keyPrefix||r||l.keyPrefix);const p={...this.options,...m};typeof m.keyPrefix=="function"&&(m.keyPrefix=Xa(m.keyPrefix,p));const v=this.options.keySeparator||".";let x;return m.keyPrefix&&Array.isArray(c)?x=c.map(S=>(typeof S=="function"&&(S=Xa(S,p)),`${m.keyPrefix}${v}${S}`)):(typeof c=="function"&&(c=Xa(c,p)),x=m.keyPrefix?`${m.keyPrefix}${v}${c}`:c),this.t(x,m)};return re(t)?l.lng=t:l.lngs=t,l.ns=a,l.keyPrefix=r,l}t(...t){var a;return(a=this.translator)==null?void 0:a.translate(...t)}exists(...t){var a;return(a=this.translator)==null?void 0:a.exists(...t)}setDefaultNamespace(t){this.options.defaultNS=t}hasLoadedNamespace(t,a={}){if(!this.isInitialized)return this.logger.warn("hasLoadedNamespace: i18next was not initialized",this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn("hasLoadedNamespace: i18n.languages were undefined or empty",this.languages),!1;const r=a.lng||this.resolvedLanguage||this.languages[0],l=this.options?this.options.fallbackLng:!1,c=this.languages[this.languages.length-1];if(r.toLowerCase()==="cimode")return!0;const f=(h,m)=>{const p=this.services.backendConnector.state[`${h}|${m}`];return p===-1||p===0||p===2};if(a.precheck){const h=a.precheck(this,f);if(h!==void 0)return h}return!!(this.hasResourceBundle(r,t)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||f(r,t)&&(!l||f(c,t)))}loadNamespaces(t,a){const r=rr();return this.options.ns?(re(t)&&(t=[t]),t.forEach(l=>{this.options.ns.indexOf(l)<0&&this.options.ns.push(l)}),this.loadResources(l=>{r.resolve(),a&&a(l)}),r):(a&&a(),Promise.resolve())}loadLanguages(t,a){const r=rr();re(t)&&(t=[t]);const l=this.options.preload||[],c=t.filter(f=>l.indexOf(f)<0&&this.services.languageUtils.isSupportedCode(f));return c.length?(this.options.preload=l.concat(c),this.loadResources(f=>{r.resolve(),a&&a(f)}),r):(a&&a(),Promise.resolve())}dir(t){var l,c;if(t||(t=this.resolvedLanguage||(((l=this.languages)==null?void 0:l.length)>0?this.languages[0]:this.language)),!t)return"rtl";try{const f=new Intl.Locale(t);if(f&&f.getTextInfo){const h=f.getTextInfo();if(h&&h.direction)return h.direction}}catch{}const a=["ar","shu","sqr","ssh","xaa","yhd","yud","aao","abh","abv","acm","acq","acw","acx","acy","adf","ads","aeb","aec","afb","ajp","apc","apd","arb","arq","ars","ary","arz","auz","avl","ayh","ayl","ayn","ayp","bbz","pga","he","iw","ps","pbt","pbu","pst","prp","prd","ug","ur","ydd","yds","yih","ji","yi","hbo","men","xmn","fa","jpr","peo","pes","prs","dv","sam","ckb"],r=((c=this.services)==null?void 0:c.languageUtils)||new lx(Wf());return t.toLowerCase().indexOf("-latn")>1?"ltr":a.indexOf(r.getLanguagePartFromCode(t))>-1||t.toLowerCase().indexOf("-arab")>1?"rtl":"ltr"}static createInstance(t={},a){const r=new pr(t,a);return r.createInstance=pr.createInstance,r}cloneInstance(t={},a=ol){const r=t.forkResourceStore;r&&delete t.forkResourceStore;const l={...this.options,...t,isClone:!0},c=new pr(l);if((t.debug!==void 0||t.prefix!==void 0)&&(c.logger=c.logger.clone(t)),["store","services","language"].forEach(h=>{c[h]=this[h]}),c.services={...this.services},c.services.utils={hasLoadedNamespace:c.hasLoadedNamespace.bind(c)},r){const h=Object.keys(this.store.data).reduce((m,p)=>(m[p]={...this.store.data[p]},m[p]=Object.keys(m[p]).reduce((v,x)=>(v[x]={...m[p][x]},v),m[p]),m),{});c.store=new rx(h,l),c.services.resourceStore=c.store}if(t.interpolation){const m={...Wf().interpolation,...this.options.interpolation,...t.interpolation},p={...l,interpolation:m};c.services.interpolator=new dx(p)}return c.translator=new zl(c.services,l),c.translator.on("*",(h,...m)=>{c.emit(h,...m)}),c.init(l,a),c.translator.options=l,c.translator.backendConnector.services.utils={hasLoadedNamespace:c.hasLoadedNamespace.bind(c)},c}toJSON(){return{options:this.options,store:this.store,language:this.language,languages:this.languages,resolvedLanguage:this.resolvedLanguage}}}const Ze=pr.createInstance();Ze.createInstance;Ze.dir;Ze.init;Ze.loadResources;Ze.reloadResources;Ze.use;Ze.changeLanguage;Ze.getFixedT;Ze.t;Ze.exists;Ze.setDefaultNamespace;Ze.hasLoadedNamespace;Ze.loadNamespaces;Ze.loadLanguages;const{slice:MO,forEach:OO}=[];function RO(n){return OO.call(MO.call(arguments,1),t=>{if(t)for(const a in t)n[a]===void 0&&(n[a]=t[a])}),n}function kO(n){return typeof n!="string"?!1:[/<\s*script.*?>/i,/<\s*\/\s*script\s*>/i,/<\s*img.*?on\w+\s*=/i,/<\s*\w+\s*on\w+\s*=.*?>/i,/javascript\s*:/i,/vbscript\s*:/i,/expression\s*\(/i,/eval\s*\(/i,/alert\s*\(/i,/document\.cookie/i,/document\.write\s*\(/i,/window\.location/i,/innerHTML/i].some(a=>a.test(n))}const px=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/,LO=function(n,t){const r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{path:"/"},l=encodeURIComponent(t);let c=`${n}=${l}`;if(r.maxAge>0){const f=r.maxAge-0;if(Number.isNaN(f))throw new Error("maxAge should be a Number");c+=`; Max-Age=${Math.floor(f)}`}if(r.domain){if(!px.test(r.domain))throw new TypeError("option domain is invalid");c+=`; Domain=${r.domain}`}if(r.path){if(!px.test(r.path))throw new TypeError("option path is invalid");c+=`; Path=${r.path}`}if(r.expires){if(typeof r.expires.toUTCString!="function")throw new TypeError("option expires is invalid");c+=`; Expires=${r.expires.toUTCString()}`}if(r.httpOnly&&(c+="; HttpOnly"),r.secure&&(c+="; Secure"),r.sameSite)switch(typeof r.sameSite=="string"?r.sameSite.toLowerCase():r.sameSite){case!0:c+="; SameSite=Strict";break;case"lax":c+="; SameSite=Lax";break;case"strict":c+="; SameSite=Strict";break;case"none":c+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}return r.partitioned&&(c+="; Partitioned"),c},gx={create(n,t,a,r){let l=arguments.length>4&&arguments[4]!==void 0?arguments[4]:{path:"/",sameSite:"strict"};a&&(l.expires=new Date,l.expires.setTime(l.expires.getTime()+a*60*1e3)),r&&(l.domain=r),document.cookie=LO(n,t,l)},read(n){const t=`${n}=`,a=document.cookie.split(";");for(let r=0;r-1&&(l=window.location.hash.substring(window.location.hash.indexOf("?")));const f=l.substring(1).split("&");for(let h=0;h0&&f[h].substring(0,m)===t&&(a=f[h].substring(m+1))}}return a}},BO={name:"hash",lookup(n){var l;let{lookupHash:t,lookupFromHashIndex:a}=n,r;if(typeof window<"u"){const{hash:c}=window.location;if(c&&c.length>2){const f=c.substring(1);if(t){const h=f.split("&");for(let m=0;m0&&h[m].substring(0,p)===t&&(r=h[m].substring(p+1))}}if(r)return r;if(!r&&a>-1){const h=c.match(/\/([a-zA-Z-]*)/g);return Array.isArray(h)?(l=h[typeof a=="number"?a:0])==null?void 0:l.replace("/",""):void 0}}}return r}};let Pa=null;const yx=()=>{if(Pa!==null)return Pa;try{if(Pa=typeof window<"u"&&window.localStorage!==null,!Pa)return!1;const n="i18next.translate.boo";window.localStorage.setItem(n,"foo"),window.localStorage.removeItem(n)}catch{Pa=!1}return Pa};var UO={name:"localStorage",lookup(n){let{lookupLocalStorage:t}=n;if(t&&yx())return window.localStorage.getItem(t)||void 0},cacheUserLanguage(n,t){let{lookupLocalStorage:a}=t;a&&yx()&&window.localStorage.setItem(a,n)}};let qa=null;const vx=()=>{if(qa!==null)return qa;try{if(qa=typeof window<"u"&&window.sessionStorage!==null,!qa)return!1;const n="i18next.translate.boo";window.sessionStorage.setItem(n,"foo"),window.sessionStorage.removeItem(n)}catch{qa=!1}return qa};var HO={name:"sessionStorage",lookup(n){let{lookupSessionStorage:t}=n;if(t&&vx())return window.sessionStorage.getItem(t)||void 0},cacheUserLanguage(n,t){let{lookupSessionStorage:a}=t;a&&vx()&&window.sessionStorage.setItem(a,n)}},PO={name:"navigator",lookup(n){const t=[];if(typeof navigator<"u"){const{languages:a,userLanguage:r,language:l}=navigator;if(a)for(let c=0;c0?t:void 0}},qO={name:"htmlTag",lookup(n){let{htmlTag:t}=n,a;const r=t||(typeof document<"u"?document.documentElement:null);return r&&typeof r.getAttribute=="function"&&(a=r.getAttribute("lang")),a}},IO={name:"path",lookup(n){var l;let{lookupFromPathIndex:t}=n;if(typeof window>"u")return;const a=window.location.pathname.match(/\/([a-zA-Z-]*)/g);return Array.isArray(a)?(l=a[typeof t=="number"?t:0])==null?void 0:l.replace("/",""):void 0}},FO={name:"subdomain",lookup(n){var l,c;let{lookupFromSubdomainIndex:t}=n;const a=typeof t=="number"?t+1:1,r=typeof window<"u"&&((c=(l=window.location)==null?void 0:l.hostname)==null?void 0:c.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i));if(r)return r[a]}};let pS=!1;try{document.cookie,pS=!0}catch{}const gS=["querystring","cookie","localStorage","sessionStorage","navigator","htmlTag"];pS||gS.splice(1,1);const GO=()=>({order:gS,lookupQuerystring:"lng",lookupCookie:"i18next",lookupLocalStorage:"i18nextLng",lookupSessionStorage:"i18nextLng",caches:["localStorage"],excludeCacheFor:["cimode"],convertDetectedLanguage:n=>n});class yS{constructor(t){let a=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};this.type="languageDetector",this.detectors={},this.init(t,a)}init(){let t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{languageUtils:{}},a=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};this.services=t,this.options=RO(a,this.options||{},GO()),typeof this.options.convertDetectedLanguage=="string"&&this.options.convertDetectedLanguage.indexOf("15897")>-1&&(this.options.convertDetectedLanguage=l=>l.replace("-","_")),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=r,this.addDetector(zO),this.addDetector(VO),this.addDetector(UO),this.addDetector(HO),this.addDetector(PO),this.addDetector(qO),this.addDetector(IO),this.addDetector(FO),this.addDetector(BO)}addDetector(t){return this.detectors[t.name]=t,this}detect(){let t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.order,a=[];return t.forEach(r=>{if(this.detectors[r]){let l=this.detectors[r].lookup(this.options);l&&typeof l=="string"&&(l=[l]),l&&(a=a.concat(l))}}),a=a.filter(r=>r!=null&&!kO(r)).map(r=>this.options.convertDetectedLanguage(r)),this.services&&this.services.languageUtils&&this.services.languageUtils.getBestMatchFromCodes?a:a.length>0?a[0]:null}cacheUserLanguage(t){let a=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.options.caches;a&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(t)>-1||a.forEach(r=>{this.detectors[r]&&this.detectors[r].cacheUserLanguage(t,this.options)}))}}yS.type="languageDetector";const YO={free:"Free",pro:"Pro",api:"API",enterprise:"Enterprise",reserveAccess:"Reserve Your Early Access",signIn:"Sign In",upgradeToPro:"Upgrade to Pro"},KO={noiseWord:"Noise",signalWord:"Signal",valueProps:"The intelligence geopolitical AI layer — ask it, subscribe to it, build on it.",reserveEarlyAccess:"Reserve Your Early Access",launchingDate:"Live now",tryFreeDashboard:"Try the free dashboard",emailPlaceholder:"Enter your email",emailAriaLabel:"Email address for waitlist",choosePlan:"Choose Your Plan",signIn:"Sign In"},XO={asFeaturedIn:"As featured in"},$O={proTitle:"World Monitor Pro",proDesc:"Your AI analyst. A personal intelligence desk. A platform you can build on. One key across 30+ live services.",proF1:"WM Analyst chat — query all 30+ services conversationally",proF2:"AI digest — daily, twice-daily, or weekly — to Slack, Discord, Telegram, Email, or webhook",proF3:"Custom Widget Builder — HTML/CSS/JS with AI-assisted modification",proF4:"MCP connectors — plug WorldMonitor into Claude, GPT, custom LLMs",proF5:"Equity research, stock analysis & backtesting",proF6:"Flight search & price comparison",proF7:"Alert rules engine — custom triggers, quiet hours, AES-256 encrypted channels",proF8:"Market watchlist, macro & central bank tracking",proF9:"AI Market Implications & Regional Intelligence (Soon: orbital surveillance, premium map layers, longer history)",proCta:"Reserve Your Early Access",choosePlan:"Choose Your Plan",entTitle:"World Monitor Enterprise",entDesc:"For teams that need shared monitoring, API access, deployment options, TV apps, and direct support.",entF1:"Everything in Pro, plus:",entF2:"Live-edge + satellite imagery & SAR",entF3:"AI agents with investor personas & MCP",entF4:"50,000+ infrastructure assets mapped",entF5:"100+ data connectors (Splunk, Snowflake, Sentinel...)",entF6:"REST API + webhooks + bulk export",entF7:"Team workspaces with SSO/MFA/RBAC",entF8:"White-label & embeddable panels",entF9:"Android TV app for SOC walls & trading floors",entF10:"Cloud, on-prem, or air-gapped deployment",entF11:"Dedicated onboarding & support",entCta:"Talk to Sales"},QO={title:"Why upgrade",noiseTitle:"Know first",noiseDesc:"AIS anomaly → Brent spike → your Slack in under a minute. Signal, not headlines.",fasterTitle:"Ask anything",fasterDesc:"Chat the same 30+ services your dashboard sees. WM Analyst, always on.",controlTitle:"Build anything",controlDesc:"Custom widget builder (HTML/CSS/JS + AI) plus MCP for your own AI workflows.",deeperTitle:"Wake up informed",deeperDesc:"30-item AI digest ranked by your alert rules. Critical, High, Medium — with Assessment and Signals to watch."},ZO={askIt:"Ask it",askItDesc:"WM Analyst chat. All 30+ services queryable in plain English.",subscribeIt:"Subscribe to it",subscribeItDesc:"AI digest on your schedule — daily, twice-daily, or weekly. Slack, Discord, Telegram, Email, webhook.",buildOnIt:"Build on it",buildOnItDesc:"Custom Widget Builder (HTML/CSS/JS + AI) and MCP for Claude, GPT, custom LLMs."},JO={eyebrow:"DELIVERY DESK",title:"Your personal intelligence desk",body:"Up to 30 ranked items per send, deduped across 500+ sources, scored against your watchlist. Choose your cadence — daily, twice-daily, or weekly — with an AI Assessment and Signals to watch delivered to Slack, Discord, Telegram, Email, or webhook.",closer:"Not a newsletter. An analyst.",channels:"Slack · Discord · Telegram · Email · Webhook · AES-256 encrypted · Quiet hours"},WO={windowTitle:"worldmonitor.app — Live Dashboard",openFullScreen:"Open full screen",tryLiveDashboard:"Try the Live Dashboard",iframeTitle:"World Monitor — Live Intelligence Dashboard",description:"3D WebGL globe · 45+ interactive map layers · Real-time market, macro, geopolitical, energy, and infrastructure data"},eR={uniqueVisitors:"Unique visitors",peakDailyUsers:"Peak daily users",countriesReached:"Countries reached",liveDataSources:"Live data sources",quote:"Markets, monetary policy, geopolitics, energy — everything moves together now. I needed something that showed me how these forces connect in real time, not just the headlines but the underlying drivers.",ceo:"CEO of",asToldTo:"as told to"},tR={title:"Built for people who need signal fast",investorsTitle:"Investors & portfolio managers",investorsDesc:"Track global equities, analyst targets, valuation metrics, and macro indicators alongside geopolitical risk signals.",tradersTitle:"Energy & commodities traders",tradersDesc:"Track vessel movements, cargo inference, supply chain disruptions, and market-moving geopolitical signals.",researchersTitle:"Researchers & analysts",researchersDesc:"Equity research, economy analytics, and geopolitical frameworks for deeper analysis and reporting.",journalistsTitle:"Journalists & media",journalistsDesc:"Follow fast-moving developments across markets and regions without stitching sources together manually.",govTitle:"Government & institutions",govDesc:"Macro policy tracking, central bank monitoring, and situational awareness across geopolitical and infrastructure signals.",teamsTitle:"Teams & organizations",teamsDesc:"Move from individual use to shared workflows, API access, TV apps, and managed deployments."},nR={title:"What World Monitor Tracks",subtitle:"30+ service domains ingested simultaneously. Markets, macro, geopolitics, energy, infrastructure — everything normalized and rendered on a WebGL globe.",markets:"Financial Markets & Equities",marketsDesc:"Global stock analysis, commodities, crypto, ETF flows, analyst targets, and FRED macro data",economy:"Economy & Central Banks",economyDesc:"GDP, inflation, interest rates, growth cycles, and monetary policy tracking across major economies",geopolitical:"Geopolitical Analysis",geopoliticalDesc:"ACLED & UCDP events with escalation scoring, risk frameworks, and trend analysis",maritime:"Maritime & Trade",maritimeDesc:"Ship movements, vessel detection, port activity, and cargo inference",aviation:"Aviation Tracking",aviationDesc:"ADS-B transponder tracking of global flight patterns",infra:"Critical Infrastructure",infraDesc:"Nuclear sites, power grids, pipelines, refineries — 50K+ mapped assets",fire:"Satellite Fire Detection",fireDesc:"NASA FIRMS near-real-time fire and hotspot data",cables:"Submarine Cables",cablesDesc:"Undersea cable routes and landing stations",internet:"Internet & GPS",internetDesc:"Outage detection, BGP anomalies, GPS jamming zones",cyber:"Cyber Threats",cyberDesc:"Ransomware feeds, BGP hijacks, DDoS detection",gdelt:"GDELT & News",gdeltDesc:"500+ RSS feeds, AI-scored GDELT events, live broadcasts",seismology:"Seismology & Natural",seismologyDesc:"USGS earthquakes, volcanic activity, severe weather"},iR={free:"Free",freeTagline:"See everything",freeDesc:"The open-source dashboard",freeF1:"5-15 min refresh",freeF2:"500+ feeds, 45 map layers",freeF3:"BYOK for AI",freeF4:"Free forever",openDashboard:"Open Dashboard",pro:"Pro",proTagline:"Markets, macro & geopolitics",proDesc:"Your AI analyst",proF1:"Equity research & stock analysis",proF2:"+ daily briefs, economy analytics",proF3:"AI included, 1 key",proF4:"Priority data refresh (Soon)",priceMonthly:"$39.99 / month",priceAnnual:"$399.99 / year",annualSavingsNote:"2 months free",enterprise:"Enterprise",enterpriseTagline:"Act before anyone else",enterpriseDesc:"The intelligence platform",entF1:"Live-edge + satellite imagery",entF2:"+ AI agents, 50K+ infra, SAR",entF3:"Custom AI, investor personas",entF4:"Contact us",contactSales:"Contact Sales"},aR={proTier:"PRO TIER",title:"Your AI Analyst That Never Sleeps",subtitle:"The free dashboard shows you the world. Pro gives you an analyst to ask, a digest you subscribe to, and primitives to build on. Stocks, macro, geopolitical risk — and the connections between them.",equityResearch:"Equity Research",equityResearchDesc:"Global stock analysis with financials visualization, analyst price targets, and valuation metrics. Track what moves markets.",geopoliticalAnalysis:"Geopolitical Analysis",geopoliticalAnalysisDesc:"Grand Chessboard strategic framework, Prisoners of Geography models, and central bank & monetary policy tracking.",economyAnalytics:"Economy Analytics",economyAnalyticsDesc:"GDP, inflation, interest rates, and growth cycles. Macro data correlated with market signals and geopolitical events.",riskMonitoring:"Risk Monitoring & Scenarios",riskMonitoringDesc:"Global risk scoring, scenario analysis, and geopolitical risk assessment. Convergence detection across market and political signals.",orbitalSurveillance:"Orbital Surveillance",orbitalSurveillanceDesc:"(Soon) Overhead pass predictions, revisit frequency analysis, and imaging window alerts. Know when intelligence satellites are watching your areas of interest.",morningBriefs:"Personal Intelligence Desk",morningBriefsDesc:"Up to 30 ranked stories per digest, deduped across 500+ sources. Pick daily, twice-daily, or weekly cadence — or real-time alerts for critical events. AI Assessment and Signals to watch delivered to Slack, Discord, Telegram, Email, or webhook. Not a newsletter — an analyst.",oneKey:"30+ Services, 1 Key",oneKeyDesc:"Finnhub, FRED, ACLED, UCDP, NASA FIRMS, AISStream, OpenSky, and more — all active, no separate registrations.",deliveryLabel:"Choose how intelligence finds you"},sR={morningBrief:"Morning Brief",markets:"Markets",marketsText:"S&P 500 futures -1.2% pre-market. Fed Chair testimony at 10am EST — rate-sensitive sectors under pressure. Analyst consensus shifting on Q2 earnings.",elevated:"Macro",elevatedText:"ECB holds rates at 3.75%. Euro area GDP revised up to 1.1%. Central bank divergence widening — USD/EUR at 3-month high.",watch:"Geopolitical",watchText:"Brent +2.3% on Hormuz AIS anomaly. 4 dark ships in 6h. Commodity supply chain risk elevated — energy sector correlations spiking."},rR={apiTier:"API TIER",title:"Programmatic Intelligence",subtitle:"For developers, analysts, and teams building on World Monitor data. Separate from Pro — use both or either.",restApi:"REST API across all 30+ service domains",authenticated:"Authenticated per-key, rate-limited per tier",structured:"Structured JSON with cache headers and OpenAPI 3.1 docs",starter:"Starter",starterReqs:"1,000 req/day",starterWebhooks:"5 webhook rules",business:"Business",businessReqs:"50,000 req/day",businessWebhooks:"Unlimited webhooks + SLA",feedData:"Feed data into your dashboards, automate alerting via Zapier/n8n/Make, build custom scoring models on CII/risk data."},oR={enterpriseTier:"ENTERPRISE TIER",title:"Intelligence Infrastructure",subtitle:"For governments, institutions, trading desks, and organizations that need the full platform with maximum security, AI agents, TV apps, and data depth.",security:"Government-Grade Security",securityDesc:"Air-gapped deployment, on-premises Docker, dedicated cloud tenant, SOC 2 Type II path, SSO/MFA, and full audit trail.",aiAgents:"AI Agents & MCP",aiAgentsDesc:"Autonomous intelligence agents with investor personas. Connect World Monitor as a tool to Claude, GPT, or custom LLMs via MCP.",dataLayers:"Expanded Data Layers",dataLayersDesc:"Tens of thousands of infrastructure assets mapped globally. Satellite imagery integration with change detection and SAR.",connectors:"100+ Data Connectors",connectorsDesc:"PostgreSQL, Snowflake, Splunk, Sentinel, Jira, Slack, Teams, and more. Export to PDF, PowerPoint, CSV, GeoJSON.",whiteLabel:"White-Label, TV & Embeddable",whiteLabelDesc:"Your brand, your domain, your desktop app. Android TV app for SOC walls and trading floors. Embeddable iframe panels.",financial:"Financial Intelligence",financialDesc:"Earnings calendar, energy grid data, enhanced commodity tracking with cargo inference, sanctions screening with AIS correlation.",commodity:"Commodity Trading",commodityDesc:"Vessel tracking + cargo inference + supply chain graph. Know before the market moves.",government:"Government & Institutions",governmentDesc:"Air-gapped, AI agents, full situational awareness, MCP. No data leaves your network.",risk:"Risk Consultancies",riskDesc:"Scenario simulation, investor personas, branded PDF/PowerPoint reports on demand.",soc:"SOCs & CERT",socDesc:"Cyber threat layer, SIEM integration, BGP anomaly monitoring, ransomware feeds.",talkToSales:"Talk to Sales",contactFormTitle:"Talk to our team",contactFormSubtitle:"Tell us about your organization and we'll get back to you within one business day.",namePlaceholder:"Your name",emailPlaceholder:"Work email",orgPlaceholder:"Company *",phonePlaceholder:"Phone number *",messagePlaceholder:"What are you looking for?",workEmailRequired:"Please use your work email address",submitContact:"Send Message",contactSending:"Sending...",contactSent:"Message sent. We'll be in touch.",contactFailed:"Failed to send. Please email enterprise@worldmonitor.app"},lR={title:"Compare Tiers",feature:"Feature",freeHeader:"Free ($0)",proHeader:"Pro ($39.99)",apiHeader:"API ($99.99)",entHeader:"Enterprise (Contact)",dataRefresh:"Data refresh",dashboard:"Dashboard",ai:"AI",briefsAlerts:"Briefs & alerts",delivery:"Delivery",apiRow:"API",infraLayers:"Infrastructure layers",satellite:"Orbital Surveillance",connectorsRow:"Connectors",deployment:"Deployment",securityRow:"Security",f5_15min:"5-15 min",fLt60s:"<60 seconds",fPerRequest:"Per-request",fLiveEdge:"Live-edge",f50panels:"50+ panels",fWhiteLabel:"White-label",fBYOK:"BYOK",fIncluded:"Included",fAgentsPersonas:"Agents + personas",fDailyFlash:"Daily + flash",fTeamDist:"Team distribution",fSlackTgWa:"Slack/Discord/TG/Email/Webhook",fWebhook:"Webhook",fSiemMcp:"+ SIEM/MCP",fRestWebhook:"REST + webhook",fMcpBulk:"+ MCP + bulk",f45:"45",fTensOfThousands:"+ tens of thousands",fLiveTracking:"Live tracking",fPassAlerts:"Pass alerts + analysis",fImagerySar:"Imagery + SAR",f100plus:"100+",fCloud:"Cloud",fCloudOnPrem:"Cloud/on-prem/air-gap",fStandard:"Standard",fKeyAuth:"Key auth",fSsoMfa:"SSO/MFA/RBAC/audit",noteBelow:"The core platform remains free. Paid plans unlock equity research, macro analytics, AI briefings, and organizational use."},cR={title:"Frequently Asked Questions",q1:"Is World Monitor still free?",a1:"Yes. The core platform remains free. Pro adds equity research, macro analytics, and AI briefings. Enterprise adds team deployments and TV apps.",q2:"Why pay for Pro?",a2:"Pro is for investors, analysts, and professionals who want stock monitoring, geopolitical analysis, economy analytics, and AI-powered daily briefings — all under one key.",q3:"Who is Enterprise for?",a3:"Enterprise is for teams that need shared use, APIs, integrations, deployment options, and direct support.",q4:"Can I start with Pro and upgrade later?",a4:"Yes. Pro works for serious individuals. Enterprise is there when team and deployment needs grow.",q5:"Is this only for conflict monitoring?",a5:"No. World Monitor is primarily a global intelligence platform covering stock markets, macroeconomics, geopolitical analysis, energy, infrastructure, and more. Conflict tracking is one of many capabilities — not the focus.",q6:"Why keep the core platform free?",a6:"Because public access matters. Paid plans fund deeper workflows for serious users and organizations.",q7:"Can I still use my own API keys?",a7:"Yes. Bring-your-own-keys always works. Pro simply means you don't have to register for 20+ separate services.",q8:"What's MCP?",a8:"MCP lets AI agents — Claude, GPT, custom LLMs — use WorldMonitor as a tool, querying all 30+ services. Included in Pro. Enterprise adds private MCP servers and custom deployments.",q9:"Can I build my own panels?",a9:"Yes. Pro includes the Custom Widget Builder — build panels from HTML, CSS, and JavaScript, with AI-assisted modification.",q10:"Can I connect Claude or GPT to WorldMonitor?",a10:"Yes. MCP is included in Pro — plug WorldMonitor into Claude, GPT, or any MCP-compatible LLM as a live tool.",q11:"How personalized is the digest?",a11:"Pick your cadence — daily, twice-daily, or weekly. We re-score every tracked story against your alert rules and watchlist, dedupe across 500+ sources, and send up to 30 ranked items with an AI Assessment and Signals to watch written to your context. Real-time alerts are also available for critical events.",q12:"What's the refresh rate?",a12:"Near real time for Pro. 5–15 minutes on Free.",q13:"Where does my data go?",a13:"Notification channels are AES-256 encrypted at rest. Digests never leave our pipeline unredacted."},uR={title:"Start with Pro. Scale to Enterprise.",subtitle:"Keep using World Monitor for free, or upgrade for equity research, macro analytics, and AI briefings. If your organization needs team access, TV apps, or API support, talk to us.",getPro:"Reserve Your Early Access",talkToSales:"Talk to Sales"},fR={beFirstInLine:"Be first in line.",lookingForEnterprise:"Looking for Enterprise?",contactUs:"Contact us",wiredArticle:"WIRED Article"},dR={submitting:"Submitting...",joinWaitlist:"Reserve Your Early Access",tooManyRequests:"Too many requests",failedTryAgain:"Failed — try again"},hR={alreadyOnList:"You're already on the list.",shareHint:"Share your link to move up the line. Each friend who joins bumps you closer to the front.",copied:"Copied!",shareOnX:"Share on X",linkedin:"LinkedIn",whatsapp:"WhatsApp",telegram:"Telegram",shareText:"I just joined the World Monitor Pro waitlist — stock monitoring, geopolitical analysis, and AI daily briefings in one platform. Join me:",joinWaitlistShare:"Join the World Monitor Pro waitlist:",youreIn:"You're in!",invitedBanner:"You've been invited — join the waitlist"},mR="Soon",vS={nav:YO,hero:KO,wired:XO,twoPath:$O,whyUpgrade:QO,pillars:ZO,deliveryDesk:JO,livePreview:WO,socialProof:eR,audience:tR,dataCoverage:nR,tiers:iR,proShowcase:aR,slackMock:sR,apiSection:rR,enterpriseShowcase:oR,pricingTable:lR,faq:cR,finalCta:uR,footer:fR,form:dR,referral:hR,soonBadge:mR},xS=["en","ar","bg","cs","de","el","es","fr","it","ja","ko","nl","pl","pt","ro","ru","sv","th","tr","vi","zh"],pR=new Set(xS),xx=new Set(["en"]),gR=new Set(["ar"]),yR=Object.assign({"./locales/ar.json":()=>Ie(()=>import("./ar-Cm8L16fJ.js"),[]).then(n=>n.default),"./locales/bg.json":()=>Ie(()=>import("./bg-meSd4JsJ.js"),[]).then(n=>n.default),"./locales/cs.json":()=>Ie(()=>import("./cs-ptRTyzJj.js"),[]).then(n=>n.default),"./locales/de.json":()=>Ie(()=>import("./de-C3_MVNE9.js"),[]).then(n=>n.default),"./locales/el.json":()=>Ie(()=>import("./el-B9-X35aF.js"),[]).then(n=>n.default),"./locales/es.json":()=>Ie(()=>import("./es-DKuPMUhm.js"),[]).then(n=>n.default),"./locales/fr.json":()=>Ie(()=>import("./fr-CqZfnoPg.js"),[]).then(n=>n.default),"./locales/it.json":()=>Ie(()=>import("./it-xRd9wXeo.js"),[]).then(n=>n.default),"./locales/ja.json":()=>Ie(()=>import("./ja-BvG2yjL7.js"),[]).then(n=>n.default),"./locales/ko.json":()=>Ie(()=>import("./ko-Bp1BAWvm.js"),[]).then(n=>n.default),"./locales/nl.json":()=>Ie(()=>import("./nl-CIy0NOIy.js"),[]).then(n=>n.default),"./locales/pl.json":()=>Ie(()=>import("./pl-P7FWM5y7.js"),[]).then(n=>n.default),"./locales/pt.json":()=>Ie(()=>import("./pt-RlnECMQU.js"),[]).then(n=>n.default),"./locales/ro.json":()=>Ie(()=>import("./ro-OfGDlDfm.js"),[]).then(n=>n.default),"./locales/ru.json":()=>Ie(()=>import("./ru-BgqyPHlN.js"),[]).then(n=>n.default),"./locales/sv.json":()=>Ie(()=>import("./sv-DuX3Lsqd.js"),[]).then(n=>n.default),"./locales/th.json":()=>Ie(()=>import("./th-CD3FOyKH.js"),[]).then(n=>n.default),"./locales/tr.json":()=>Ie(()=>import("./tr-F4p4sScu.js"),[]).then(n=>n.default),"./locales/vi.json":()=>Ie(()=>import("./vi-D1texoPw.js"),[]).then(n=>n.default),"./locales/zh.json":()=>Ie(()=>import("./zh-BxyDCIra.js"),[]).then(n=>n.default)});function vR(n){var a;const t=((a=(n||"en").split("-")[0])==null?void 0:a.toLowerCase())||"en";return pR.has(t)?t:"en"}async function xR(n){const t=vR(n);if(xx.has(t))return t;const a=yR[`./locales/${t}.json`],r=a?await a():vS;return Ze.addResourceBundle(t,"translation",r,!0,!0),xx.add(t),t}async function bR(){if(Ze.isInitialized)return;await Ze.use(yS).init({resources:{en:{translation:vS}},supportedLngs:[...xS],nonExplicitSupportedLngs:!0,fallbackLng:"en",interpolation:{escapeValue:!1},detection:{order:["querystring","localStorage","navigator"],lookupQuerystring:"lang",caches:["localStorage"]}});const n=await xR(Ze.language||"en");n!=="en"&&await Ze.changeLanguage(n);const t=(Ze.language||n).split("-")[0]||"en";document.documentElement.setAttribute("lang",t==="zh"?"zh-CN":t),gR.has(t)&&document.documentElement.setAttribute("dir","rtl")}function _(n,t){return Ze.t(n,t)}const bS="https://api.worldmonitor.app/api",bx="https://customer.dodopayments.com",SR="ACTIVE_SUBSCRIPTION_EXISTS",Sx="'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace";let nt=null,gr=null,Vl=null,bl=!1,or=null;async function Er(){return nt||or||(or=wR().catch(n=>{throw or=null,n}),or)}async function wR(){const{Clerk:n}=await Ie(async()=>{const{Clerk:r}=await import("./clerk-PNSFEZs8.js");return{Clerk:r}},[]),t="pk_live_Y2xlcmsud29ybGRtb25pdG9yLmFwcCQ",a=new n(t);return await a.load({appearance:{variables:{colorBackground:"#0f0f0f",colorInputBackground:"#141414",colorInputText:"#e8e8e8",colorText:"#e8e8e8",colorTextSecondary:"#aaaaaa",colorPrimary:"#44ff88",colorNeutral:"#e8e8e8",colorDanger:"#ff4444",borderRadius:"4px",fontFamily:Sx,fontFamilyButtons:Sx},elements:{card:{backgroundColor:"#111111",border:"1px solid #2a2a2a",boxShadow:"0 8px 32px rgba(0,0,0,0.6)"},formButtonPrimary:{color:"#000000",fontWeight:"600"},footerActionLink:{color:"#44ff88"},socialButtonsBlockButton:{borderColor:"#2a2a2a",color:"#e8e8e8",backgroundColor:"#141414"}}}}),nt=a,nt.addListener(()=>{if(nt!=null&&nt.user&&gr){const r=gr,l=Vl;gr=null,Vl=null,SS(r,l??{})}}),nt}function _R(n){Ie(async()=>{const{DodoPayments:t}=await import("./index.esm-BiNDwt_v.js");return{DodoPayments:t}},[]).then(({DodoPayments:t})=>{t.Initialize({mode:"test",displayType:"overlay",onEvent:a=>{var r,l,c;a.event_type==="checkout.status"&&(((r=a.data)==null?void 0:r.status)??((c=(l=a.data)==null?void 0:l.message)==null?void 0:c.status))==="succeeded"&&(n==null||n())}})}).catch(t=>{console.error("[checkout] Failed to load Dodo overlay SDK:",t)})}async function TR(n,t){if(bl)return!1;let a;try{a=await Er()}catch(r){return console.error("[checkout] Failed to load Clerk:",r),Ja(r,{tags:{surface:"pro-marketing",action:"load-clerk"}}),!1}if(!a.user){gr=n,Vl=t??null;try{a.openSignIn()}catch(r){console.error("[checkout] Failed to open sign in:",r),Ja(r,{tags:{surface:"pro-marketing",action:"checkout-sign-in"}}),gr=null,Vl=null}return!1}return SS(n,t??{})}async function SS(n,t){if(bl)return!1;bl=!0;try{const a=await ER();if(!a)return console.error("[checkout] No auth token after retry"),!1;const r=await fetch(`${bS}/create-checkout`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({productId:n,returnUrl:"https://worldmonitor.app",discountCode:t.discountCode,referralCode:t.referralCode}),signal:AbortSignal.timeout(15e3)});if(!r.ok){const f=await r.json().catch(()=>({}));return console.error("[checkout] Edge error:",r.status,f),r.status===409&&(f==null?void 0:f.error)===SR&&await AR(a,f==null?void 0:f.message),!1}const l=await r.json();if(!(l!=null&&l.checkout_url))return console.error("[checkout] No checkout_url in response"),!1;const{DodoPayments:c}=await Ie(async()=>{const{DodoPayments:f}=await import("./index.esm-BiNDwt_v.js");return{DodoPayments:f}},[]);return c.Checkout.open({checkoutUrl:l.checkout_url,options:{manualRedirect:!0,themeConfig:{dark:{bgPrimary:"#0d0d0d",bgSecondary:"#1a1a1a",borderPrimary:"#323232",textPrimary:"#ffffff",textSecondary:"#909090",buttonPrimary:"#22c55e",buttonPrimaryHover:"#16a34a",buttonTextPrimary:"#0d0d0d"},light:{bgPrimary:"#ffffff",bgSecondary:"#f8f9fa",borderPrimary:"#d4d4d4",textPrimary:"#1a1a1a",textSecondary:"#555555",buttonPrimary:"#16a34a",buttonPrimaryHover:"#15803d",buttonTextPrimary:"#ffffff"},radius:"4px"}}}),!0}catch(a){return console.error("[checkout] Failed:",a),!1}finally{bl=!1}}async function ER(){var t,a,r,l;let n=await((t=nt==null?void 0:nt.session)==null?void 0:t.getToken({template:"convex"}).catch(()=>null))??await((a=nt==null?void 0:nt.session)==null?void 0:a.getToken().catch(()=>null));return n||(await new Promise(c=>setTimeout(c,2e3)),n=await((r=nt==null?void 0:nt.session)==null?void 0:r.getToken({template:"convex"}).catch(()=>null))??await((l=nt==null?void 0:nt.session)==null?void 0:l.getToken().catch(()=>null))),n}async function AR(n,t){t&&console.warn("[checkout] Redirecting to billing portal:",t);try{const a=await fetch(`${bS}/customer-portal`,{method:"POST",headers:{Authorization:`Bearer ${n}`},signal:AbortSignal.timeout(15e3)}),r=await a.json().catch(()=>({})),l=typeof(r==null?void 0:r.portal_url)=="string"?r.portal_url:bx;a.ok||console.error("[checkout] Customer portal error:",a.status,r),window.location.assign(l)}catch(a){console.error("[checkout] Failed to open billing portal:",a),window.location.assign(bx)}}const NR=[{name:"Free",price:0,period:"forever",description:"Get started with the essentials",features:["Core dashboard panels","Global news feed","Earthquake & weather alerts","Basic map view"],cta:"Get Started",href:"https://worldmonitor.app",highlighted:!1},{name:"Pro",monthlyPrice:39.99,annualPrice:399.99,description:"Full intelligence dashboard",features:["Everything in Free","AI stock analysis & backtesting","Daily market briefs","Military & geopolitical tracking","Custom widget builder","MCP data connectors","Priority data refresh"],monthlyProductId:"pdt_0Nbtt71uObulf7fGXhQup",annualProductId:"pdt_0NbttMIfjLWC10jHQWYgJ",highlighted:!0},{name:"API",monthlyPrice:99.99,annualPrice:999,description:"Programmatic access to intelligence data",features:["REST API access","Real-time data streams","1,000 requests/day","Webhook notifications","Custom data exports"],monthlyProductId:"pdt_0NbttVmG1SERrxhygbbUq",annualProductId:"pdt_0Nbu2lawHYE3dv2THgSEV",highlighted:!1},{name:"Enterprise",price:null,description:"Custom solutions for organizations",features:["Everything in Pro + API","Unlimited API requests","Dedicated support","Custom integrations","SLA guarantee","On-premise option"],cta:"Contact Sales",href:"mailto:enterprise@worldmonitor.app",highlighted:!1}],DR="https://api.worldmonitor.app/api/product-catalog";function jR(){const[n,t]=Z.useState(NR);return Z.useEffect(()=>{let a=!1;return fetch(DR,{signal:AbortSignal.timeout(5e3)}).then(r=>r.ok?r.json():null).then(r=>{var l;!a&&((l=r==null?void 0:r.tiers)!=null&&l.length)&&t(r.tiers)}).catch(()=>{}),()=>{a=!0}},[]),n}function CR(n,t){return n.price===0?{amount:"$0",suffix:"forever"}:n.price===null&&n.monthlyPrice===void 0?{amount:"Custom",suffix:"tailored to you"}:n.annualPrice===null&&n.monthlyPrice!==void 0?{amount:`$${n.monthlyPrice}`,suffix:"/mo"}:t==="annual"&&n.annualPrice!=null?{amount:`$${n.annualPrice}`,suffix:"/yr"}:{amount:`$${n.monthlyPrice}`,suffix:"/mo"}}function MR(n,t){return n.cta&&n.href&&n.price===0?{type:"link",label:n.cta,href:n.href,external:!0}:n.cta&&n.href&&n.price===null?{type:"link",label:n.cta,href:n.href,external:!0}:n.monthlyProductId?{type:"checkout",label:"Get Started",productId:t==="annual"&&n.annualProductId?n.annualProductId:n.monthlyProductId}:{type:"link",label:"Learn More",href:"#",external:!1}}function OR({refCode:n}){const[t,a]=Z.useState("monthly"),r=jR(),l=Z.useCallback(c=>{TR(c,{referralCode:n})},[n]);return g.jsx("section",{id:"pricing",className:"py-24 px-6 border-t border-wm-border bg-[#060606]",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsxs("div",{className:"text-center mb-16",children:[g.jsx(Ka.h2,{className:"text-3xl md:text-5xl font-display font-bold mb-4",initial:{opacity:0,y:20},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5},children:"Choose Your Plan"}),g.jsx(Ka.p,{className:"text-wm-muted max-w-xl mx-auto mb-8",initial:{opacity:0,y:10},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5,delay:.1},children:"From real-time monitoring to full intelligence infrastructure. Pick the tier that fits your mission."}),g.jsxs(Ka.div,{className:"inline-flex items-center gap-3 bg-wm-card border border-wm-border rounded-sm p-1",initial:{opacity:0,y:10},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5,delay:.2},children:[g.jsx("button",{onClick:()=>a("monthly"),className:`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors ${t==="monthly"?"bg-wm-green text-wm-bg font-bold":"text-wm-muted hover:text-wm-text"}`,children:"Monthly"}),g.jsxs("button",{onClick:()=>a("annual"),className:`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors flex items-center gap-2 ${t==="annual"?"bg-wm-green text-wm-bg font-bold":"text-wm-muted hover:text-wm-text"}`,children:["Annual",g.jsx("span",{className:`text-[10px] px-1.5 py-0.5 rounded-sm ${t==="annual"?"bg-wm-bg/20 text-wm-bg":"bg-wm-green/10 text-wm-green"}`,children:"Save 17%"})]})]})]}),g.jsx("div",{className:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6",children:r.map((c,f)=>{const h=CR(c,t),m=MR(c,t);return g.jsxs(Ka.div,{className:`relative bg-zinc-900 rounded-lg p-6 flex flex-col ${c.highlighted?"border-2 border-wm-green shadow-lg shadow-wm-green/10":"border border-wm-border"}`,initial:{opacity:0,y:30},whileInView:{opacity:1,y:0},viewport:{once:!0},transition:{duration:.5,delay:f*.1},children:[c.highlighted&&g.jsxs("div",{className:"absolute -top-3 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 bg-wm-green text-wm-bg px-3 py-1 rounded-full text-xs font-mono font-bold uppercase tracking-wider",children:[g.jsx(rO,{className:"w-3 h-3","aria-hidden":"true"}),"Most Popular"]}),g.jsx("h3",{className:`font-display text-lg font-bold mb-1 ${c.highlighted?"text-wm-green":"text-wm-text"}`,children:c.name}),g.jsx("p",{className:"text-xs text-wm-muted mb-4",children:c.description}),g.jsxs("div",{className:"mb-6",children:[g.jsx("span",{className:"text-4xl font-display font-bold",children:h.amount}),g.jsxs("span",{className:"text-sm text-wm-muted ml-1",children:["/",h.suffix]})]}),g.jsx("ul",{className:"space-y-3 mb-8 flex-1",children:c.features.map((p,v)=>g.jsxs("li",{className:"flex items-start gap-2 text-sm",children:[g.jsx(Hd,{className:`w-4 h-4 shrink-0 mt-0.5 ${c.highlighted?"text-wm-green":"text-wm-muted"}`,"aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted",children:p})]},v))}),m.type==="link"?g.jsxs("a",{href:m.href,target:m.external?"_blank":void 0,rel:m.external?"noreferrer":void 0,className:`block text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors ${c.highlighted?"bg-wm-green text-wm-bg hover:bg-green-400":"border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text"}`,children:[m.label," ",g.jsx(Wi,{className:"w-3.5 h-3.5 inline-block ml-1","aria-hidden":"true"})]}):g.jsxs("button",{onClick:()=>l(m.productId),className:`block w-full text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors cursor-pointer ${c.highlighted?"bg-wm-green text-wm-bg hover:bg-green-400":"border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text"}`,children:[m.label," ",g.jsx(Wi,{className:"w-3.5 h-3.5 inline-block ml-1","aria-hidden":"true"})]})]},c.name)})}),g.jsx("p",{className:"text-center text-xs text-wm-muted font-mono mt-8",children:"Have a promo code? Enter it during checkout."})]})})}function RR(){return g.jsx("span",{style:{display:"inline-block",padding:"2px 8px",marginLeft:"6px",fontSize:"10px",fontWeight:600,letterSpacing:"0.04em",textTransform:"uppercase",color:"#fbbf24",background:"rgba(251,191,36,0.12)",border:"1px solid rgba(251,191,36,0.3)",borderRadius:"4px",verticalAlign:"middle"},children:_("soonBadge")})}const kR="/pro/assets/worldmonitor-7-mar-2026-CtI5YvxO.jpg",LR="data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0.11%2010.99%20124.78%2024.98'%3e%3cpath%20d='M105.375%2014.875v17.25h8.5c2.375%200%203.75-.375%204.75-1.25%201.25-1.125%201.875-3.125%201.875-7.375s-.625-6.25-1.875-7.375c-1-.875-2.375-1.25-4.75-1.25zM117%2023.5c0%203.75-.25%204.625-1%205.125-.5.375-1.125.5-2.375.5h-4.75V17.75h4.75c1.25%200%201.875%200%202.375.5.75.625%201%201.5%201%205.25zm7.875%2012.438H99.937V11h24.938zM79.563%2017.75v-2.875h14.75v5.5h-3.126V17.75h-6v4.125h4.75v2.75h-4.75v4.625h6.126v-3h3.124v5.875H79.564V29.25h2.374v-11.5zM66.188%2027.625c0%201.875.124%203.25.374%204.375h3.376c-.126-.875-.25-2.5-.25-4.625-.126-2.5-.876-2.875-2.626-3.25%202-.375%202.876-1.25%202.876-4.375%200-2.5-.376-3.5-1.126-4.125-.5-.5-1.374-.75-2.75-.75h-10.5v17.25h3.5v-6.75h4.876c1%200%201.374.125%201.75.375s.5.625.5%201.875zm-7.126-5v-4.75h5.626c.75%200%201%20.125%201.124.25.25.25.5.625.5%202.125s-.25%202-.5%202.25c-.124.125-.374.25-1.124.25zm15.876%2013.313h-25V11h24.937v24.938zM43.438%2029.25v2.875H31.562V29.25h4.25v-11.5h-4.25v-2.875h11.875v2.875h-4.25v11.5zM23.375%2014.875h-3.25L17.75%2028.5%2015%2015.875c-.125-.875-.5-1-1.25-1H12c-.75%200-1.125.25-1.25%201L8%2028.5%205.625%2014.875h-3.5L5.5%2031.25c.125.75.375.875%201.25.875h2.375c.75%200%201-.125%201.25-.875L13%2019.375l2.625%2011.875c.125.75.375.875%201.25.875h2.25c.75%200%201.125-.125%201.25-.875zm1.75%2021.063h-25V11h24.938v24.938z'%3e%3c/path%3e%3c/svg%3e",zR="https://api.worldmonitor.app/api",VR="0x4AAAAAACnaYgHIyxclu8Tj";function BR(){if(!window.turnstile)return 0;let n=0;return document.querySelectorAll(".cf-turnstile:not([data-rendered])").forEach(t=>{const a=window.turnstile.render(t,{sitekey:VR,size:"flexible",callback:r=>{t.dataset.token=r},"expired-callback":()=>{delete t.dataset.token},"error-callback":()=>{delete t.dataset.token}});t.dataset.rendered="true",t.dataset.widgetId=String(a),n++}),n}function UR(){return new URLSearchParams(window.location.search).get("ref")||void 0}function wS(){Er().then(n=>n.openSignIn()).catch(n=>{console.error("[auth] Failed to open sign in:",n),Ja(n,{tags:{surface:"pro-marketing",action:"open-sign-in"}})})}function _S(){const[n,t]=Z.useState(null),[a,r]=Z.useState(!1);return Z.useEffect(()=>{let l=!0,c;return Er().then(f=>{l&&(t(f.user??null),r(!0),c=f.addListener(()=>{l&&t(f.user??null)}))}).catch(f=>{console.error("[auth] Failed to load Clerk for nav auth state:",f),Ja(f,{tags:{surface:"pro-marketing",action:"load-clerk-for-nav"}}),l&&r(!0)}),()=>{l=!1,c==null||c()}},[]),{user:n,isLoaded:a}}function HR(){const n=Z.useRef(null);return Z.useEffect(()=>{if(!n.current)return;const t=n.current;let a=!1;return Er().then(r=>{a||!t||r.mountUserButton(t,{afterSignOutUrl:"https://www.worldmonitor.app/pro"})}).catch(r=>{console.error("[auth] Failed to mount user button:",r),Ja(r,{tags:{surface:"pro-marketing",action:"mount-user-button"}})}),()=>{a=!0,Er().then(r=>{t&&r.unmountUserButton(t)}).catch(()=>{})}},[]),g.jsx("div",{ref:n,className:"flex items-center"})}const PR=()=>g.jsx("svg",{viewBox:"0 0 24 24",className:"w-5 h-5",fill:"currentColor","aria-hidden":"true",children:g.jsx("path",{d:"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"})}),TS=()=>g.jsxs("a",{href:"https://worldmonitor.app",className:"flex items-center gap-2 hover:opacity-80 transition-opacity","aria-label":"World Monitor — Home",children:[g.jsxs("div",{className:"relative w-8 h-8 rounded-full bg-wm-card border border-wm-border flex items-center justify-center overflow-hidden",children:[g.jsx(Rl,{className:"w-5 h-5 text-wm-blue opacity-50 absolute","aria-hidden":"true"}),g.jsx(l3,{className:"w-6 h-6 text-wm-green absolute z-10","aria-hidden":"true"})]}),g.jsxs("div",{className:"flex flex-col",children:[g.jsx("span",{className:"font-display font-bold text-sm leading-none tracking-tight",children:"WORLD MONITOR"}),g.jsx("span",{className:"text-[9px] text-wm-muted font-mono uppercase tracking-widest leading-none mt-1",children:"by Someone.ceo"})]})]}),qR=()=>{const{user:n,isLoaded:t}=_S();return g.jsx("nav",{className:"fixed top-0 left-0 right-0 z-50 glass-panel border-b-0 border-x-0 rounded-none","aria-label":"Main navigation",children:g.jsxs("div",{className:"max-w-7xl mx-auto px-6 h-16 flex items-center justify-between",children:[g.jsx(TS,{}),g.jsxs("div",{className:"hidden md:flex items-center gap-8 text-sm font-mono text-wm-muted",children:[g.jsx("a",{href:"#tiers",className:"hover:text-wm-text transition-colors",children:_("nav.free")}),g.jsx("a",{href:"#pro",className:"hover:text-wm-green transition-colors",children:_("nav.pro")}),g.jsx("a",{href:"#api",className:"hover:text-wm-text transition-colors",children:_("nav.api")}),g.jsx("a",{href:"#enterprise",className:"hover:text-wm-text transition-colors",children:_("nav.enterprise")})]}),g.jsxs("div",{className:"flex items-center gap-2",children:[t&&(n?g.jsx(HR,{}):g.jsx("button",{type:"button",onClick:wS,className:"border border-wm-border text-wm-text px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:border-wm-text transition-colors",children:_("nav.signIn")})),g.jsx("a",{href:"#pricing",className:"bg-wm-green text-wm-bg px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:_("nav.upgradeToPro")})]})]})})},IR=()=>g.jsxs("a",{href:"https://www.wired.me/story/the-music-streaming-ceo-who-built-a-global-war-map",target:"_blank",rel:"noreferrer",className:"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-wm-border bg-wm-card/50 text-wm-muted text-xs font-mono hover:border-wm-green/30 hover:text-wm-text transition-colors",children:[_("wired.asFeaturedIn")," ",g.jsx("span",{className:"text-wm-text font-bold",children:"WIRED"})," ",g.jsx(rS,{className:"w-3 h-3","aria-hidden":"true"})]}),FR=()=>g.jsxs("div",{className:"relative my-4 md:my-8 -mx-6",children:[g.jsx("div",{className:"absolute inset-0 flex items-center justify-center pointer-events-none",children:g.jsx("div",{className:"w-64 h-40 md:w-96 md:h-56 bg-wm-green/8 rounded-full blur-[80px]"})}),g.jsx("div",{className:"flex items-end justify-center gap-[3px] md:gap-1 h-28 md:h-44 relative px-4","aria-hidden":"true",children:Array.from({length:60}).map((r,l)=>{const c=Math.abs(l-30),f=c<=8,h=f?1-c/8:0,m=60+h*110,p=Math.max(8,35-c*.8);return g.jsx(Ka.div,{className:`flex-1 max-w-2 md:max-w-3 rounded-sm ${f?"bg-wm-green":"bg-wm-muted/20"}`,style:f?{boxShadow:`0 0 ${6+h*12}px rgba(74,222,128,${h*.5})`}:void 0,initial:{height:f?m*.3:p*.5,opacity:f?.4:.08},animate:f?{height:[m*.5,m,m*.65,m*.9],opacity:[.6+h*.3,1,.75+h*.2,.95]}:{height:[p,p*.3,p*.7,p*.15,p*.5],opacity:[.2,.06,.15,.04,.12]},transition:{duration:f?2.5+h*.5:1+Math.random()*.6,repeat:1/0,repeatType:"reverse",delay:f?c*.07:Math.random()*.6,ease:"easeInOut"}},l)})})]}),GR=()=>{const{user:n,isLoaded:t}=_S(),a=t&&!n;return g.jsxs("section",{className:"pt-28 pb-12 px-6 relative overflow-hidden",children:[g.jsx("div",{className:"absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(74,222,128,0.08)_0%,transparent_50%)] pointer-events-none"}),g.jsx("div",{className:"max-w-4xl mx-auto text-center relative z-10",children:g.jsxs(Ka.div,{initial:{opacity:0,y:20},animate:{opacity:1,y:0},transition:{duration:.6},children:[g.jsx("div",{className:"mb-4",children:g.jsx(IR,{})}),g.jsxs("h1",{className:"text-6xl md:text-8xl font-display font-bold tracking-tighter leading-[0.95]",children:[g.jsx("span",{className:"text-wm-muted/40",children:_("hero.noiseWord")}),g.jsx("span",{className:"mx-3 md:mx-5 text-wm-border/50",children:"→"}),g.jsx("span",{className:"text-transparent bg-clip-text bg-gradient-to-r from-wm-green to-emerald-300 text-glow",children:_("hero.signalWord")})]}),g.jsx(FR,{}),g.jsx("p",{className:"text-lg md:text-xl text-wm-muted max-w-xl mx-auto font-light leading-relaxed",children:_("hero.valueProps")}),g.jsxs("div",{className:"flex flex-col sm:flex-row gap-3 justify-center mt-8",children:[g.jsxs("a",{href:"#pricing",className:"bg-wm-green text-wm-bg px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors flex items-center justify-center gap-2",children:[_("hero.choosePlan")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]}),a&&g.jsx("button",{type:"button",onClick:wS,className:"border border-wm-border text-wm-text px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:border-wm-text transition-colors",children:_("hero.signIn")})]}),g.jsx("div",{className:"flex items-center justify-center mt-4",children:g.jsxs("a",{href:"https://worldmonitor.app",className:"text-xs text-wm-green font-mono hover:text-green-300 transition-colors flex items-center gap-1",children:[_("hero.tryFreeDashboard")," ",g.jsx(Wi,{className:"w-3 h-3","aria-hidden":"true"})]})})]})})]})},YR=()=>g.jsx("section",{className:"border-y border-wm-border bg-wm-card/30 py-16 px-6",children:g.jsxs("div",{className:"max-w-5xl mx-auto",children:[g.jsx("div",{className:"grid grid-cols-2 md:grid-cols-4 gap-8 text-center mb-12",children:[{value:"2M+",label:_("socialProof.uniqueVisitors")},{value:"421K",label:_("socialProof.peakDailyUsers")},{value:"190+",label:_("socialProof.countriesReached")},{value:"500+",label:_("socialProof.liveDataSources")}].map((n,t)=>g.jsxs("div",{children:[g.jsx("p",{className:"text-3xl md:text-4xl font-display font-bold text-wm-green",children:n.value}),g.jsx("p",{className:"text-xs font-mono text-wm-muted uppercase tracking-widest mt-1",children:n.label})]},t))}),g.jsxs("blockquote",{className:"max-w-3xl mx-auto text-center",children:[g.jsxs("p",{className:"text-lg md:text-xl text-wm-muted italic leading-relaxed",children:['"',_("socialProof.quote"),'"']}),g.jsx("footer",{className:"mt-6 flex items-center justify-center gap-3",children:g.jsx("a",{href:"https://www.wired.me/story/the-music-streaming-ceo-who-built-a-global-war-map",target:"_blank",rel:"noreferrer",className:"inline-flex items-center gap-2 text-wm-muted hover:text-wm-text transition-colors",children:g.jsx("img",{src:LR,alt:"WIRED",className:"h-5 brightness-0 invert opacity-60 hover:opacity-100 transition-opacity"})})})]})]})}),KR=()=>g.jsxs("section",{className:"py-24 px-6 max-w-5xl mx-auto",id:"tiers",children:[g.jsx("h2",{className:"sr-only",children:"Plans"}),g.jsxs("div",{className:"grid md:grid-cols-2 gap-8",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-green p-8 relative border-glow",children:[g.jsx("div",{className:"absolute top-0 left-0 w-full h-1 bg-wm-green"}),g.jsx("h3",{className:"font-display text-2xl font-bold mb-2",children:_("twoPath.proTitle")}),g.jsx("p",{className:"text-sm text-wm-muted mb-6",children:_("twoPath.proDesc")}),g.jsx("ul",{className:"space-y-3 mb-8",children:[_("twoPath.proF1"),_("twoPath.proF2"),_("twoPath.proF3"),_("twoPath.proF4"),_("twoPath.proF5"),_("twoPath.proF6"),_("twoPath.proF7"),_("twoPath.proF8"),_("twoPath.proF9")].map((n,t)=>g.jsxs("li",{className:"flex items-start gap-3 text-sm",children:[g.jsx(Hd,{className:"w-4 h-4 shrink-0 mt-0.5 text-wm-green","aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted",children:n})]},t))}),g.jsx("a",{href:"#pricing",className:"block text-center py-2.5 rounded-sm font-mono text-xs uppercase tracking-wider font-bold bg-wm-green text-wm-bg hover:bg-green-400 transition-colors",children:_("twoPath.choosePlan")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-8",children:[g.jsx("h3",{className:"font-display text-2xl font-bold mb-2",children:_("twoPath.entTitle")}),g.jsx("p",{className:"text-sm text-wm-muted mb-6",children:_("twoPath.entDesc")}),g.jsxs("ul",{className:"space-y-3 mb-8",children:[g.jsx("li",{className:"text-xs font-mono text-wm-green uppercase tracking-wider mb-1",children:_("twoPath.entF1")}),[_("twoPath.entF2"),_("twoPath.entF3"),_("twoPath.entF4"),_("twoPath.entF5"),_("twoPath.entF6"),_("twoPath.entF7"),_("twoPath.entF8"),_("twoPath.entF9"),_("twoPath.entF10"),_("twoPath.entF11")].map((n,t)=>g.jsxs("li",{className:"flex items-start gap-3 text-sm",children:[g.jsx(Hd,{className:"w-4 h-4 shrink-0 mt-0.5 text-wm-muted","aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted",children:n})]},t))]}),g.jsx("a",{href:"#enterprise",className:"block text-center py-2.5 rounded-sm font-mono text-xs uppercase tracking-wider font-bold border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text transition-colors",children:_("twoPath.entCta")})]})]})]}),XR=()=>{const n=[{icon:g.jsx(M3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.noiseTitle"),desc:_("whyUpgrade.noiseDesc")},{icon:g.jsx(uS,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.fasterTitle"),desc:_("whyUpgrade.fasterDesc")},{icon:g.jsx(eO,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.controlTitle"),desc:_("whyUpgrade.controlDesc")},{icon:g.jsx(cS,{className:"w-6 h-6","aria-hidden":"true"}),title:_("whyUpgrade.deeperTitle"),desc:_("whyUpgrade.deeperDesc")}];return g.jsx("section",{className:"py-24 px-6 border-t border-wm-border bg-wm-card/20",children:g.jsxs("div",{className:"max-w-5xl mx-auto",children:[g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-16 text-center",children:_("whyUpgrade.title")}),g.jsx("div",{className:"grid md:grid-cols-2 gap-8",children:n.map((t,a)=>g.jsxs("div",{className:"flex gap-5",children:[g.jsx("div",{className:"text-wm-green shrink-0 mt-1",children:t.icon}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold text-lg mb-2",children:t.title}),g.jsx("p",{className:"text-sm text-wm-muted leading-relaxed",children:t.desc})]})]},a))})]})})},$R=()=>{const n=[{icon:g.jsx(h3,{className:"w-7 h-7","aria-hidden":"true"}),title:_("pillars.askIt"),desc:_("pillars.askItDesc")},{icon:g.jsx(f3,{className:"w-7 h-7","aria-hidden":"true"}),title:_("pillars.subscribeIt"),desc:_("pillars.subscribeItDesc")},{icon:g.jsx(Jl,{className:"w-7 h-7","aria-hidden":"true"}),title:_("pillars.buildOnIt"),desc:_("pillars.buildOnItDesc")}];return g.jsx("section",{className:"py-20 px-6 border-t border-wm-border",children:g.jsx("div",{className:"max-w-5xl mx-auto",children:g.jsx("div",{className:"grid md:grid-cols-3 gap-6",children:n.map((t,a)=>g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6 hover:border-wm-green/30 transition-colors",children:[g.jsx("div",{className:"text-wm-green mb-4",children:t.icon}),g.jsx("h3",{className:"font-display text-xl font-bold mb-2",children:t.title}),g.jsx("p",{className:"text-sm text-wm-muted leading-relaxed",children:t.desc})]},a))})})})},QR=()=>g.jsx("section",{className:"py-24 px-6 border-t border-wm-border bg-wm-card/20",children:g.jsxs("div",{className:"max-w-4xl mx-auto text-center",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-green/30 bg-wm-green/10 text-wm-green text-xs font-mono mb-6",children:_("deliveryDesk.eyebrow")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("deliveryDesk.title")}),g.jsx("p",{className:"text-lg text-wm-muted leading-relaxed mb-6",children:_("deliveryDesk.body")}),g.jsx("p",{className:"text-xl md:text-2xl font-display font-bold text-wm-green mb-8",children:_("deliveryDesk.closer")}),g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest",children:_("deliveryDesk.channels")})]})}),ZR=()=>g.jsx("section",{className:"px-6 py-16",children:g.jsxs("div",{className:"max-w-6xl mx-auto",children:[g.jsxs("div",{className:"relative rounded-lg overflow-hidden border border-wm-border shadow-2xl shadow-wm-green/5",children:[g.jsxs("div",{className:"bg-wm-card px-4 py-2 border-b border-wm-border flex items-center gap-3",children:[g.jsxs("div",{className:"flex gap-1.5",children:[g.jsx("div",{className:"w-3 h-3 rounded-full bg-red-500/70"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-yellow-500/70"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-green-500/70"})]}),g.jsx("span",{className:"font-mono text-xs text-wm-muted ml-2",children:_("livePreview.windowTitle")}),g.jsxs("a",{href:"https://worldmonitor.app",target:"_blank",rel:"noreferrer",className:"ml-auto text-xs text-wm-green font-mono hover:text-green-300 transition-colors flex items-center gap-1",children:[_("livePreview.openFullScreen")," ",g.jsx(rS,{className:"w-3 h-3","aria-hidden":"true"})]})]}),g.jsxs("div",{className:"relative aspect-[16/9] bg-black",children:[g.jsx("img",{src:kR,alt:"World Monitor Dashboard",className:"absolute inset-0 w-full h-full object-cover"}),g.jsx("iframe",{src:"https://worldmonitor.app?embed=pro-preview",title:_("livePreview.iframeTitle"),className:"relative w-full h-full border-0",loading:"lazy",sandbox:"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"}),g.jsx("div",{className:"absolute inset-0 pointer-events-none bg-gradient-to-t from-wm-bg/80 via-transparent to-transparent"}),g.jsx("div",{className:"absolute bottom-4 left-0 right-0 text-center pointer-events-auto",children:g.jsxs("a",{href:"https://worldmonitor.app",target:"_blank",rel:"noreferrer",className:"inline-flex items-center gap-2 bg-wm-green text-wm-bg px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:[_("livePreview.tryLiveDashboard")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]})})]})]}),g.jsx("p",{className:"text-center text-xs text-wm-muted font-mono mt-4",children:_("livePreview.description")})]})}),JR=()=>{const t=["Finnhub","FRED","Bloomberg","CNBC","Nikkei","CoinGecko","Polymarket","Reuters","ACLED","UCDP","GDELT","NASA FIRMS","USGS","OpenSky","AISStream","Cloudflare Radar","BGPStream","GPSJam","NOAA","Copernicus","IAEA","Al Jazeera","Sky News","Euronews","DW News","France 24","OilPrice","Rigzone","Maritime Executive","Hellenic Shipping News","Defense One","Jane's","The War Zone","TechCrunch","Ars Technica","The Verge","Wired","Krebs on Security","BleepingComputer","The Record"].join(" · ");return g.jsx("section",{className:"border-y border-wm-border bg-wm-card/20 overflow-hidden py-4","aria-label":"Data sources",children:g.jsxs("div",{className:"marquee-track whitespace-nowrap font-mono text-xs text-wm-muted uppercase tracking-widest",children:[g.jsxs("span",{className:"inline-block px-4",children:[t," · "]}),g.jsxs("span",{className:"inline-block px-4",children:[t," · "]})]})})},WR=()=>g.jsx("section",{className:"py-24 px-6 border-t border-wm-border bg-wm-card/30",id:"pro",children:g.jsxs("div",{className:"max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-start",children:[g.jsxs("div",{children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-green/30 bg-wm-green/10 text-wm-green text-xs font-mono mb-6",children:_("proShowcase.proTier")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("proShowcase.title")}),g.jsx("p",{className:"text-wm-muted mb-8",children:_("proShowcase.subtitle")}),g.jsxs("div",{className:"space-y-6",children:[g.jsxs("div",{className:"flex gap-4",children:[g.jsx(uS,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.equityResearch")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.equityResearchDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Rl,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.geopoliticalAnalysis")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.geopoliticalAnalysisDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Ch,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.economyAnalytics")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.economyAnalyticsDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Mh,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.riskMonitoring")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.riskMonitoringDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(cS,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsxs("h4",{className:"font-bold mb-1",children:[_("proShowcase.orbitalSurveillance"),g.jsx(RR,{})]}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.orbitalSurveillanceDesc").replace(/^\(Soon\)\s*/,"")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(_3,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.morningBriefs")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.morningBriefsDesc")})]})]}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(k3,{className:"w-6 h-6 text-wm-green shrink-0","aria-hidden":"true"}),g.jsxs("div",{children:[g.jsx("h3",{className:"font-bold mb-1",children:_("proShowcase.oneKey")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("proShowcase.oneKeyDesc")})]})]})]}),g.jsxs("div",{className:"mt-10 pt-8 border-t border-wm-border",children:[g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-4",children:_("proShowcase.deliveryLabel")}),g.jsx("div",{className:"flex gap-6",children:[{icon:g.jsx(PR,{}),label:"Slack"},{icon:g.jsx(I3,{className:"w-5 h-5","aria-hidden":"true"}),label:"Discord"},{icon:g.jsx($3,{className:"w-5 h-5","aria-hidden":"true"}),label:"Telegram"},{icon:g.jsx(P3,{className:"w-5 h-5","aria-hidden":"true"}),label:"Email"},{icon:g.jsx(Jl,{className:"w-5 h-5","aria-hidden":"true"}),label:"Webhook"}].map((n,t)=>g.jsxs("div",{className:"flex flex-col items-center gap-1.5 text-wm-muted hover:text-wm-text transition-colors cursor-pointer",children:[n.icon,g.jsx("span",{className:"text-[10px] font-mono",children:n.label})]},t))})]})]}),g.jsxs("div",{className:"bg-[#1a1d21] rounded-lg border border-[#35373b] overflow-hidden shadow-2xl sticky top-24",children:[g.jsxs("div",{className:"bg-[#222529] px-4 py-3 border-b border-[#35373b] flex items-center gap-3",children:[g.jsx("div",{className:"w-3 h-3 rounded-full bg-red-500"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-yellow-500"}),g.jsx("div",{className:"w-3 h-3 rounded-full bg-green-500"}),g.jsx("span",{className:"ml-2 font-mono text-xs text-gray-400",children:"#world-monitor-alerts"})]}),g.jsx("div",{className:"p-6 space-y-6 font-sans text-sm",children:g.jsxs("div",{className:"flex gap-4",children:[g.jsx("div",{className:"w-10 h-10 rounded bg-wm-green/20 flex items-center justify-center shrink-0",children:g.jsx(Rl,{className:"w-6 h-6 text-wm-green","aria-hidden":"true"})}),g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2 mb-1",children:[g.jsx("span",{className:"font-bold text-gray-200",children:"World Monitor"}),g.jsx("span",{className:"text-xs text-gray-500 bg-gray-800 px-1 rounded",children:"APP"}),g.jsx("span",{className:"text-xs text-gray-500",children:"8:00 AM"})]}),g.jsxs("p",{className:"text-gray-300 font-bold mb-3",children:[_("slackMock.morningBrief")," · Mar 6"]}),g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"pl-3 border-l-2 border-blue-500",children:[g.jsx("span",{className:"text-blue-400 font-bold text-xs uppercase tracking-wider",children:_("slackMock.markets")}),g.jsx("p",{className:"text-gray-300 mt-1",children:_("slackMock.marketsText")})]}),g.jsxs("div",{className:"pl-3 border-l-2 border-orange-500",children:[g.jsx("span",{className:"text-orange-400 font-bold text-xs uppercase tracking-wider",children:_("slackMock.elevated")}),g.jsx("p",{className:"text-gray-300 mt-1",children:_("slackMock.elevatedText")})]}),g.jsxs("div",{className:"pl-3 border-l-2 border-yellow-500",children:[g.jsx("span",{className:"text-yellow-400 font-bold text-xs uppercase tracking-wider",children:_("slackMock.watch")}),g.jsx("p",{className:"text-gray-300 mt-1",children:_("slackMock.watchText")})]})]})]})]})})]})]})}),e4=()=>{const n=[{icon:g.jsx(v3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.investorsTitle"),desc:_("audience.investorsDesc")},{icon:g.jsx(j3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.tradersTitle"),desc:_("audience.tradersDesc")},{icon:g.jsx(K3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.researchersTitle"),desc:_("audience.researchersDesc")},{icon:g.jsx(Rl,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.journalistsTitle"),desc:_("audience.journalistsDesc")},{icon:g.jsx(z3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.govTitle"),desc:_("audience.govDesc")},{icon:g.jsx(p3,{className:"w-6 h-6","aria-hidden":"true"}),title:_("audience.teamsTitle"),desc:_("audience.teamsDesc")}];return g.jsx("section",{className:"py-24 px-6",children:g.jsxs("div",{className:"max-w-5xl mx-auto",children:[g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-16 text-center",children:_("audience.title")}),g.jsx("div",{className:"grid md:grid-cols-3 gap-6",children:n.map((t,a)=>g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6 hover:border-wm-green/30 transition-colors",children:[g.jsx("div",{className:"text-wm-green mb-4",children:t.icon}),g.jsx("h3",{className:"font-bold mb-2",children:t.title}),g.jsx("p",{className:"text-sm text-wm-muted",children:t.desc})]},a))})]})})},t4=()=>g.jsx("section",{className:"py-24 px-6 border-y border-wm-border bg-[#0a0a0a]",id:"api",children:g.jsxs("div",{className:"max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-center",children:[g.jsx("div",{className:"order-2 lg:order-1",children:g.jsxs("div",{className:"bg-black border border-wm-border rounded-lg overflow-hidden font-mono text-sm",children:[g.jsxs("div",{className:"bg-wm-card px-4 py-2 border-b border-wm-border flex items-center gap-2",children:[g.jsx(iO,{className:"w-4 h-4 text-wm-muted","aria-hidden":"true"}),g.jsx("span",{className:"text-wm-muted text-xs",children:"api.worldmonitor.app"})]}),g.jsx("div",{className:"p-6 text-gray-300 overflow-x-auto",children:g.jsx("pre",{children:g.jsxs("code",{children:[g.jsx("span",{className:"text-wm-blue",children:"curl"})," \\",g.jsx("br",{}),g.jsx("span",{className:"text-wm-green",children:'"https://api.worldmonitor.app/v1/intelligence/convergence?region=MENA&time_window=6h"'})," \\",g.jsx("br",{}),"-H ",g.jsx("span",{className:"text-wm-green",children:'"Authorization: Bearer wm_live_xxx"'}),g.jsx("br",{}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"{"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"status"'}),": ",g.jsx("span",{className:"text-wm-green",children:'"success"'}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"data"'}),": ",g.jsx("span",{className:"text-wm-muted",children:"["}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"{"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"type"'}),": ",g.jsx("span",{className:"text-wm-green",children:'"multi_signal_convergence"'}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"signals"'}),": ",g.jsx("span",{className:"text-wm-muted",children:'["military_flights", "ais_dark_ships", "oref_sirens"]'}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"confidence"'}),": ",g.jsx("span",{className:"text-orange-400",children:"0.92"}),",",g.jsx("br",{}),g.jsx("span",{className:"text-wm-blue",children:'"location"'}),": ",g.jsx("span",{className:"text-wm-muted",children:"{"})," ",g.jsx("span",{className:"text-wm-blue",children:'"lat"'}),": ",g.jsx("span",{className:"text-orange-400",children:"34.05"}),", ",g.jsx("span",{className:"text-wm-blue",children:'"lng"'}),": ",g.jsx("span",{className:"text-orange-400",children:"35.12"})," ",g.jsx("span",{className:"text-wm-muted",children:"}"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"}"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"]"}),g.jsx("br",{}),g.jsx("span",{className:"text-wm-muted",children:"}"})]})})})]})}),g.jsxs("div",{className:"order-1 lg:order-2",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-border bg-wm-card text-wm-muted text-xs font-mono mb-6",children:_("apiSection.apiTier")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("apiSection.title")}),g.jsx("p",{className:"text-wm-muted mb-8",children:_("apiSection.subtitle")}),g.jsxs("ul",{className:"space-y-4 mb-8",children:[g.jsxs("li",{className:"flex items-start gap-3",children:[g.jsx(Z3,{className:"w-5 h-5 text-wm-muted shrink-0","aria-hidden":"true"}),g.jsx("span",{className:"text-sm",children:_("apiSection.restApi")})]}),g.jsxs("li",{className:"flex items-start gap-3",children:[g.jsx(U3,{className:"w-5 h-5 text-wm-muted shrink-0","aria-hidden":"true"}),g.jsx("span",{className:"text-sm",children:_("apiSection.authenticated")})]}),g.jsxs("li",{className:"flex items-start gap-3",children:[g.jsx(A3,{className:"w-5 h-5 text-wm-muted shrink-0","aria-hidden":"true"}),g.jsx("span",{className:"text-sm",children:_("apiSection.structured")})]})]}),g.jsxs("div",{className:"grid grid-cols-2 gap-4 mb-8 p-4 bg-wm-card border border-wm-border rounded-sm",children:[g.jsxs("div",{children:[g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("apiSection.starter")}),g.jsx("p",{className:"text-sm font-bold",children:_("apiSection.starterReqs")}),g.jsx("p",{className:"text-xs text-wm-muted",children:_("apiSection.starterWebhooks")})]}),g.jsxs("div",{children:[g.jsx("p",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("apiSection.business")}),g.jsx("p",{className:"text-sm font-bold",children:_("apiSection.businessReqs")}),g.jsx("p",{className:"text-xs text-wm-muted",children:_("apiSection.businessWebhooks")})]})]}),g.jsx("p",{className:"text-sm text-wm-muted border-l-2 border-wm-border pl-4",children:_("apiSection.feedData")})]})]})}),n4=()=>g.jsx("section",{className:"py-24 px-6",id:"enterprise",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsxs("div",{className:"text-center mb-16",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-border bg-wm-card text-wm-muted text-xs font-mono mb-6",children:_("enterpriseShowcase.enterpriseTier")}),g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("enterpriseShowcase.title")}),g.jsx("p",{className:"text-wm-muted max-w-2xl mx-auto",children:_("enterpriseShowcase.subtitle")})]}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-6",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Mh,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.security")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.securityDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(sS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.aiAgents")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.aiAgentsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(oS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.dataLayers")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.dataLayersDesc")})]})]}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-12",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Jl,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.connectors")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.connectorsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(lS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.whiteLabel")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.whiteLabelDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Ch,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.financial")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.financialDesc")})]})]}),g.jsxs("div",{className:"data-grid mb-12",children:[g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.commodity")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.commodityDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.government")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.governmentDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.risk")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.riskDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h4",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.soc")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.socDesc")})]})]}),g.jsx("div",{className:"text-center mt-12",children:g.jsxs("a",{href:"#enterprise-contact","aria-label":"Talk to sales about Enterprise plans",className:"inline-flex items-center gap-2 bg-wm-green text-wm-bg px-8 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:[_("enterpriseShowcase.talkToSales")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]})})]})}),i4=()=>{const n=[{feature:_("pricingTable.dataRefresh"),free:_("pricingTable.f5_15min"),pro:_("pricingTable.fLt60s"),api:_("pricingTable.fPerRequest"),ent:_("pricingTable.fLiveEdge")},{feature:_("pricingTable.dashboard"),free:_("pricingTable.f50panels"),pro:_("pricingTable.f50panels"),api:"—",ent:_("pricingTable.fWhiteLabel")},{feature:_("pricingTable.ai"),free:_("pricingTable.fBYOK"),pro:_("pricingTable.fIncluded"),api:"—",ent:_("pricingTable.fAgentsPersonas")},{feature:_("pricingTable.briefsAlerts"),free:"—",pro:_("pricingTable.fDailyFlash"),api:"—",ent:_("pricingTable.fTeamDist")},{feature:_("pricingTable.delivery"),free:"—",pro:_("pricingTable.fSlackTgWa"),api:_("pricingTable.fWebhook"),ent:_("pricingTable.fSiemMcp")},{feature:_("pricingTable.apiRow"),free:"—",pro:"—",api:_("pricingTable.fRestWebhook"),ent:_("pricingTable.fMcpBulk")},{feature:_("pricingTable.infraLayers"),free:_("pricingTable.f45"),pro:_("pricingTable.f45"),api:"—",ent:_("pricingTable.fTensOfThousands")},{feature:_("pricingTable.satellite"),free:_("pricingTable.fLiveTracking"),pro:_("pricingTable.fPassAlerts"),api:"—",ent:_("pricingTable.fImagerySar")},{feature:_("pricingTable.connectorsRow"),free:"—",pro:"—",api:"—",ent:_("pricingTable.f100plus")},{feature:_("pricingTable.deployment"),free:_("pricingTable.fCloud"),pro:_("pricingTable.fCloud"),api:_("pricingTable.fCloud"),ent:_("pricingTable.fCloudOnPrem")},{feature:_("pricingTable.securityRow"),free:_("pricingTable.fStandard"),pro:_("pricingTable.fStandard"),api:_("pricingTable.fKeyAuth"),ent:_("pricingTable.fSsoMfa")}];return g.jsxs("section",{className:"py-24 px-6 max-w-7xl mx-auto",children:[g.jsx("div",{className:"text-center mb-16",children:g.jsx("h2",{className:"text-3xl md:text-5xl font-display font-bold mb-6",children:_("pricingTable.title")})}),g.jsxs("div",{className:"hidden md:block",children:[g.jsxs("div",{className:"grid grid-cols-5 gap-4 mb-4 pb-4 border-b border-wm-border font-mono text-xs uppercase tracking-widest text-wm-muted",children:[g.jsx("div",{children:_("pricingTable.feature")}),g.jsx("div",{children:_("pricingTable.freeHeader")}),g.jsx("div",{className:"text-wm-green",children:_("pricingTable.proHeader")}),g.jsx("div",{children:_("pricingTable.apiHeader")}),g.jsx("div",{children:_("pricingTable.entHeader")})]}),n.map((t,a)=>g.jsxs("div",{className:"grid grid-cols-5 gap-4 py-4 border-b border-wm-border/50 text-sm hover:bg-wm-card/50 transition-colors",children:[g.jsx("div",{className:"font-medium",children:t.feature}),g.jsx("div",{className:"text-wm-muted",children:t.free}),g.jsx("div",{className:"text-wm-green",children:t.pro}),g.jsx("div",{className:"text-wm-muted",children:t.api}),g.jsx("div",{className:"text-wm-muted",children:t.ent})]},a))]}),g.jsx("div",{className:"md:hidden space-y-4",children:n.map((t,a)=>g.jsxs("div",{className:"bg-wm-card border border-wm-border p-4 rounded-sm",children:[g.jsx("p",{className:"font-medium text-sm mb-3",children:t.feature}),g.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-muted",children:[_("tiers.free"),":"]})," ",t.free]}),g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-green",children:[_("tiers.pro"),":"]})," ",g.jsx("span",{className:"text-wm-green",children:t.pro})]}),g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-muted",children:[_("nav.api"),":"]})," ",t.api]}),g.jsxs("div",{children:[g.jsxs("span",{className:"text-wm-muted",children:[_("tiers.enterprise"),":"]})," ",t.ent]})]})]},a))}),g.jsx("p",{className:"text-center text-sm text-wm-muted mt-8",children:_("pricingTable.noteBelow")})]})},a4=()=>{const n=[{q:_("faq.q1"),a:_("faq.a1"),open:!0},{q:_("faq.q2"),a:_("faq.a2")},{q:_("faq.q3"),a:_("faq.a3")},{q:_("faq.q4"),a:_("faq.a4")},{q:_("faq.q5"),a:_("faq.a5")},{q:_("faq.q6"),a:_("faq.a6")},{q:_("faq.q7"),a:_("faq.a7")},{q:_("faq.q8"),a:_("faq.a8")},{q:_("faq.q9"),a:_("faq.a9")},{q:_("faq.q10"),a:_("faq.a10")},{q:_("faq.q11"),a:_("faq.a11")},{q:_("faq.q12"),a:_("faq.a12")},{q:_("faq.q13"),a:_("faq.a13")}];return g.jsxs("section",{className:"py-24 px-6 max-w-3xl mx-auto",children:[g.jsx("h2",{className:"text-3xl font-display font-bold mb-12 text-center",children:_("faq.title")}),g.jsx("div",{className:"space-y-4",children:n.map((t,a)=>g.jsxs("details",{open:t.open,className:"group bg-wm-card border border-wm-border rounded-sm [&_summary::-webkit-details-marker]:hidden",children:[g.jsxs("summary",{className:"flex items-center justify-between p-6 cursor-pointer font-medium",children:[t.q,g.jsx(S3,{className:"w-5 h-5 text-wm-muted group-open:rotate-180 transition-transform","aria-hidden":"true"})]}),g.jsx("div",{className:"px-6 pb-6 text-wm-muted text-sm border-t border-wm-border pt-4 mt-2",children:t.a})]},a))})]})},s4=()=>g.jsx("footer",{className:"border-t border-wm-border bg-[#020202] pt-8 pb-12 px-6 text-center",children:g.jsxs("div",{className:"flex flex-col md:flex-row items-center justify-between max-w-7xl mx-auto text-xs text-wm-muted font-mono",children:[g.jsxs("div",{className:"flex items-center gap-3 mb-4 md:mb-0",children:[g.jsx("img",{src:"/favico/favicon-32x32.png",alt:"",width:"28",height:"28",className:"rounded-full"}),g.jsxs("div",{className:"flex flex-col",children:[g.jsx("span",{className:"font-display font-bold text-sm leading-none tracking-tight text-wm-text",children:"WORLD MONITOR"}),g.jsx("span",{className:"text-[9px] uppercase tracking-[2px] opacity-60 mt-0.5",children:"by Someone.ceo"})]})]}),g.jsxs("div",{className:"flex items-center gap-6",children:[g.jsx("a",{href:"/",className:"hover:text-wm-text transition-colors",children:"Dashboard"}),g.jsx("a",{href:"https://www.worldmonitor.app/blog/",className:"hover:text-wm-text transition-colors",children:"Blog"}),g.jsx("a",{href:"https://www.worldmonitor.app/docs",className:"hover:text-wm-text transition-colors",children:"Docs"}),g.jsx("a",{href:"https://status.worldmonitor.app/",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Status"}),g.jsx("a",{href:"https://github.com/koala73/worldmonitor",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"GitHub"}),g.jsx("a",{href:"https://discord.gg/re63kWKxaz",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Discord"}),g.jsx("a",{href:"https://x.com/worldmonitorai",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"X"})]}),g.jsxs("span",{className:"text-[10px] opacity-40 mt-4 md:mt-0",children:["© ",new Date().getFullYear()," WorldMonitor"]})]})}),r4=()=>g.jsxs("div",{className:"min-h-screen selection:bg-wm-green/30 selection:text-wm-green",children:[g.jsx("nav",{className:"fixed top-0 left-0 right-0 z-50 glass-panel border-b-0 border-x-0 rounded-none","aria-label":"Main navigation",children:g.jsxs("div",{className:"max-w-7xl mx-auto px-6 h-16 flex items-center justify-between",children:[g.jsx("a",{href:"#",onClick:n=>{n.preventDefault(),window.location.hash=""},children:g.jsx(TS,{})}),g.jsxs("div",{className:"hidden md:flex items-center gap-8 text-sm font-mono text-wm-muted",children:[g.jsx("a",{href:"#",onClick:n=>{n.preventDefault(),window.location.hash=""},className:"hover:text-wm-text transition-colors",children:_("nav.pro")}),g.jsx("a",{href:"#enterprise",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("features"))==null||t.scrollIntoView({behavior:"smooth"})},className:"hover:text-wm-text transition-colors",children:_("nav.enterprise")}),g.jsx("a",{href:"#enterprise-contact",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("contact"))==null||t.scrollIntoView({behavior:"smooth"})},className:"hover:text-wm-green transition-colors",children:_("enterpriseShowcase.talkToSales")})]}),g.jsx("a",{href:"#enterprise-contact",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("contact"))==null||t.scrollIntoView({behavior:"smooth"})},className:"bg-wm-green text-wm-bg px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:_("enterpriseShowcase.talkToSales")})]})}),g.jsxs("main",{className:"pt-24",children:[g.jsx("section",{className:"py-24 px-6 text-center",children:g.jsxs("div",{className:"max-w-4xl mx-auto",children:[g.jsx("div",{className:"inline-flex items-center gap-2 px-3 py-1 rounded-full border border-wm-border bg-wm-card text-wm-muted text-xs font-mono mb-6",children:_("enterpriseShowcase.enterpriseTier")}),g.jsx("h2",{className:"text-4xl md:text-6xl font-display font-bold mb-6",children:_("enterpriseShowcase.title")}),g.jsx("p",{className:"text-lg text-wm-muted max-w-2xl mx-auto mb-10",children:_("enterpriseShowcase.subtitle")}),g.jsxs("a",{href:"#enterprise-contact",onClick:n=>{var t;n.preventDefault(),(t=document.getElementById("contact"))==null||t.scrollIntoView({behavior:"smooth"})},className:"inline-flex items-center gap-2 bg-wm-green text-wm-bg px-8 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:[_("enterpriseShowcase.talkToSales")," ",g.jsx(Wi,{className:"w-4 h-4","aria-hidden":"true"})]})]})}),g.jsx("section",{className:"py-24 px-6",id:"features",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsx("h2",{className:"sr-only",children:"Enterprise Features"}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-6",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Mh,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.security")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.securityDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(sS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.aiAgents")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.aiAgentsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(oS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.dataLayers")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.dataLayersDesc")})]})]}),g.jsxs("div",{className:"grid md:grid-cols-3 gap-6 mb-12",children:[g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Jl,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.connectors")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.connectorsDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(lS,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.whiteLabel")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.whiteLabelDesc")})]}),g.jsxs("div",{className:"bg-wm-card border border-wm-border p-6",children:[g.jsx(Ch,{className:"w-8 h-8 text-wm-muted mb-4","aria-hidden":"true"}),g.jsx("h3",{className:"font-bold mb-2",children:_("enterpriseShowcase.financial")}),g.jsx("p",{className:"text-sm text-wm-muted",children:_("enterpriseShowcase.financialDesc")})]})]})]})}),g.jsx("section",{className:"py-24 px-6 border-t border-wm-border",children:g.jsxs("div",{className:"max-w-7xl mx-auto",children:[g.jsx("h2",{className:"text-3xl font-display font-bold mb-12 text-center",children:_("enterpriseShowcase.title")}),g.jsxs("div",{className:"data-grid",children:[g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.commodity")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.commodityDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.government")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.governmentDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.risk")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.riskDesc")})]}),g.jsxs("div",{className:"data-cell",children:[g.jsx("h3",{className:"font-mono text-xs text-wm-muted uppercase tracking-widest mb-2",children:_("enterpriseShowcase.soc")}),g.jsx("p",{className:"text-sm",children:_("enterpriseShowcase.socDesc")})]})]})]})}),g.jsx("section",{className:"py-24 px-6 border-t border-wm-border",id:"contact",children:g.jsxs("div",{className:"max-w-xl mx-auto",children:[g.jsx("h2",{className:"font-display text-3xl font-bold mb-2 text-center",children:_("enterpriseShowcase.contactFormTitle")}),g.jsx("p",{className:"text-sm text-wm-muted mb-10 text-center",children:_("enterpriseShowcase.contactFormSubtitle")}),g.jsxs("form",{className:"space-y-4",onSubmit:async n=>{var m;n.preventDefault();const t=n.currentTarget,a=t.querySelector('button[type="submit"]'),r=a.textContent;a.disabled=!0,a.textContent=_("enterpriseShowcase.contactSending");const l=new FormData(t),c=((m=t.querySelector('input[name="website"]'))==null?void 0:m.value)||"",f=t.querySelector(".cf-turnstile"),h=(f==null?void 0:f.dataset.token)||"";try{const p=await fetch(`${zR}/leads/v1/submit-contact`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:l.get("email"),name:l.get("name"),organization:l.get("organization"),phone:l.get("phone"),message:l.get("message"),source:"enterprise-contact",website:c,turnstileToken:h})}),v=t.querySelector("[data-form-error]");if(!p.ok){const x=await p.json().catch(()=>({}));if(p.status===422&&v){v.textContent=x.message||x.error||_("enterpriseShowcase.workEmailRequired"),v.classList.remove("hidden"),a.textContent=r,a.disabled=!1;return}throw new Error}v&&v.classList.add("hidden"),a.textContent=_("enterpriseShowcase.contactSent"),a.className=a.className.replace("bg-wm-green","bg-wm-card border border-wm-green text-wm-green")}catch{a.textContent=_("enterpriseShowcase.contactFailed"),a.disabled=!1,f!=null&&f.dataset.widgetId&&window.turnstile&&(window.turnstile.reset(f.dataset.widgetId),delete f.dataset.token),setTimeout(()=>{a.textContent=r},4e3)}},children:[g.jsx("input",{type:"text",name:"website",autoComplete:"off",tabIndex:-1,"aria-hidden":"true",className:"absolute opacity-0 h-0 w-0 pointer-events-none"}),g.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[g.jsx("input",{type:"text",name:"name",placeholder:_("enterpriseShowcase.namePlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"}),g.jsx("input",{type:"email",name:"email",placeholder:_("enterpriseShowcase.emailPlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"})]}),g.jsx("span",{"data-form-error":!0,className:"hidden text-red-400 text-xs font-mono block"}),g.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[g.jsx("input",{type:"text",name:"organization",placeholder:_("enterpriseShowcase.orgPlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"}),g.jsx("input",{type:"tel",name:"phone",placeholder:_("enterpriseShowcase.phonePlaceholder"),required:!0,className:"bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono"})]}),g.jsx("textarea",{name:"message",placeholder:_("enterpriseShowcase.messagePlaceholder"),rows:4,className:"w-full bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono resize-none"}),g.jsx("div",{className:"cf-turnstile mx-auto"}),g.jsx("button",{type:"submit",className:"w-full bg-wm-green text-wm-bg py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors",children:_("enterpriseShowcase.submitContact")})]})]})})]}),g.jsx("footer",{className:"border-t border-wm-border bg-[#020202] py-8 px-6 text-center",children:g.jsxs("div",{className:"flex flex-col md:flex-row items-center justify-between max-w-7xl mx-auto text-xs text-wm-muted font-mono",children:[g.jsxs("div",{className:"flex items-center gap-3 mb-4 md:mb-0",children:[g.jsx("img",{src:"/favico/favicon-32x32.png",alt:"",width:"28",height:"28",className:"rounded-full"}),g.jsxs("div",{className:"flex flex-col",children:[g.jsx("span",{className:"font-display font-bold text-sm leading-none tracking-tight text-wm-text",children:"WORLD MONITOR"}),g.jsx("span",{className:"text-[9px] uppercase tracking-[2px] opacity-60 mt-0.5",children:"by Someone.ceo"})]})]}),g.jsxs("div",{className:"flex items-center gap-6",children:[g.jsx("a",{href:"/",className:"hover:text-wm-text transition-colors",children:"Dashboard"}),g.jsx("a",{href:"https://www.worldmonitor.app/blog/",className:"hover:text-wm-text transition-colors",children:"Blog"}),g.jsx("a",{href:"https://www.worldmonitor.app/docs",className:"hover:text-wm-text transition-colors",children:"Docs"}),g.jsx("a",{href:"https://status.worldmonitor.app/",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Status"}),g.jsx("a",{href:"https://github.com/koala73/worldmonitor",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"GitHub"}),g.jsx("a",{href:"https://discord.gg/re63kWKxaz",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"Discord"}),g.jsx("a",{href:"https://x.com/worldmonitorai",target:"_blank",rel:"noreferrer",className:"hover:text-wm-text transition-colors",children:"X"})]}),g.jsxs("span",{className:"text-[10px] opacity-40 mt-4 md:mt-0",children:["© ",new Date().getFullYear()," WorldMonitor"]})]})})]});function o4(){const[n,t]=Z.useState(()=>window.location.hash.startsWith("#enterprise")?"enterprise":"home");return Z.useEffect(()=>{_R(()=>{const a=document.createElement("div");Object.assign(a.style,{position:"fixed",top:"0",left:"0",right:"0",zIndex:"99999",padding:"14px 20px",background:"linear-gradient(135deg, #16a34a, #22c55e)",color:"#fff",fontWeight:"600",fontSize:"14px",textAlign:"center",boxShadow:"0 2px 12px rgba(0,0,0,0.3)",transition:"opacity 0.4s ease, transform 0.4s ease",transform:"translateY(-100%)",opacity:"0"}),a.textContent="Payment received! Unlocking your premium features...",document.body.appendChild(a),requestAnimationFrame(()=>{a.style.transform="translateY(0)",a.style.opacity="1"}),setTimeout(()=>{window.location.href="https://worldmonitor.app"},3e3)})},[]),Z.useEffect(()=>{const a=()=>{const r=window.location.hash,l=r.startsWith("#enterprise")?"enterprise":"home",c=n==="enterprise";t(l),l==="enterprise"&&!c&&window.scrollTo(0,0),r==="#enterprise-contact"&&setTimeout(()=>{var f;(f=document.getElementById("contact"))==null||f.scrollIntoView({behavior:"smooth"})},c?0:100)};return window.addEventListener("hashchange",a),()=>window.removeEventListener("hashchange",a)},[n]),Z.useEffect(()=>{n==="enterprise"&&window.location.hash==="#enterprise-contact"&&setTimeout(()=>{var a;(a=document.getElementById("contact"))==null||a.scrollIntoView({behavior:"smooth"})},100)},[]),n==="enterprise"?g.jsx(r4,{}):g.jsxs("div",{className:"min-h-screen selection:bg-wm-green/30 selection:text-wm-green",children:[g.jsx(qR,{}),g.jsxs("main",{children:[g.jsx(GR,{}),g.jsx(JR,{}),g.jsx($R,{}),g.jsx(XR,{}),g.jsx(KR,{}),g.jsx(WR,{}),g.jsx(QR,{}),g.jsx(e4,{}),g.jsx(YR,{}),g.jsx(ZR,{}),g.jsx(OR,{refCode:UR()}),g.jsx(i4,{}),g.jsx(t4,{}),g.jsx(n4,{}),g.jsx(a4,{})]}),g.jsx(s4,{})]})}const l4=void 0;JN({dsn:void 0,environment:location.hostname==="worldmonitor.app"||location.hostname.endsWith(".worldmonitor.app")?"production":location.hostname.includes("vercel.app")?"preview":"development",enabled:!!l4&&!location.hostname.startsWith("localhost"),allowUrls:[/https?:\/\/(www\.|tech\.|finance\.|commodity\.|happy\.)?worldmonitor\.app/,/https?:\/\/.*\.vercel\.app/],tracesSampleRate:.1,ignoreErrors:[/ResizeObserver loop/,/^TypeError: Load failed/,/^TypeError: Failed to fetch/,/^TypeError: NetworkError/,/Non-Error promise rejection captured with value:/]});const c4='script[src^="https://challenges.cloudflare.com/turnstile/v0/api.js"]';bR().then(()=>{sD.createRoot(document.getElementById("root")).render(g.jsx(Z.StrictMode,{children:g.jsx(o4,{})}));const n=()=>window.turnstile?BR()>0:!1,t=document.querySelector(c4);if(t==null||t.addEventListener("load",()=>{n()},{once:!0}),!n()){let a=0;const r=window.setInterval(()=>{(n()||++a>=20)&&window.clearInterval(r)},500)}window.addEventListener("hashchange",()=>{let a=0;const r=()=>{n()||++a>=10||setTimeout(r,200)};setTimeout(r,100)})}); diff --git a/public/pro/index.html b/public/pro/index.html index 1419e56fe..5f4ce9c5e 100644 --- a/public/pro/index.html +++ b/public/pro/index.html @@ -144,7 +144,7 @@ } - + diff --git a/scripts/enforce-rate-limit-policies.mjs b/scripts/enforce-rate-limit-policies.mjs new file mode 100644 index 000000000..78e2cff8d --- /dev/null +++ b/scripts/enforce-rate-limit-policies.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Validates every key in ENDPOINT_RATE_POLICIES (server/_shared/rate-limit.ts) + * is a real gateway route by checking the OpenAPI specs generated from protos. + * Catches rename-drift that causes policies to become dead code (the + * sanctions-entity-search review finding — the policy key was + * `/api/sanctions/v1/lookup-entity` but the proto RPC generates path + * `/api/sanctions/v1/lookup-sanction-entity`, so the 30/min limit never + * applied and the endpoint fell through to the 600/min global limiter). + * + * Runs in the same pre-push + CI context as lint:api-contract. + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = new URL('..', import.meta.url).pathname; +const OPENAPI_DIR = join(ROOT, 'docs/api'); +const RATE_LIMIT_SRC = join(ROOT, 'server/_shared/rate-limit.ts'); + +function extractPolicyKeys() { + const src = readFileSync(RATE_LIMIT_SRC, 'utf8'); + const match = src.match(/ENDPOINT_RATE_POLICIES:\s*Record<[^>]+>\s*=\s*\{([\s\S]*?)\n\};/); + if (!match) { + throw new Error('Could not locate ENDPOINT_RATE_POLICIES in rate-limit.ts'); + } + const block = match[1]; + const keys = []; + // Match quoted keys: '/api/...' or "/api/..." + const keyRe = /['"](\/api\/[^'"]+)['"]\s*:/g; + let m; + while ((m = keyRe.exec(block)) !== null) { + keys.push(m[1]); + } + return keys; +} + +function extractRoutesFromOpenApi() { + const routes = new Set(); + const files = readdirSync(OPENAPI_DIR).filter((f) => f.endsWith('.openapi.yaml')); + for (const file of files) { + const yaml = readFileSync(join(OPENAPI_DIR, file), 'utf8'); + // OpenAPI paths section — each route is a top-level key under `paths:` + // indented 4 spaces. Strip trailing colon. + const pathRe = /^\s{4}(\/api\/[^\s:]+):/gm; + let m; + while ((m = pathRe.exec(yaml)) !== null) { + routes.add(m[1]); + } + } + return routes; +} + +function main() { + const keys = extractPolicyKeys(); + const routes = extractRoutesFromOpenApi(); + const missing = keys.filter((k) => !routes.has(k)); + + if (missing.length > 0) { + console.error('✗ ENDPOINT_RATE_POLICIES key(s) do not match any generated gateway route:\n'); + for (const key of missing) { + console.error(` - ${key}`); + } + console.error('\nEach key must be a proto-generated RPC path. Check that:'); + console.error(' 1. The key matches the path in docs/api/.openapi.yaml exactly.'); + console.error(' 2. If you renamed the RPC in proto, update the policy key to match.'); + console.error(' 3. If the policy is for a non-proto legacy route, remove it once that route is migrated.\n'); + console.error('Similar issues in history: review of #3242 flagged the sanctions-entity-search'); + console.error('policy under `/api/sanctions/v1/lookup-entity` when the generated path was'); + console.error('`/api/sanctions/v1/lookup-sanction-entity` — the policy was dead code.'); + process.exit(1); + } + + console.log(`✓ rate-limit policies clean: ${keys.length} policies validated against ${routes.size} gateway routes.`); +} + +main(); diff --git a/scripts/enforce-sebuf-api-contract.mjs b/scripts/enforce-sebuf-api-contract.mjs new file mode 100644 index 000000000..46d4990bf --- /dev/null +++ b/scripts/enforce-sebuf-api-contract.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Sebuf API contract enforcement. + * + * Every file under api/ must be one of: + * 1. A sebuf gateway — api//v/[rpc].ts paired with a + * generated service_server under src/generated/server/worldmonitor//v/. + * 2. A listed entry in api/api-route-exceptions.json with category, reason, + * owner, and (for temporary categories) a removal_issue. + * + * Also checks the reverse: every generated service has a gateway. This catches + * the case where a proto is deleted but the gateway wrapper is left behind. + * + * Skips: underscore-prefixed helpers, *.test.*, and anything gitignored (the + * compiled sidecar bundles at api/[[...path]].js and api//v1/[rpc].js + * are build artifacts, not source). + * + * Exit 0 clean, 1 on any violation. Output is agent-readable: file:line + remedy. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const ROOT = process.cwd(); +const API_DIR = join(ROOT, 'api'); +const GEN_SERVER_DIR = join(ROOT, 'src/generated/server/worldmonitor'); +const MANIFEST_PATH = join(API_DIR, 'api-route-exceptions.json'); + +const VALID_CATEGORIES = new Set([ + 'external-protocol', + 'non-json', + 'upstream-proxy', + 'ops-admin', + 'internal-helper', + 'deferred', + 'migration-pending', +]); + +// Categories that describe *permanent* exceptions — never expected to leave the +// manifest. A removal_issue on these would be misleading. +const PERMANENT_CATEGORIES = new Set([ + 'external-protocol', + 'non-json', + 'upstream-proxy', + 'ops-admin', + 'internal-helper', +]); + +const violations = []; + +function violation(file, message, remedy) { + violations.push({ file, message, remedy }); +} + +// --- Enumerate candidate files under api/ --- + +function walk(dir, acc = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, acc); + } else { + acc.push(full); + } + } + return acc; +} + +const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.mjs', '.cjs']; + +function isSourceFile(path) { + const base = path.split(sep).pop(); + if (base.startsWith('_')) return false; + if (base.includes('.test.')) return false; + return SOURCE_EXTS.some((ext) => base.endsWith(ext)); +} + +const allApiFiles = walk(API_DIR).filter(isSourceFile); + +// Filter out gitignored paths in one batch. +function filterIgnored(files) { + if (files.length === 0) return []; + const relPaths = files.map((f) => relative(ROOT, f)).join('\n'); + let ignored = new Set(); + try { + // --stdin returns the ignored paths (one per line). Exit 0 = some matched, + // 1 = none matched, 128 = error. We treat 0 and 1 as success. + const output = execFileSync('git', ['check-ignore', '--stdin'], { + input: relPaths, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + ignored = new Set(output.split('\n').filter(Boolean)); + } catch (err) { + // exit code 1 = no paths ignored; treat as empty. + if (err.status === 1) { + ignored = new Set(); + } else { + throw err; + } + } + return files.filter((f) => !ignored.has(relative(ROOT, f))); +} + +const candidateFiles = filterIgnored(allApiFiles); + +// --- Load manifest --- + +if (!existsSync(MANIFEST_PATH)) { + console.error(`✖ ${relative(ROOT, MANIFEST_PATH)} is missing.`); + console.error( + ' Remedy: restore api-route-exceptions.json (see docs/adding-endpoints.mdx). It is the single source of truth for non-proto endpoints.', + ); + process.exit(1); +} + +let manifest; +try { + manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); +} catch (err) { + console.error(`✖ ${relative(ROOT, MANIFEST_PATH)} is not valid JSON: ${err.message}`); + process.exit(1); +} + +if (!Array.isArray(manifest.exceptions)) { + console.error(`✖ ${relative(ROOT, MANIFEST_PATH)} is missing the "exceptions" array.`); + process.exit(1); +} + +// Validate every manifest entry's shape. +const manifestByPath = new Map(); +for (const [idx, entry] of manifest.exceptions.entries()) { + const label = `api-route-exceptions.json[${idx}]`; + if (typeof entry.path !== 'string' || entry.path.length === 0) { + violation(label, 'entry is missing a non-empty "path" string', 'Set "path" to the api/ path this entry covers.'); + continue; + } + if (manifestByPath.has(entry.path)) { + violation( + label, + `duplicate entry for path "${entry.path}"`, + 'Remove the duplicate — one entry per path.', + ); + } + manifestByPath.set(entry.path, entry); + + if (!VALID_CATEGORIES.has(entry.category)) { + violation( + label, + `invalid category "${entry.category}" for ${entry.path}`, + `category must be one of: ${[...VALID_CATEGORIES].join(', ')}.`, + ); + } + if (typeof entry.reason !== 'string' || entry.reason.trim().length < 10) { + violation( + label, + `entry for ${entry.path} is missing a substantive "reason" (≥10 chars)`, + 'Write a one-sentence reason explaining why this endpoint cannot or should not be a sebuf RPC.', + ); + } + if (typeof entry.owner !== 'string' || !entry.owner.startsWith('@')) { + violation( + label, + `entry for ${entry.path} has an invalid "owner" (must be a GitHub handle starting with @)`, + 'Set "owner" to a GitHub handle like @SebastienMelki.', + ); + } + if (entry.removal_issue !== null && entry.removal_issue !== undefined) { + if (typeof entry.removal_issue !== 'string') { + violation( + label, + `entry for ${entry.path} has non-string "removal_issue"`, + 'Set "removal_issue" to null, "TBD", or an issue reference like "#3207".', + ); + } else if ( + entry.removal_issue !== 'TBD' && + !/^#\d+$/.test(entry.removal_issue) + ) { + violation( + label, + `entry for ${entry.path} has malformed "removal_issue" "${entry.removal_issue}"`, + 'Use null for permanent exceptions, "TBD" while an issue is being filed, or "#" once tracked.', + ); + } + } + if (PERMANENT_CATEGORIES.has(entry.category) && entry.removal_issue) { + violation( + label, + `entry for ${entry.path} is category "${entry.category}" but has a "removal_issue" set`, + 'Permanent categories (external-protocol, non-json, upstream-proxy, ops-admin, internal-helper) do not track removal. Set removal_issue to null.', + ); + } + if (!PERMANENT_CATEGORIES.has(entry.category) && !entry.removal_issue) { + violation( + label, + `entry for ${entry.path} is category "${entry.category}" but has no "removal_issue"`, + 'Temporary categories (deferred, migration-pending) must declare a tracking issue or "TBD".', + ); + } + + // Reverse pointer: manifest must not name a file that doesn't exist. + const absolute = join(ROOT, entry.path); + if (!existsSync(absolute)) { + violation( + label, + `entry for ${entry.path} points to a file that does not exist`, + 'Remove the entry if the file was deleted, or fix the path.', + ); + } +} + +// --- Classify each api/ source file --- + +// Sebuf gateway pattern — two accepted forms: +// 1. api//v/[rpc].ts (standard, domain-first) +// 2. api/v//[rpc].ts (version-first, for partner-URL +// preservation where the external contract already uses that layout — +// e.g. /api/v2/shipping/*). +// Both map to src/generated/server/worldmonitor//v/. +const GATEWAY_RE = /^api\/(?:([a-z][a-z0-9-]*)\/v(\d+)|v(\d+)\/([a-z][a-z0-9-]*))\/\[rpc\]\.(ts|tsx|js|mjs|cjs)$/; + +function kebabToSnake(s) { + return s.replace(/-/g, '_'); +} + +const seenGatewayDomains = new Set(); + +for (const absolute of candidateFiles) { + const rel = relative(ROOT, absolute).split(sep).join('/'); + + // Skip the manifest itself — it isn't an endpoint. + if (rel === 'api/api-route-exceptions.json') continue; + + const gatewayMatch = rel.match(GATEWAY_RE); + if (gatewayMatch) { + // Group 1+2 = standard form (domain, version); 3+4 = version-first form (version, domain). + const domain = gatewayMatch[1] ?? gatewayMatch[4]; + const version = gatewayMatch[2] ?? gatewayMatch[3]; + const snakeDomain = kebabToSnake(domain); + const expectedServer = join( + GEN_SERVER_DIR, + snakeDomain, + `v${version}`, + 'service_server.ts', + ); + seenGatewayDomains.add(`${snakeDomain}/v${version}`); + if (!existsSync(expectedServer)) { + violation( + rel, + `sebuf gateway for /${domain}/v${version} has no matching generated service`, + `Expected ${relative(ROOT, expectedServer)}. Either regenerate (cd proto && buf generate), restore the proto under proto/worldmonitor/${snakeDomain}/v${version}/service.proto, or delete this orphaned gateway.`, + ); + } + continue; + } + + if (manifestByPath.has(rel)) { + // The entry was already validated above. Nothing more to check here. + continue; + } + + violation( + rel, + 'file under api/ is neither a sebuf gateway nor a listed exception', + 'New JSON data APIs must be sebuf RPCs (proto → buf generate → handler). See docs/adding-endpoints.mdx. If this endpoint genuinely cannot be proto (external protocol, binary response, upstream proxy, ops plumbing), add an entry to api/api-route-exceptions.json — expect reviewer pushback.', + ); +} + +// --- Bidirectional check: every generated service has a gateway --- + +if (existsSync(GEN_SERVER_DIR)) { + for (const domainDir of readdirSync(GEN_SERVER_DIR, { withFileTypes: true })) { + if (!domainDir.isDirectory()) continue; + const snakeDomain = domainDir.name; + const domainPath = join(GEN_SERVER_DIR, snakeDomain); + for (const versionDir of readdirSync(domainPath, { withFileTypes: true })) { + if (!versionDir.isDirectory()) continue; + if (!/^v\d+$/.test(versionDir.name)) continue; + const serviceServer = join( + domainPath, + versionDir.name, + 'service_server.ts', + ); + if (!existsSync(serviceServer)) continue; + const key = `${snakeDomain}/${versionDir.name}`; + if (!seenGatewayDomains.has(key)) { + const kebabDomain = snakeDomain.replace(/_/g, '-'); + violation( + relative(ROOT, serviceServer), + `generated service ${snakeDomain}/${versionDir.name} has no HTTP gateway under api/`, + `Create api/${kebabDomain}/${versionDir.name}/[rpc].ts (follow the pattern from any existing domain — it just imports the generated server factory and re-exports as the edge handler).`, + ); + } + } + } +} + +// --- Output --- + +if (violations.length === 0) { + console.log( + `✓ sebuf API contract clean: ${candidateFiles.length} api/ files checked, ${manifest.exceptions.length} manifest entries validated.`, + ); + process.exit(0); +} + +console.error(`✖ sebuf API contract: ${violations.length} violation(s):\n`); +for (const v of violations) { + console.error(` ${v.file}`); + console.error(` ${v.message}`); + console.error(` Remedy: ${v.remedy}`); + console.error(''); +} +process.exit(1); diff --git a/api/_email-validation.js b/server/_shared/email-validation.ts similarity index 83% rename from api/_email-validation.js rename to server/_shared/email-validation.ts index 6bd69e917..51dffb5f1 100644 --- a/api/_email-validation.js +++ b/server/_shared/email-validation.ts @@ -1,4 +1,4 @@ -const DISPOSABLE_DOMAINS = new Set([ +const DISPOSABLE_DOMAINS = new Set([ 'guerrillamail.com', 'guerrillamail.de', 'guerrillamail.net', 'guerrillamail.org', 'guerrillamailblock.com', 'grr.la', 'sharklasers.com', 'spam4.me', 'tempmail.com', 'temp-mail.org', 'temp-mail.io', @@ -27,23 +27,25 @@ const DISPOSABLE_DOMAINS = new Set([ const OFFENSIVE_RE = /(nigger|faggot|fuckfaggot)/i; -const TYPO_TLDS = new Set(['con', 'coma', 'comhade', 'gmai', 'gmial']); +const TYPO_TLDS = new Set(['con', 'coma', 'comhade', 'gmai', 'gmial']); -async function hasMxRecords(domain) { +export type EmailValidationResult = { valid: true } | { valid: false; reason: string }; + +async function hasMxRecords(domain: string): Promise { try { const res = await fetch( `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`, - { headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) } + { headers: { Accept: 'application/dns-json' }, signal: AbortSignal.timeout(3000) }, ); if (!res.ok) return true; - const data = await res.json(); + const data = (await res.json()) as { Answer?: unknown[] }; return Array.isArray(data.Answer) && data.Answer.length > 0; } catch { return true; } } -export async function validateEmail(email) { +export async function validateEmail(email: string): Promise { const normalized = email.trim().toLowerCase(); const atIdx = normalized.indexOf('@'); if (atIdx < 1) return { valid: false, reason: 'Invalid email format' }; diff --git a/server/_shared/rate-limit.ts b/server/_shared/rate-limit.ts index b04bd649f..8e67bda3b 100644 --- a/server/_shared/rate-limit.ts +++ b/server/_shared/rate-limit.ts @@ -80,6 +80,18 @@ interface EndpointRatePolicy { const ENDPOINT_RATE_POLICIES: Record = { '/api/news/v1/summarize-article-cache': { limit: 3000, window: '60 s' }, '/api/intelligence/v1/classify-event': { limit: 600, window: '60 s' }, + // 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-sanction-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' }, + // Scenario engine: legacy /api/scenario/v1/run capped at 10 jobs/min/IP via + // inline Upstash INCR. Gateway now enforces the same budget with per-IP + // keying in checkEndpointRateLimit. + '/api/scenario/v1/run-scenario': { limit: 10, window: '60 s' }, }; const endpointLimiters = new Map(); @@ -131,3 +143,59 @@ export async function checkEndpointRateLimit( return null; } } + +// --- In-handler scoped rate limits --- +// +// Handlers that need a per-subscope cap *in addition to* the gateway-level +// endpoint policy (e.g. a tighter budget for one request variant) use this +// helper. Gateway's checkEndpointRateLimit still runs first — this is a +// second stage. + +const scopedLimiters = new Map(); + +function getScopedRatelimit(scope: string, limit: number, window: Duration): Ratelimit | null { + const cacheKey = `${scope}|${limit}|${window}`; + const cached = scopedLimiters.get(cacheKey); + if (cached) return cached; + + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return null; + + const rl = new Ratelimit({ + redis: new Redis({ url, token }), + limiter: Ratelimit.slidingWindow(limit, window), + prefix: 'rl:scope', + analytics: false, + }); + scopedLimiters.set(cacheKey, rl); + return rl; +} + +export interface ScopedRateLimitResult { + allowed: boolean; + limit: number; + reset: number; +} + +/** + * Returns whether the request is under the scoped budget. `scope` is an + * opaque namespace (e.g. `${pathname}#desktop`); `identifier` is usually the + * client IP but can be any stable caller identifier. Fail-open on Redis errors + * to stay consistent with checkRateLimit / checkEndpointRateLimit semantics. + */ +export async function checkScopedRateLimit( + scope: string, + limit: number, + window: Duration, + identifier: string, +): Promise { + const rl = getScopedRatelimit(scope, limit, window); + if (!rl) return { allowed: true, limit, reset: 0 }; + try { + const result = await rl.limit(`${scope}:${identifier}`); + return { allowed: result.success, limit: result.limit, reset: result.reset }; + } catch { + return { allowed: true, limit, reset: 0 }; + } +} diff --git a/api/_turnstile.js b/server/_shared/turnstile.ts similarity index 61% rename from api/_turnstile.js rename to server/_shared/turnstile.ts index 605c76619..7b979df40 100644 --- a/api/_turnstile.js +++ b/server/_shared/turnstile.ts @@ -1,7 +1,6 @@ const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; -export function getClientIp(request) { - // Prefer platform-populated IP headers before falling back to x-forwarded-for. +export function getClientIp(request: Request): string { return ( request.headers.get('x-real-ip') || request.headers.get('cf-connecting-ip') || @@ -10,12 +9,24 @@ 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', -}) { + // Default: dev = allow (missing secret is expected locally), prod = deny. + // Callers that need the opposite (deliberately allow missing-secret in prod) + // can still pass 'allow' explicitly. + missingSecretPolicy = 'allow-in-development', +}: VerifyTurnstileArgs): Promise { const secret = process.env.TURNSTILE_SECRET_KEY; if (!secret) { if (missingSecretPolicy === 'allow') return true; @@ -33,7 +44,7 @@ export async function verifyTurnstile({ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret, response: token, remoteip: ip }), }); - const data = await res.json(); + const data = (await res.json()) as { success?: boolean }; return data.success === true; } catch { return false; diff --git a/server/alias-rewrite.ts b/server/alias-rewrite.ts new file mode 100644 index 000000000..bf561f353 --- /dev/null +++ b/server/alias-rewrite.ts @@ -0,0 +1,31 @@ +/** + * URL-rewrite alias helper for legacy v1 paths that were renamed during the + * sebuf migration (#3207). The sebuf generator produces RPC URLs derived from + * method names (e.g. `run-scenario`), which diverge from the documented v1 + * URLs (`run`). These aliases keep the old documented URLs working byte-for- + * byte — external callers, docs, and partner scripts don't break. + * + * Each alias edge function rewrites the request pathname to the new sebuf + * path and hands off to the domain gateway. The gateway applies auth, rate + * limiting, and entitlement checks against the *new* path, so premium + * gating / cache tiers / entitlement maps stay keyed on a single canonical + * URL. + * + * Trivially deleted when v1 retires — just `rm` the alias files. + */ +export async function rewriteToSebuf( + req: Request, + newPath: string, + gateway: (req: Request) => Promise, +): Promise { + const url = new URL(req.url); + url.pathname = newPath; + const body = + req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer(); + const rewritten = new Request(url.toString(), { + method: req.method, + headers: req.headers, + body, + }); + return gateway(rewritten); +} diff --git a/server/gateway.ts b/server/gateway.ts index 2e80d9540..08fabd47f 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -218,9 +218,17 @@ const RPC_CACHE_TIER: Record = { '/api/supply-chain/v1/get-country-chokepoint-index': 'slow-browser', '/api/supply-chain/v1/get-bypass-options': 'slow-browser', '/api/supply-chain/v1/get-country-cost-shock': 'slow-browser', + '/api/supply-chain/v1/get-country-products': 'slow-browser', + '/api/supply-chain/v1/get-multi-sector-cost-shock': 'slow-browser', '/api/supply-chain/v1/get-sector-dependency': 'slow-browser', '/api/supply-chain/v1/get-route-explorer-lane': 'slow-browser', '/api/supply-chain/v1/get-route-impact': 'slow-browser', + // Scenario engine: list-scenario-templates is a compile-time constant catalog; + // daily tier gives browser max-age=3600 matching the legacy /api/scenario/v1/templates + // endpoint header. get-scenario-status is premium-gated — gateway short-circuits + // to 'slow-browser' but the entry is still required by tests/route-cache-tier.test.mjs. + '/api/scenario/v1/list-scenario-templates': 'daily', + '/api/scenario/v1/get-scenario-status': 'slow-browser', '/api/health/v1/list-disease-outbreaks': 'slow', '/api/health/v1/list-air-quality-alerts': 'fast', '/api/intelligence/v1/get-social-velocity': 'fast', @@ -241,6 +249,13 @@ const RPC_CACHE_TIER: Record = { '/api/intelligence/v1/get-regional-brief': 'slow', '/api/resilience/v1/get-resilience-score': 'slow', '/api/resilience/v1/get-resilience-ranking': 'slow', + + // Partner-facing shipping/v2. route-intelligence is premium-gated; gateway + // short-circuits to slow-browser. Entry required by tests/route-cache-tier.test.mjs. + '/api/v2/shipping/route-intelligence': 'slow-browser', + // GET /webhooks lists caller's webhooks — premium-gated; short-circuited to + // slow-browser. Entry required by tests/route-cache-tier.test.mjs. + '/api/v2/shipping/webhooks': 'slow-browser', }; import { PREMIUM_RPC_PATHS } from '../src/shared/premium-paths'; diff --git a/server/worldmonitor/leads/v1/handler.ts b/server/worldmonitor/leads/v1/handler.ts new file mode 100644 index 000000000..23d2df2ad --- /dev/null +++ b/server/worldmonitor/leads/v1/handler.ts @@ -0,0 +1,9 @@ +import type { LeadsServiceHandler } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server'; + +import { registerInterest } from './register-interest'; +import { submitContact } from './submit-contact'; + +export const leadsHandler: LeadsServiceHandler = { + submitContact, + registerInterest, +}; diff --git a/api/register-interest.js b/server/worldmonitor/leads/v1/register-interest.ts similarity index 73% rename from api/register-interest.js rename to server/worldmonitor/leads/v1/register-interest.ts index 1d284b1e2..a2e7fddb3 100644 --- a/api/register-interest.js +++ b/server/worldmonitor/leads/v1/register-interest.ts @@ -1,24 +1,45 @@ -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'; +import { checkScopedRateLimit } from '../../../_shared/rate-limit'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const MAX_EMAIL_LENGTH = 320; const MAX_META_LENGTH = 100; -const RATE_LIMIT = 5; -const RATE_WINDOW_MS = 60 * 60 * 1000; +const DESKTOP_SOURCES = new Set(['desktop-settings']); -const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS }); +// Legacy api/register-interest.js capped desktop-source signups at 2/hr per IP +// on top of the generic 5/hr endpoint budget. Since `source` is an unsigned +// client-supplied field, this cap is the backstop — the signed-header fix that +// actually authenticates the desktop bypass is tracked as a follow-up. +const DESKTOP_RATE_SCOPE = '/api/leads/v1/register-interest#desktop'; +const DESKTOP_RATE_LIMIT = 2; +const DESKTOP_RATE_WINDOW = '1 h' as const; -async function sendConfirmationEmail(email, referralCode) { +interface ConvexRegisterResult { + status: 'registered' | 'already_registered'; + referralCode: string; + referralCount: number; + position?: number; + emailSuppressed?: boolean; +} + +async function sendConfirmationEmail(email: string, referralCode: string): Promise { const referralLink = `https://worldmonitor.app/pro?ref=${referralCode}`; - const shareText = encodeURIComponent('I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:'); + const shareText = encodeURIComponent("I just joined the World Monitor Pro waitlist \u2014 real-time global intelligence powered by AI. Join me:"); const shareUrl = encodeURIComponent(referralLink); const twitterShare = `https://x.com/intent/tweet?text=${shareText}&url=${shareUrl}`; const linkedinShare = `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`; @@ -40,7 +61,7 @@ async function sendConfirmationEmail(email, referralCode) { body: JSON.stringify({ from: 'World Monitor ', to: [email], - subject: 'You\u2019re on the World Monitor Pro waitlist', + subject: "You\u2019re on the World Monitor Pro waitlist", html: `
@@ -100,7 +121,7 @@ async function sendConfirmationEmail(email, referralCode) {
Users
-
435+
+
500+
Sources
@@ -168,105 +189,84 @@ async function sendConfirmationEmail(email, referralCode) { } } -export default async function handler(req) { - if (isDisallowedOrigin(req)) { - return jsonResponse({ error: 'Origin not allowed' }, 403); +export async function registerInterest( + ctx: ServerContext, + req: RegisterInterestRequest, +): Promise { + // Honeypot — silently accept but do nothing. + if (req.website) { + return { status: 'registered', referralCode: '', referralCount: 0, position: 0, emailSuppressed: false }; } - const cors = getCorsHeaders(req, 'POST, OPTIONS'); + const ip = getClientIp(ctx.request); + const isDesktopSource = typeof req.source === 'string' && DESKTOP_SOURCES.has(req.source); - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - - if (req.method !== 'POST') { - return jsonResponse({ error: 'Method not allowed' }, 405, cors); - } - - const ip = getClientIp(req); - if (rateLimiter.isRateLimited(ip)) { - return jsonResponse({ error: 'Too many requests' }, 429, cors); - } - - let body; - try { - body = await req.json(); - } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400, cors); - } - - // Honeypot — bots auto-fill this hidden field; real users leave it empty - if (body.website) { - return jsonResponse({ status: 'registered' }, 200, cors); - } - - // Cloudflare Turnstile verification — skip for desktop app (no browser captcha available). - // Desktop bypasses captcha, so enforce stricter rate limit (2/hr vs 5/hr). - const DESKTOP_SOURCES = new Set(['desktop-settings']); - const isDesktopSource = typeof body.source === 'string' && DESKTOP_SOURCES.has(body.source); + // Desktop sources bypass Turnstile (no browser captcha). `source` is + // attacker-controlled, so anyone claiming desktop-settings skips the + // captcha — apply a tighter 2/hr per-IP cap on that path to cap abuse + // (matches the legacy handler's in-memory secondary cap). Proper fix is + // a signed desktop-secret header; tracked as a follow-up. if (isDesktopSource) { - const entry = rateLimiter.getEntry(ip); - if (entry && entry.count > 2) { - return jsonResponse({ error: 'Rate limit exceeded' }, 429, cors); + const scoped = await checkScopedRateLimit( + DESKTOP_RATE_SCOPE, + DESKTOP_RATE_LIMIT, + DESKTOP_RATE_WINDOW, + ip, + ); + if (!scoped.allowed) { + throw new ApiError(429, 'Too many requests', ''); } } else { const turnstileOk = await verifyTurnstile({ - token: body.turnstileToken || '', + token: req.turnstileToken || '', ip, logPrefix: '[register-interest]', }); if (!turnstileOk) { - return jsonResponse({ error: 'Bot verification failed' }, 403, cors); + throw new ApiError(403, 'Bot verification failed', ''); } } - const { email, source, appVersion, referredBy } = body; - if (!email || typeof email !== 'string' || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) { - return jsonResponse({ error: 'Invalid email address' }, 400, cors); + const { email, source, appVersion, referredBy } = req; + if (!email || email.length > MAX_EMAIL_LENGTH || !EMAIL_RE.test(email)) { + throw new ValidationError([{ field: 'email', description: 'Invalid email address' }]); } const emailCheck = await validateEmail(email); if (!emailCheck.valid) { - return jsonResponse({ error: emailCheck.reason }, 400, cors); + throw new ValidationError([{ field: 'email', description: emailCheck.reason }]); } - const safeSource = typeof source === 'string' - ? source.slice(0, MAX_META_LENGTH) - : 'unknown'; - const safeAppVersion = typeof appVersion === 'string' - ? appVersion.slice(0, MAX_META_LENGTH) - : 'unknown'; - const safeReferredBy = typeof referredBy === 'string' - ? referredBy.slice(0, 20) - : undefined; + const safeSource = source ? source.slice(0, MAX_META_LENGTH) : 'unknown'; + const safeAppVersion = appVersion ? appVersion.slice(0, MAX_META_LENGTH) : 'unknown'; + const safeReferredBy = referredBy ? referredBy.slice(0, 20) : undefined; const convexUrl = process.env.CONVEX_URL; if (!convexUrl) { - return jsonResponse({ error: 'Registration service unavailable' }, 503, cors); + throw new ApiError(503, 'Registration service unavailable', ''); } - try { - const client = new ConvexHttpClient(convexUrl); - const result = await client.mutation('registerInterest:register', { - email, - source: safeSource, - appVersion: safeAppVersion, - referredBy: safeReferredBy, - }); + const client = new ConvexHttpClient(convexUrl); + const result = (await client.mutation('registerInterest:register' as any, { + email, + source: safeSource, + appVersion: safeAppVersion, + referredBy: safeReferredBy, + })) as ConvexRegisterResult; - // Send confirmation email for new registrations (awaited to avoid Edge isolate termination) - // Skip if email is on the suppression list (previously bounced) - if (result.status === 'registered' && result.referralCode) { - if (!result.emailSuppressed) { - await sendConfirmationEmail(email, result.referralCode); - } else { - console.log(`[register-interest] Skipped email to suppressed address: ${email}`); - } + if (result.status === 'registered' && result.referralCode) { + if (!result.emailSuppressed) { + await sendConfirmationEmail(email, result.referralCode); + } else { + console.log(`[register-interest] Skipped email to suppressed address: ${email}`); } - - return jsonResponse(result, 200, cors); - } catch (err) { - console.error('[register-interest] Convex error:', err); - return jsonResponse({ error: 'Registration failed' }, 500, cors); } + + return { + status: result.status, + referralCode: result.referralCode, + referralCount: result.referralCount, + position: result.position ?? 0, + emailSuppressed: result.emailSuppressed ?? false, + }; } diff --git a/api/contact.js b/server/worldmonitor/leads/v1/submit-contact.ts similarity index 58% rename from api/contact.js rename to server/worldmonitor/leads/v1/submit-contact.ts index cc88e68bf..2f5f38b5c 100644 --- a/api/contact.js +++ b/server/worldmonitor/leads/v1/submit-contact.ts @@ -1,17 +1,24 @@ -export const config = { runtime: 'edge' }; +/** + * RPC: submitContact -- Stores an enterprise contact submission and emails ops. + * Port from api/contact.js + * Sources: Convex contactMessages:submit mutation + Resend notification email + */ import { ConvexHttpClient } from 'convex/browser'; -import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; -import { getClientIp, verifyTurnstile } from './_turnstile.js'; -import { jsonResponse } from './_json-response.js'; -import { createIpRateLimiter } from './_ip-rate-limit.js'; +import type { + ServerContext, + SubmitContactRequest, + SubmitContactResponse, +} from '../../../../src/generated/server/worldmonitor/leads/v1/service_server'; +import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/leads/v1/service_server'; +import { getClientIp, verifyTurnstile } from '../../../_shared/turnstile'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/; const MAX_FIELD = 500; const MAX_MESSAGE = 2000; -const FREE_EMAIL_DOMAINS = new Set([ +const FREE_EMAIL_DOMAINS = new Set([ 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp', 'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr', 'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com', @@ -24,12 +31,27 @@ const FREE_EMAIL_DOMAINS = new Set([ 't-online.de', 'libero.it', 'virgilio.it', ]); -const RATE_LIMIT = 3; -const RATE_WINDOW_MS = 60 * 60 * 1000; +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} -const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS }); +function sanitizeForSubject(str: string, maxLen = 50): string { + return str.replace(/[\r\n\0]/g, '').slice(0, maxLen); +} -async function sendNotificationEmail(name, email, organization, phone, message, ip, country) { +async function sendNotificationEmail( + name: string, + email: string, + organization: string, + phone: string, + message: string | undefined, + ip: string, + country: string | null, +): Promise { const resendKey = process.env.RESEND_API_KEY; if (!resendKey) { console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent'); @@ -77,109 +99,79 @@ async function sendNotificationEmail(name, email, organization, phone, message, } } -function escapeHtml(str) { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function sanitizeForSubject(str, maxLen = 50) { - return str.replace(/[\r\n\0]/g, '').slice(0, maxLen); -} - -export default async function handler(req) { - if (isDisallowedOrigin(req)) { - return jsonResponse({ error: 'Origin not allowed' }, 403); +export async function submitContact( + ctx: ServerContext, + req: SubmitContactRequest, +): Promise { + // Honeypot — silently accept but do nothing (bots auto-fill hidden field). + if (req.website) { + return { status: 'sent', emailSent: false }; } - const cors = getCorsHeaders(req, 'POST, OPTIONS'); - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: cors }); - } - - if (req.method !== 'POST') { - return jsonResponse({ error: 'Method not allowed' }, 405, cors); - } - - const ip = getClientIp(req); - const country = req.headers.get('cf-ipcountry') || req.headers.get('x-vercel-ip-country') || null; - - if (rateLimiter.isRateLimited(ip)) { - return jsonResponse({ error: 'Too many requests' }, 429, cors); - } - - let body; - try { - body = await req.json(); - } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400, cors); - } - - if (body.website) { - return jsonResponse({ status: 'sent' }, 200, cors); - } + const ip = getClientIp(ctx.request); + const country = ctx.request.headers.get('cf-ipcountry') + || ctx.request.headers.get('x-vercel-ip-country'); const turnstileOk = await verifyTurnstile({ - token: body.turnstileToken || '', + token: req.turnstileToken || '', ip, logPrefix: '[contact]', - missingSecretPolicy: 'allow-in-development', }); if (!turnstileOk) { - return jsonResponse({ error: 'Bot verification failed' }, 403, cors); + throw new ApiError(403, 'Bot verification failed', ''); } - const { email, name, organization, phone, message, source } = body; + const { email, name, organization, phone, message, source } = req; - if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) { - return jsonResponse({ error: 'Invalid email' }, 400, cors); + if (!email || !EMAIL_RE.test(email)) { + throw new ValidationError([{ field: 'email', description: 'Invalid email' }]); } const emailDomain = email.split('@')[1]?.toLowerCase(); if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) { - return jsonResponse({ error: 'Please use your work email address' }, 422, cors); + throw new ApiError(422, 'Please use your work email address', ''); } - if (!name || typeof name !== 'string' || name.trim().length === 0) { - return jsonResponse({ error: 'Name is required' }, 400, cors); + if (!name || name.trim().length === 0) { + throw new ValidationError([{ field: 'name', description: 'Name is required' }]); } - if (!organization || typeof organization !== 'string' || organization.trim().length === 0) { - return jsonResponse({ error: 'Company is required' }, 400, cors); + if (!organization || organization.trim().length === 0) { + throw new ValidationError([{ field: 'organization', description: 'Company is required' }]); } - if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) { - return jsonResponse({ error: 'Valid phone number is required' }, 400, cors); + if (!phone || !PHONE_RE.test(phone.trim())) { + throw new ValidationError([{ field: 'phone', description: 'Valid phone number is required' }]); } const safeName = name.slice(0, MAX_FIELD); const safeOrg = organization.slice(0, MAX_FIELD); const safePhone = phone.trim().slice(0, 30); - const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined; - const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact'; + const safeMsg = message ? message.slice(0, MAX_MESSAGE) : undefined; + const safeSource = source ? source.slice(0, 100) : 'enterprise-contact'; const convexUrl = process.env.CONVEX_URL; if (!convexUrl) { - return jsonResponse({ error: 'Service unavailable' }, 503, cors); + throw new ApiError(503, 'Service unavailable', ''); } - try { - const client = new ConvexHttpClient(convexUrl); - await client.mutation('contactMessages:submit', { - name: safeName, - email: email.trim(), - organization: safeOrg, - phone: safePhone, - message: safeMsg, - source: safeSource, - }); + const client = new ConvexHttpClient(convexUrl); + await client.mutation('contactMessages:submit' as any, { + name: safeName, + email: email.trim(), + organization: safeOrg, + phone: safePhone, + message: safeMsg, + source: safeSource, + }); - const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg, ip, country); + const emailSent = await sendNotificationEmail( + safeName, + email.trim(), + safeOrg, + safePhone, + safeMsg, + ip, + country, + ); - return jsonResponse({ status: 'sent', emailSent }, 200, cors); - } catch (err) { - console.error('[contact] error:', err); - return jsonResponse({ error: 'Failed to send message' }, 500, cors); - } + return { status: 'sent', emailSent }; } diff --git a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts index 446733ffc..e9f7e4a97 100644 --- a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts +++ b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts @@ -7,6 +7,7 @@ import type { AisDisruption, AisDisruptionType, AisDisruptionSeverity, + SnapshotCandidateReport, } from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server'; import { getRelayBaseUrl, getRelayHeaders } from '../../../_shared/relay'; @@ -26,44 +27,73 @@ const SEVERITY_MAP: Record = { high: 'AIS_DISRUPTION_SEVERITY_HIGH', }; -// In-memory cache (matches old /api/ais-snapshot behavior) +// Cache the two variants separately — candidate reports materially change +// payload size, and clients with no position callbacks should not have to +// wait on or pay for the heavier payload. const SNAPSHOT_CACHE_TTL_MS = 300_000; // 5 min -- matches client poll interval -let cachedSnapshot: VesselSnapshot | undefined; -let cacheTimestamp = 0; -let inFlightRequest: Promise | null = null; -async function fetchVesselSnapshot(): Promise { - // Return cached if fresh +interface SnapshotCacheSlot { + snapshot: VesselSnapshot | undefined; + timestamp: number; + inFlight: Promise | null; +} + +const cache: Record<'with' | 'without', SnapshotCacheSlot> = { + with: { snapshot: undefined, timestamp: 0, inFlight: null }, + without: { snapshot: undefined, timestamp: 0, inFlight: null }, +}; + +async function fetchVesselSnapshot(includeCandidates: boolean): Promise { + const slot = cache[includeCandidates ? 'with' : 'without']; const now = Date.now(); - if (cachedSnapshot && (now - cacheTimestamp) < SNAPSHOT_CACHE_TTL_MS) { - return cachedSnapshot; + if (slot.snapshot && (now - slot.timestamp) < SNAPSHOT_CACHE_TTL_MS) { + return slot.snapshot; } - // In-flight dedup: if a request is already running, await it - if (inFlightRequest) { - return inFlightRequest; + if (slot.inFlight) { + return slot.inFlight; } - inFlightRequest = fetchVesselSnapshotFromRelay(); + slot.inFlight = fetchVesselSnapshotFromRelay(includeCandidates); try { - const result = await inFlightRequest; + const result = await slot.inFlight; if (result) { - cachedSnapshot = result; - cacheTimestamp = Date.now(); + slot.snapshot = result; + slot.timestamp = Date.now(); } - return result ?? cachedSnapshot; // serve stale on relay failure + return result ?? slot.snapshot; // serve stale on relay failure } finally { - inFlightRequest = null; + slot.inFlight = null; } } -async function fetchVesselSnapshotFromRelay(): Promise { +function toCandidateReport(raw: any): SnapshotCandidateReport | null { + if (!raw || typeof raw !== 'object') return null; + const mmsi = String(raw.mmsi ?? ''); + if (!mmsi) return null; + const lat = Number(raw.lat); + const lon = Number(raw.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { + mmsi, + name: String(raw.name ?? ''), + lat, + lon, + shipType: Number.isFinite(Number(raw.shipType)) ? Number(raw.shipType) : 0, + heading: Number.isFinite(Number(raw.heading)) ? Number(raw.heading) : 0, + speed: Number.isFinite(Number(raw.speed)) ? Number(raw.speed) : 0, + course: Number.isFinite(Number(raw.course)) ? Number(raw.course) : 0, + timestamp: Number.isFinite(Number(raw.timestamp)) ? Number(raw.timestamp) : Date.now(), + }; +} + +async function fetchVesselSnapshotFromRelay(includeCandidates: boolean): Promise { try { const relayBaseUrl = getRelayBaseUrl(); if (!relayBaseUrl) return undefined; const response = await fetch( - `${relayBaseUrl}/ais/snapshot?candidates=false`, + `${relayBaseUrl}/ais/snapshot?candidates=${includeCandidates ? 'true' : 'false'}`, { headers: getRelayHeaders(), signal: AbortSignal.timeout(10000), @@ -107,10 +137,22 @@ async function fetchVesselSnapshotFromRelay(): Promise r !== null) + : []; + return { snapshotAt: Date.now(), densityZones, disruptions, + sequence: Number.isFinite(Number(data.sequence)) ? Number(data.sequence) : 0, + status: { + connected: Boolean(rawStatus.connected), + vessels: Number.isFinite(Number(rawStatus.vessels)) ? Number(rawStatus.vessels) : 0, + messages: Number.isFinite(Number(rawStatus.messages)) ? Number(rawStatus.messages) : 0, + }, + candidateReports, }; } catch { return undefined; @@ -123,10 +165,10 @@ async function fetchVesselSnapshotFromRelay(): Promise { try { - const snapshot = await fetchVesselSnapshot(); + const snapshot = await fetchVesselSnapshot(Boolean(req.includeCandidates)); return { snapshot }; } catch { return { snapshot: undefined }; diff --git a/server/worldmonitor/military/v1/list-military-flights.ts b/server/worldmonitor/military/v1/list-military-flights.ts index f536aac7f..35c9c154c 100644 --- a/server/worldmonitor/military/v1/list-military-flights.ts +++ b/server/worldmonitor/military/v1/list-military-flights.ts @@ -3,15 +3,18 @@ import type { ListMilitaryFlightsRequest, ListMilitaryFlightsResponse, MilitaryAircraftType, + MilitaryOperator, + MilitaryConfidence, } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; import { isMilitaryCallsign, isMilitaryHex, detectAircraftType, UPSTREAM_TIMEOUT_MS } from './_shared'; -import { cachedFetchJson } from '../../../_shared/redis'; +import { cachedFetchJson, getRawJson } from '../../../_shared/redis'; import { markNoCacheResponse } from '../../../_shared/response-headers'; import { getRelayBaseUrl, getRelayHeaders } from '../../../_shared/relay'; const REDIS_CACHE_KEY = 'military:flights:v1'; const REDIS_CACHE_TTL = 600; // 10 min — reduce upstream API pressure +const REDIS_STALE_KEY = 'military:flights:stale:v1'; /** Snap a coordinate to a grid step so nearby bbox values share cache entries. */ const quantize = (v: number, step: number) => Math.round(v / step) * step; @@ -53,8 +56,110 @@ const AIRCRAFT_TYPE_MAP: Record = { reconnaissance: 'MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE', drone: 'MILITARY_AIRCRAFT_TYPE_DRONE', bomber: 'MILITARY_AIRCRAFT_TYPE_BOMBER', + fighter: 'MILITARY_AIRCRAFT_TYPE_FIGHTER', + helicopter: 'MILITARY_AIRCRAFT_TYPE_HELICOPTER', + vip: 'MILITARY_AIRCRAFT_TYPE_VIP', + special_ops: 'MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS', }; +const OPERATOR_MAP: Record = { + usaf: 'MILITARY_OPERATOR_USAF', + raf: 'MILITARY_OPERATOR_RAF', + faf: 'MILITARY_OPERATOR_FAF', + gaf: 'MILITARY_OPERATOR_GAF', + iaf: 'MILITARY_OPERATOR_IAF', + nato: 'MILITARY_OPERATOR_NATO', + other: 'MILITARY_OPERATOR_OTHER', +}; + +const CONFIDENCE_MAP: Record = { + high: 'MILITARY_CONFIDENCE_HIGH', + medium: 'MILITARY_CONFIDENCE_MEDIUM', + low: 'MILITARY_CONFIDENCE_LOW', +}; + +interface StaleFlight { + id?: string; + callsign?: string; + hexCode?: string; + registration?: string; + aircraftType?: string; + aircraftModel?: string; + operator?: string; + operatorCountry?: string; + lat?: number | null; + lon?: number | null; + altitude?: number; + heading?: number; + speed?: number; + verticalRate?: number; + onGround?: boolean; + squawk?: string; + origin?: string; + destination?: string; + lastSeenMs?: number; + firstSeenMs?: number; + confidence?: string; + isInteresting?: boolean; + note?: string; +} + +interface StalePayload { + flights?: StaleFlight[]; + fetchedAt?: number; +} + +/** + * Convert the seed cron's app-shape flight (flat lat/lon, lowercase enums, + * lastSeenMs) into the proto shape (nested GeoCoordinates, enum strings, + * lastSeenAt). Mirrors the inverse of src/services/military-flights.ts:mapProtoFlight. + * hexCode is canonicalized to uppercase per the invariant documented on + * MilitaryFlight.hex_code in military_flight.proto. + */ +function staleToProto(f: StaleFlight): ListMilitaryFlightsResponse['flights'][number] | null { + if (f.lat == null || f.lon == null) return null; + const icao = (f.hexCode || f.id || '').toUpperCase(); + if (!icao) return null; + return { + id: icao, + callsign: (f.callsign || '').trim(), + hexCode: icao, + registration: f.registration || '', + aircraftType: (AIRCRAFT_TYPE_MAP[f.aircraftType || ''] || 'MILITARY_AIRCRAFT_TYPE_UNKNOWN') as MilitaryAircraftType, + aircraftModel: f.aircraftModel || '', + operator: (OPERATOR_MAP[f.operator || ''] || 'MILITARY_OPERATOR_OTHER') as MilitaryOperator, + operatorCountry: f.operatorCountry || '', + location: { latitude: f.lat, longitude: f.lon }, + altitude: f.altitude ?? 0, + heading: f.heading ?? 0, + speed: f.speed ?? 0, + verticalRate: f.verticalRate ?? 0, + onGround: f.onGround ?? false, + squawk: f.squawk || '', + origin: f.origin || '', + destination: f.destination || '', + lastSeenAt: f.lastSeenMs ?? Date.now(), + firstSeenAt: f.firstSeenMs ?? 0, + confidence: (CONFIDENCE_MAP[f.confidence || ''] || 'MILITARY_CONFIDENCE_LOW') as MilitaryConfidence, + isInteresting: f.isInteresting ?? false, + note: f.note || '', + enrichment: undefined, + }; +} + +async function fetchStaleFallback(): Promise { + try { + const raw = (await getRawJson(REDIS_STALE_KEY)) as StalePayload | null; + if (!raw || !Array.isArray(raw.flights) || raw.flights.length === 0) return null; + const flights = raw.flights + .map(staleToProto) + .filter((f): f is NonNullable => f != null); + return flights.length > 0 ? flights : null; + } catch { + return null; + } +} + export async function listMilitaryFlights( ctx: ServerContext, req: ListMilitaryFlightsRequest, @@ -115,11 +220,17 @@ export async function listMilitaryFlights( if (!isMilitaryCallsign(callsign) && !isMilitaryHex(icao24)) continue; const aircraftType = detectAircraftType(callsign); + // Canonicalize hex_code to uppercase — the seed cron + // (scripts/seed-military-flights.mjs) writes uppercase, and + // src/services/military-flights.ts getFlightByHex uppercases the + // lookup input. Preserving OpenSky's lowercase here would break + // every hex lookup silently. + const hex = icao24.toUpperCase(); flights.push({ - id: icao24, + id: hex, callsign: (callsign || '').trim(), - hexCode: icao24, + hexCode: hex, registration: '', aircraftType: (AIRCRAFT_TYPE_MAP[aircraftType] || 'MILITARY_AIRCRAFT_TYPE_UNKNOWN') as MilitaryAircraftType, aircraftModel: '', @@ -148,6 +259,15 @@ export async function listMilitaryFlights( ); if (!fullResult) { + // Live fetch failed. The legacy /api/military-flights handler cascaded + // military:flights:v1 → military:flights:stale:v1 before returning empty. + // The seed cron (scripts/seed-military-flights.mjs) writes both keys + // every run; stale has a 24h TTL versus 10min live, so it's the right + // fallback when OpenSky / the relay hiccups. + const staleFlights = await fetchStaleFallback(); + if (staleFlights && staleFlights.length > 0) { + return { flights: filterFlightsToBounds(staleFlights, requestBounds), clusters: [], pagination: undefined }; + } markNoCacheResponse(ctx.request); return { flights: [], clusters: [], pagination: undefined }; } diff --git a/server/worldmonitor/sanctions/v1/lookup-entity.ts b/server/worldmonitor/sanctions/v1/lookup-entity.ts index 2b9b96acd..406797d2b 100644 --- a/server/worldmonitor/sanctions/v1/lookup-entity.ts +++ b/server/worldmonitor/sanctions/v1/lookup-entity.ts @@ -11,7 +11,10 @@ import { getCachedJson } from '../../../_shared/redis'; const ENTITY_INDEX_KEY = 'sanctions:entities:v1'; const DEFAULT_MAX = 10; const MAX_RESULTS_LIMIT = 50; +const MAX_QUERY_LENGTH = 200; const MIN_QUERY_LENGTH = 2; +const OPENSANCTIONS_BASE = 'https://api.opensanctions.org'; +const OPENSANCTIONS_TIMEOUT_MS = 8_000; interface EntityIndexRecord { id: string; @@ -21,6 +24,24 @@ interface EntityIndexRecord { pr: string[]; } +interface OpenSanctionsHit { + id?: string; + schema?: string; + caption?: string; + properties?: { + name?: string[]; + country?: string[]; + nationality?: string[]; + program?: string[]; + sanctions?: string[]; + }; +} + +interface OpenSanctionsSearchResponse { + results?: OpenSanctionsHit[]; + total?: { value?: number }; +} + function normalize(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]/g, ' ').replace(/\s+/g, ' ').trim(); } @@ -30,59 +51,122 @@ function clampMax(value: number): number { return Math.min(Math.max(Math.trunc(value), 1), MAX_RESULTS_LIMIT); } +function entityTypeFromSchema(schema: string): string { + if (schema === 'Vessel') return 'vessel'; + if (schema === 'Aircraft') return 'aircraft'; + if (schema === 'Person') return 'individual'; + return 'entity'; +} + +function normalizeOpenSanctionsHit(hit: OpenSanctionsHit): SanctionEntityMatch | null { + const props = hit.properties ?? {}; + const name = (props.name ?? [hit.caption ?? '']).filter(Boolean)[0] ?? ''; + if (!name || !hit.id) return null; + const countries = (props.country ?? props.nationality ?? []).slice(0, 3); + const programs = (props.program ?? props.sanctions ?? []).slice(0, 3); + return { + id: `opensanctions:${hit.id}`, + name, + entityType: entityTypeFromSchema(hit.schema ?? ''), + countryCodes: countries, + programs, + }; +} + +async function searchOpenSanctions(q: string, limit: number): Promise<{ results: SanctionEntityMatch[]; total: number } | null> { + const url = new URL(`${OPENSANCTIONS_BASE}/search/default`); + url.searchParams.set('q', q); + url.searchParams.set('limit', String(limit)); + + const resp = await fetch(url.toString(), { + headers: { + 'User-Agent': 'WorldMonitor/1.0 sanctions-search', + Accept: 'application/json', + }, + signal: AbortSignal.timeout(OPENSANCTIONS_TIMEOUT_MS), + }); + + if (!resp.ok) return null; + + const data = (await resp.json()) as OpenSanctionsSearchResponse; + const hits = Array.isArray(data.results) ? data.results : []; + const results = hits + .map(normalizeOpenSanctionsHit) + .filter((r): r is SanctionEntityMatch => r !== null); + const total = data.total?.value ?? results.length; + return { results, total }; +} + +function searchOfacLocal(q: string, maxResults: number, raw: unknown): { results: SanctionEntityMatch[]; total: number } { + if (!Array.isArray(raw)) return { results: [], total: 0 }; + + const index = raw as EntityIndexRecord[]; + const needle = normalize(q); + const tokens = needle.split(' ').filter(Boolean); + const scored: Array<{ score: number; entry: EntityIndexRecord }> = []; + + for (const entry of index) { + const haystack = normalize(entry.name); + + if (haystack === needle) { + scored.push({ score: 100, entry }); + continue; + } + if (haystack.startsWith(needle)) { + scored.push({ score: 80, entry }); + continue; + } + if (tokens.length > 0 && tokens.every((t) => haystack.includes(t))) { + const pos = haystack.indexOf(tokens[0] ?? ''); + scored.push({ score: 60 - Math.min(pos, 20), entry }); + continue; + } + const matchCount = tokens.filter((t) => haystack.includes(t)).length; + if (matchCount > 0) { + scored.push({ score: matchCount * 10, entry }); + } + } + + scored.sort((a, b) => b.score - a.score); + + const results: SanctionEntityMatch[] = scored.slice(0, maxResults).map(({ entry }) => ({ + id: entry.id, + name: entry.name, + entityType: entry.et, + countryCodes: entry.cc, + programs: entry.pr, + })); + + return { results, total: scored.length }; +} + export const lookupSanctionEntity: SanctionsServiceHandler['lookupSanctionEntity'] = async ( _ctx: ServerContext, req: LookupSanctionEntityRequest, ): Promise => { const q = (req.q ?? '').trim(); - if (q.length < MIN_QUERY_LENGTH) { - return { results: [], total: 0, source: 'ofac' }; + if (q.length < MIN_QUERY_LENGTH || q.length > MAX_QUERY_LENGTH) { + return { results: [], total: 0, source: 'opensanctions' }; } const maxResults = clampMax(req.maxResults); - const needle = normalize(q); - const tokens = needle.split(' ').filter(Boolean); + // Primary: live query against OpenSanctions — broader global coverage than + // the local OFAC index. Matches the legacy /api/sanctions-entity-search path. + try { + const upstream = await searchOpenSanctions(q, maxResults); + if (upstream) { + return { ...upstream, source: 'opensanctions' }; + } + } catch { + // fall through to OFAC fallback + } + + // Fallback: local OFAC fuzzy match from the seeded Redis index. Keeps the + // endpoint useful when OpenSanctions is unreachable or rate-limiting us. try { const raw = await getCachedJson(ENTITY_INDEX_KEY, true); - if (!Array.isArray(raw)) return { results: [], total: 0, source: 'ofac' }; - - const index = raw as EntityIndexRecord[]; - const scored: Array<{ score: number; entry: EntityIndexRecord }> = []; - - for (const entry of index) { - const haystack = normalize(entry.name); - - if (haystack === needle) { - scored.push({ score: 100, entry }); - continue; - } - if (haystack.startsWith(needle)) { - scored.push({ score: 80, entry }); - continue; - } - if (tokens.length > 0 && tokens.every((t) => haystack.includes(t))) { - const pos = haystack.indexOf(tokens[0] ?? ''); - scored.push({ score: 60 - Math.min(pos, 20), entry }); - continue; - } - const matchCount = tokens.filter((t) => haystack.includes(t)).length; - if (matchCount > 0) { - scored.push({ score: matchCount * 10, entry }); - } - } - - scored.sort((a, b) => b.score - a.score); - - const results: SanctionEntityMatch[] = scored.slice(0, maxResults).map(({ entry }) => ({ - id: entry.id, - name: entry.name, - entityType: entry.et, - countryCodes: entry.cc, - programs: entry.pr, - })); - - return { results, total: scored.length, source: 'ofac' }; + return { ...searchOfacLocal(q, maxResults, raw), source: 'ofac' }; } catch { return { results: [], total: 0, source: 'ofac' }; } diff --git a/server/worldmonitor/scenario/v1/get-scenario-status.ts b/server/worldmonitor/scenario/v1/get-scenario-status.ts new file mode 100644 index 000000000..cde99c5f9 --- /dev/null +++ b/server/worldmonitor/scenario/v1/get-scenario-status.ts @@ -0,0 +1,99 @@ +import type { + ServerContext, + GetScenarioStatusRequest, + GetScenarioStatusResponse, + ScenarioResult, +} from '../../../../src/generated/server/worldmonitor/scenario/v1/service_server'; +import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/scenario/v1/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { getRawJson } from '../../../_shared/redis'; + +// Matches jobIds produced by run-scenario.ts: `scenario:{13-digit-ts}:{8-char-suffix}`. +// Guards `GET /scenario-result/{jobId}` against path-traversal via crafted jobId. +const JOB_ID_RE = /^scenario:\d{13}:[a-z0-9]{8}$/; + +interface WorkerResultEnvelope { + status?: string; + result?: unknown; + error?: unknown; +} + +function coerceImpactCountries(raw: unknown): ScenarioResult['topImpactCountries'] { + if (!Array.isArray(raw)) return []; + const out: ScenarioResult['topImpactCountries'] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const c = entry as { iso2?: unknown; totalImpact?: unknown; impactPct?: unknown }; + out.push({ + iso2: typeof c.iso2 === 'string' ? c.iso2 : '', + totalImpact: typeof c.totalImpact === 'number' ? c.totalImpact : 0, + impactPct: typeof c.impactPct === 'number' ? c.impactPct : 0, + }); + } + return out; +} + +function coerceTemplate(raw: unknown): ScenarioResult['template'] { + if (!raw || typeof raw !== 'object') return undefined; + const t = raw as { name?: unknown; disruptionPct?: unknown; durationDays?: unknown; costShockMultiplier?: unknown }; + return { + name: typeof t.name === 'string' ? t.name : '', + disruptionPct: typeof t.disruptionPct === 'number' ? t.disruptionPct : 0, + durationDays: typeof t.durationDays === 'number' ? t.durationDays : 0, + costShockMultiplier: typeof t.costShockMultiplier === 'number' ? t.costShockMultiplier : 1, + }; +} + +function coerceResult(raw: unknown): ScenarioResult | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const r = raw as { affectedChokepointIds?: unknown; topImpactCountries?: unknown; template?: unknown }; + return { + affectedChokepointIds: Array.isArray(r.affectedChokepointIds) + ? r.affectedChokepointIds.filter((id): id is string => typeof id === 'string') + : [], + topImpactCountries: coerceImpactCountries(r.topImpactCountries), + template: coerceTemplate(r.template), + }; +} + +export async function getScenarioStatus( + ctx: ServerContext, + req: GetScenarioStatusRequest, +): Promise { + const isPro = await isCallerPremium(ctx.request); + if (!isPro) { + throw new ApiError(403, 'PRO subscription required', ''); + } + + const jobId = req.jobId ?? ''; + if (!JOB_ID_RE.test(jobId)) { + throw new ValidationError([{ field: 'jobId', description: 'Invalid or missing jobId' }]); + } + + // Worker writes under the raw (unprefixed) key, so we must read raw. + let envelope: WorkerResultEnvelope | null = null; + try { + envelope = await getRawJson(`scenario-result:${jobId}`) as WorkerResultEnvelope | null; + } catch { + throw new ApiError(502, 'Failed to fetch job status', ''); + } + + if (!envelope) { + return { status: 'pending', error: '' }; + } + + const status = typeof envelope.status === 'string' ? envelope.status : 'pending'; + + if (status === 'done') { + const result = coerceResult(envelope.result); + return { status: 'done', result, error: '' }; + } + + if (status === 'failed') { + const error = typeof envelope.error === 'string' ? envelope.error : 'computation_error'; + return { status: 'failed', error }; + } + + return { status, error: '' }; +} diff --git a/server/worldmonitor/scenario/v1/handler.ts b/server/worldmonitor/scenario/v1/handler.ts new file mode 100644 index 000000000..c05bc6136 --- /dev/null +++ b/server/worldmonitor/scenario/v1/handler.ts @@ -0,0 +1,11 @@ +import type { ScenarioServiceHandler } from '../../../../src/generated/server/worldmonitor/scenario/v1/service_server'; + +import { runScenario } from './run-scenario'; +import { getScenarioStatus } from './get-scenario-status'; +import { listScenarioTemplates } from './list-scenario-templates'; + +export const scenarioHandler: ScenarioServiceHandler = { + runScenario, + getScenarioStatus, + listScenarioTemplates, +}; diff --git a/server/worldmonitor/scenario/v1/list-scenario-templates.ts b/server/worldmonitor/scenario/v1/list-scenario-templates.ts new file mode 100644 index 000000000..c3864b7e4 --- /dev/null +++ b/server/worldmonitor/scenario/v1/list-scenario-templates.ts @@ -0,0 +1,26 @@ +import type { + ServerContext, + ListScenarioTemplatesRequest, + ListScenarioTemplatesResponse, +} from '../../../../src/generated/server/worldmonitor/scenario/v1/service_server'; + +import { SCENARIO_TEMPLATES } from '../../supply-chain/v1/scenario-templates'; + +export async function listScenarioTemplates( + _ctx: ServerContext, + _req: ListScenarioTemplatesRequest, +): Promise { + return { + templates: SCENARIO_TEMPLATES.map((t) => ({ + id: t.id, + name: t.name, + affectedChokepointIds: [...t.affectedChokepointIds], + disruptionPct: t.disruptionPct, + durationDays: t.durationDays, + // Empty array means ALL sectors on the wire (mirrors the `affectedHs2: null` + // template convention — proto `repeated` cannot carry null). + affectedHs2: t.affectedHs2 ? [...t.affectedHs2] : [], + costShockMultiplier: t.costShockMultiplier, + })), + }; +} diff --git a/server/worldmonitor/scenario/v1/run-scenario.ts b/server/worldmonitor/scenario/v1/run-scenario.ts new file mode 100644 index 000000000..a14dc73b3 --- /dev/null +++ b/server/worldmonitor/scenario/v1/run-scenario.ts @@ -0,0 +1,79 @@ +import type { + ServerContext, + RunScenarioRequest, + RunScenarioResponse, +} from '../../../../src/generated/server/worldmonitor/scenario/v1/service_server'; +import { ApiError, ValidationError } from '../../../../src/generated/server/worldmonitor/scenario/v1/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { runRedisPipeline } from '../../../_shared/redis'; +import { getScenarioTemplate } from '../../supply-chain/v1/scenario-templates'; + +const QUEUE_KEY = 'scenario-queue:pending'; +const MAX_QUEUE_DEPTH = 100; +const JOB_ID_CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +function generateJobId(): string { + const ts = Date.now(); + let suffix = ''; + const array = new Uint8Array(8); + crypto.getRandomValues(array); + for (const byte of array) suffix += JOB_ID_CHARSET[byte % JOB_ID_CHARSET.length]; + return `scenario:${ts}:${suffix}`; +} + +export async function runScenario( + ctx: ServerContext, + req: RunScenarioRequest, +): Promise { + const isPro = await isCallerPremium(ctx.request); + if (!isPro) { + throw new ApiError(403, 'PRO subscription required', ''); + } + + const scenarioId = (req.scenarioId ?? '').trim(); + if (!scenarioId) { + throw new ValidationError([{ field: 'scenarioId', description: 'scenarioId is required' }]); + } + if (!getScenarioTemplate(scenarioId)) { + throw new ValidationError([{ field: 'scenarioId', description: `Unknown scenario: ${scenarioId}` }]); + } + + const iso2 = req.iso2 ? req.iso2.trim() : ''; + if (iso2 && !/^[A-Z]{2}$/.test(iso2)) { + throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase country code' }]); + } + + // Queue-depth backpressure. Raw key: worker reads it unprefixed, so we must too. + const [depthEntry] = await runRedisPipeline([['LLEN', QUEUE_KEY]], true); + const depth = typeof depthEntry?.result === 'number' ? depthEntry.result : 0; + if (depth > MAX_QUEUE_DEPTH) { + throw new ApiError(429, 'Scenario queue is at capacity, please try again later', ''); + } + + const jobId = generateJobId(); + const payload = JSON.stringify({ + jobId, + scenarioId, + iso2: iso2 || null, + enqueuedAt: Date.now(), + }); + + // Upstash RPUSH returns the new list length; helper returns [] on transport + // failure. Either no entry or a non-numeric result means the enqueue never + // landed — surface as 502 so the caller retries. + const [pushEntry] = await runRedisPipeline([['RPUSH', QUEUE_KEY, payload]], true); + if (!pushEntry || typeof pushEntry.result !== 'number') { + throw new ApiError(502, 'Failed to enqueue scenario job', ''); + } + + // statusUrl is a server-computed convenience URL preserved from the legacy + // /api/scenario/v1/run contract so external callers can keep polling via the + // response body rather than hardcoding the status path. See the proto comment + // on RunScenarioResponse for why this matters on a v1 → v1 migration. + return { + jobId, + status: 'pending', + statusUrl: `/api/scenario/v1/get-scenario-status?jobId=${encodeURIComponent(jobId)}`, + }; +} diff --git a/server/worldmonitor/shipping/v2/handler.ts b/server/worldmonitor/shipping/v2/handler.ts new file mode 100644 index 000000000..bd503e828 --- /dev/null +++ b/server/worldmonitor/shipping/v2/handler.ts @@ -0,0 +1,11 @@ +import type { ShippingV2ServiceHandler } from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; + +import { routeIntelligence } from './route-intelligence'; +import { registerWebhook } from './register-webhook'; +import { listWebhooks } from './list-webhooks'; + +export const shippingV2Handler: ShippingV2ServiceHandler = { + routeIntelligence, + registerWebhook, + listWebhooks, +}; diff --git a/server/worldmonitor/shipping/v2/list-webhooks.ts b/server/worldmonitor/shipping/v2/list-webhooks.ts new file mode 100644 index 000000000..faba5cfd8 --- /dev/null +++ b/server/worldmonitor/shipping/v2/list-webhooks.ts @@ -0,0 +1,70 @@ +import type { + ServerContext, + ListWebhooksRequest, + ListWebhooksResponse, + WebhookSummary, +} from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; +import { ApiError } from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; + +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../../../api/_api-key.js'; +import { isCallerPremium } from '../../../_shared/premium-check'; +import { runRedisPipeline } from '../../../_shared/redis'; +import { + webhookKey, + ownerIndexKey, + callerFingerprint, + type WebhookRecord, +} from './webhook-shared'; + +export async function listWebhooks( + ctx: ServerContext, + _req: ListWebhooksRequest, +): Promise { + // Without forceKey, Clerk-authenticated pro callers reach this handler with + // no API key, callerFingerprint() returns the 'anon' fallback, and the + // ownerTag !== ownerHash defense-in-depth below collapses because both + // sides equal 'anon' — exposing every 'anon'-bucket tenant's webhooks to + // every Clerk-session holder. See registerWebhook for full rationale. + const apiKeyResult = validateApiKey(ctx.request, { forceKey: true }) as { + valid: boolean; required: boolean; error?: string; + }; + if (apiKeyResult.required && !apiKeyResult.valid) { + throw new ApiError(401, apiKeyResult.error ?? 'API key required', ''); + } + + const isPro = await isCallerPremium(ctx.request); + if (!isPro) { + throw new ApiError(403, 'PRO subscription required', ''); + } + + const ownerHash = await callerFingerprint(ctx.request); + const smembersResult = await runRedisPipeline([['SMEMBERS', ownerIndexKey(ownerHash)]]); + const memberIds = (smembersResult[0]?.result as string[] | null) ?? []; + + if (memberIds.length === 0) { + return { webhooks: [] }; + } + + const getResults = await runRedisPipeline(memberIds.map(id => ['GET', webhookKey(id)])); + const webhooks: WebhookSummary[] = []; + for (const r of getResults) { + if (!r.result || typeof r.result !== 'string') continue; + try { + const record = JSON.parse(r.result) as WebhookRecord; + if (record.ownerTag !== ownerHash) continue; + webhooks.push({ + subscriberId: record.subscriberId, + callbackUrl: record.callbackUrl, + chokepointIds: record.chokepointIds, + alertThreshold: record.alertThreshold, + createdAt: record.createdAt, + active: record.active, + }); + } catch { + // skip malformed + } + } + + return { webhooks }; +} diff --git a/server/worldmonitor/shipping/v2/register-webhook.ts b/server/worldmonitor/shipping/v2/register-webhook.ts new file mode 100644 index 000000000..0373cec40 --- /dev/null +++ b/server/worldmonitor/shipping/v2/register-webhook.ts @@ -0,0 +1,100 @@ +import type { + ServerContext, + RegisterWebhookRequest, + RegisterWebhookResponse, +} from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; +import { + ApiError, + ValidationError, +} from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; + +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../../../api/_api-key.js'; +import { isCallerPremium } from '../../../_shared/premium-check'; +import { runRedisPipeline } from '../../../_shared/redis'; +import { + WEBHOOK_TTL, + VALID_CHOKEPOINT_IDS, + isBlockedCallbackUrl, + generateSecret, + generateSubscriberId, + webhookKey, + ownerIndexKey, + callerFingerprint, + type WebhookRecord, +} from './webhook-shared'; + +export async function registerWebhook( + ctx: ServerContext, + req: RegisterWebhookRequest, +): Promise { + // Webhooks are per-tenant keyed on callerFingerprint(), which hashes the + // API key. Without forceKey, a Clerk-authenticated pro caller reaches this + // handler with no API key, callerFingerprint() falls back to 'anon', and + // every such caller collapses into a shared 'anon' owner bucket — letting + // one Clerk-session holder enumerate/overwrite other tenants' webhooks. + // Matches the legacy `api/v2/shipping/webhooks/[subscriberId]{,/[action]}.ts` + // gate and the documented "X-WorldMonitor-Key required" contract in + // docs/api-shipping-v2.mdx. + const apiKeyResult = validateApiKey(ctx.request, { forceKey: true }) as { + valid: boolean; required: boolean; error?: string; + }; + if (apiKeyResult.required && !apiKeyResult.valid) { + throw new ApiError(401, apiKeyResult.error ?? 'API key required', ''); + } + + const isPro = await isCallerPremium(ctx.request); + if (!isPro) { + throw new ApiError(403, 'PRO subscription required', ''); + } + + const callbackUrl = (req.callbackUrl ?? '').trim(); + if (!callbackUrl) { + throw new ValidationError([{ field: 'callbackUrl', description: 'callbackUrl is required' }]); + } + + const ssrfError = isBlockedCallbackUrl(callbackUrl); + if (ssrfError) { + throw new ValidationError([{ field: 'callbackUrl', description: ssrfError }]); + } + + const chokepointIds = Array.isArray(req.chokepointIds) ? req.chokepointIds : []; + const invalidCp = chokepointIds.find(id => !VALID_CHOKEPOINT_IDS.has(id)); + if (invalidCp) { + throw new ValidationError([ + { field: 'chokepointIds', description: `Unknown chokepoint ID: ${invalidCp}` }, + ]); + } + + // Proto default int32 is 0 — treat 0 as "unset" to preserve the legacy + // default of 50 when the caller omits alertThreshold. + const alertThreshold = req.alertThreshold > 0 ? req.alertThreshold : 50; + if (alertThreshold < 0 || alertThreshold > 100) { + throw new ValidationError([ + { field: 'alertThreshold', description: 'alertThreshold must be a number between 0 and 100' }, + ]); + } + + const ownerTag = await callerFingerprint(ctx.request); + const newSubscriberId = generateSubscriberId(); + const secret = await generateSecret(); + + const record: WebhookRecord = { + subscriberId: newSubscriberId, + ownerTag, + callbackUrl, + chokepointIds: chokepointIds.length ? chokepointIds : [...VALID_CHOKEPOINT_IDS], + alertThreshold, + createdAt: new Date().toISOString(), + active: true, + secret, + }; + + await runRedisPipeline([ + ['SET', webhookKey(newSubscriberId), JSON.stringify(record), 'EX', String(WEBHOOK_TTL)], + ['SADD', ownerIndexKey(ownerTag), newSubscriberId], + ['EXPIRE', ownerIndexKey(ownerTag), String(WEBHOOK_TTL)], + ]); + + return { subscriberId: newSubscriberId, secret }; +} diff --git a/server/worldmonitor/shipping/v2/route-intelligence.ts b/server/worldmonitor/shipping/v2/route-intelligence.ts new file mode 100644 index 000000000..557659eb8 --- /dev/null +++ b/server/worldmonitor/shipping/v2/route-intelligence.ts @@ -0,0 +1,116 @@ +import type { + ServerContext, + RouteIntelligenceRequest, + RouteIntelligenceResponse, + ChokepointExposure, + BypassOption, +} from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; +import { + ApiError, + ValidationError, +} from '../../../../src/generated/server/worldmonitor/shipping/v2/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { getCachedJson } from '../../../_shared/redis'; +import { CHOKEPOINT_STATUS_KEY } from '../../../_shared/cache-keys'; +import { BYPASS_CORRIDORS_BY_CHOKEPOINT, type CargoType } from '../../../_shared/bypass-corridors'; +import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry'; +import COUNTRY_PORT_CLUSTERS from '../../../../scripts/shared/country-port-clusters.json'; + +interface PortClusterEntry { + nearestRouteIds: string[]; + coastSide: string; +} + +interface ChokepointStatusEntry { + id: string; + name?: string; + disruptionScore?: number; + warRiskTier?: string; +} + +interface ChokepointStatusResponse { + chokepoints?: ChokepointStatusEntry[]; +} + +const VALID_CARGO_TYPES = new Set(['container', 'tanker', 'bulk', 'roro']); + +export async function routeIntelligence( + ctx: ServerContext, + req: RouteIntelligenceRequest, +): Promise { + const isPro = await isCallerPremium(ctx.request); + if (!isPro) { + throw new ApiError(403, 'PRO subscription required', ''); + } + + const fromIso2 = (req.fromIso2 ?? '').trim().toUpperCase(); + const toIso2 = (req.toIso2 ?? '').trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) { + throw new ValidationError([ + { field: 'fromIso2', description: 'fromIso2 and toIso2 must be valid 2-letter ISO country codes' }, + ]); + } + + const cargoTypeRaw = (req.cargoType ?? '').trim().toLowerCase(); + const cargoType: CargoType = (VALID_CARGO_TYPES.has(cargoTypeRaw) ? cargoTypeRaw : 'container') as CargoType; + const hs2 = (req.hs2 ?? '').trim().replace(/\D/g, '') || '27'; + + const clusters = COUNTRY_PORT_CLUSTERS as unknown as Record; + const fromCluster = clusters[fromIso2]; + const toCluster = clusters[toIso2]; + + const fromRoutes = new Set(fromCluster?.nearestRouteIds ?? []); + const toRoutes = new Set(toCluster?.nearestRouteIds ?? []); + const sharedRoutes = [...fromRoutes].filter(r => toRoutes.has(r)); + const primaryRouteId = sharedRoutes[0] ?? fromCluster?.nearestRouteIds[0] ?? ''; + + const statusRaw = (await getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null)) as ChokepointStatusResponse | null; + const statusMap = new Map( + (statusRaw?.chokepoints ?? []).map(cp => [cp.id, cp]), + ); + + const relevantRouteSet = new Set(sharedRoutes.length ? sharedRoutes : (fromCluster?.nearestRouteIds ?? [])); + const chokepointExposures: ChokepointExposure[] = CHOKEPOINT_REGISTRY + .filter(cp => cp.routeIds.some(r => relevantRouteSet.has(r))) + .map(cp => { + const overlap = cp.routeIds.filter(r => relevantRouteSet.has(r)).length; + const exposurePct = Math.round((overlap / Math.max(cp.routeIds.length, 1)) * 100); + return { chokepointId: cp.id, chokepointName: cp.displayName, exposurePct }; + }) + .filter(e => e.exposurePct > 0) + .sort((a, b) => b.exposurePct - a.exposurePct); + + const primaryChokepoint = chokepointExposures[0]; + const primaryCpStatus = primaryChokepoint ? statusMap.get(primaryChokepoint.chokepointId) : null; + + const disruptionScore = primaryCpStatus?.disruptionScore ?? 0; + const warRiskTier = primaryCpStatus?.warRiskTier ?? 'WAR_RISK_TIER_NORMAL'; + + const bypassOptions: BypassOption[] = primaryChokepoint + ? (BYPASS_CORRIDORS_BY_CHOKEPOINT[primaryChokepoint.chokepointId] ?? []) + .filter(c => c.suitableCargoTypes.length === 0 || c.suitableCargoTypes.includes(cargoType)) + .slice(0, 5) + .map(c => ({ + id: c.id, + name: c.name, + type: c.type, + addedTransitDays: c.addedTransitDays, + addedCostMultiplier: c.addedCostMultiplier, + activationThreshold: c.activationThreshold, + })) + : []; + + return { + fromIso2, + toIso2, + cargoType, + hs2, + primaryRouteId, + chokepointExposures, + bypassOptions, + warRiskTier, + disruptionScore, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/server/worldmonitor/shipping/v2/webhook-shared.ts b/server/worldmonitor/shipping/v2/webhook-shared.ts new file mode 100644 index 000000000..880950326 --- /dev/null +++ b/server/worldmonitor/shipping/v2/webhook-shared.ts @@ -0,0 +1,102 @@ +import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry'; + +export const WEBHOOK_TTL = 86400 * 30; // 30 days +export const VALID_CHOKEPOINT_IDS = new Set(CHOKEPOINT_REGISTRY.map(c => c.id)); + +// Private IP ranges + known cloud metadata hostnames blocked at registration. +// DNS rebinding is not mitigated here (no DNS resolution in edge runtime); the +// delivery worker must re-resolve and re-check before sending. +export const PRIVATE_HOSTNAME_PATTERNS = [ + /^localhost$/i, + /^127\.\d+\.\d+\.\d+$/, + /^10\.\d+\.\d+\.\d+$/, + /^192\.168\.\d+\.\d+$/, + /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, + /^169\.254\.\d+\.\d+$/, + /^fd[0-9a-f]{2}:/i, + /^fe80:/i, + /^::1$/, + /^0\.0\.0\.0$/, + /^0\.\d+\.\d+\.\d+$/, + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/, +]; + +export const BLOCKED_METADATA_HOSTNAMES = new Set([ + '169.254.169.254', + 'metadata.google.internal', + 'metadata.internal', + 'instance-data', + 'metadata', + 'computemetadata', + 'link-local.s3.amazonaws.com', +]); + +export function isBlockedCallbackUrl(rawUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return 'callbackUrl is not a valid URL'; + } + + if (parsed.protocol !== 'https:') { + return 'callbackUrl must use https'; + } + + const hostname = parsed.hostname.toLowerCase(); + + if (BLOCKED_METADATA_HOSTNAMES.has(hostname)) { + return 'callbackUrl hostname is a blocked metadata endpoint'; + } + + for (const pattern of PRIVATE_HOSTNAME_PATTERNS) { + if (pattern.test(hostname)) { + return `callbackUrl resolves to a private/reserved address: ${hostname}`; + } + } + + return null; +} + +export async function generateSecret(): Promise { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export function generateSubscriberId(): string { + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return 'wh_' + [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export function webhookKey(subscriberId: string): string { + return `webhook:sub:${subscriberId}:v1`; +} + +export function ownerIndexKey(ownerHash: string): string { + return `webhook:owner:${ownerHash}:v1`; +} + +/** SHA-256 hash of the caller's API key — used as ownerTag and owner index key. Never secret. */ +export async function callerFingerprint(req: Request): Promise { + const key = + req.headers.get('X-WorldMonitor-Key') ?? + req.headers.get('X-Api-Key') ?? + ''; + if (!key) return 'anon'; + const encoded = new TextEncoder().encode(key); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); + return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +export interface WebhookRecord { + subscriberId: string; + ownerTag: string; + callbackUrl: string; + chokepointIds: string[]; + alertThreshold: number; + createdAt: string; + active: boolean; + secret: string; +} diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts index bc75f6963..eaf7e6d71 100644 --- a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts @@ -257,7 +257,7 @@ async function fetchChokepointData(): Promise { const [navResult, vesselResult, transitSummariesData, flowsData] = await Promise.all([ listNavigationalWarnings(ctx, { area: '', pageSize: 0, cursor: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }), - getVesselSnapshot(ctx, { neLat: 90, neLon: 180, swLat: -90, swLon: -180 }).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }), + getVesselSnapshot(ctx, { neLat: 90, neLon: 180, swLat: -90, swLon: -180, includeCandidates: false }).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }), getCachedJson(TRANSIT_SUMMARIES_KEY, true).catch(() => null) as Promise, getCachedJson(FLOWS_KEY, true).catch(() => null) as Promise | null>, ]); diff --git a/server/worldmonitor/supply-chain/v1/get-country-products.ts b/server/worldmonitor/supply-chain/v1/get-country-products.ts new file mode 100644 index 000000000..e4a96134d --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-country-products.ts @@ -0,0 +1,47 @@ +import type { + ServerContext, + GetCountryProductsRequest, + GetCountryProductsResponse, + CountryProduct, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; +import { ValidationError } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { getCachedJson } from '../../../_shared/redis'; + +interface BilateralHs4Payload { + iso2: string; + products?: CountryProduct[]; + fetchedAt?: string; +} + +export async function getCountryProducts( + ctx: ServerContext, + req: GetCountryProductsRequest, +): Promise { + const iso2 = (req.iso2 ?? '').trim().toUpperCase(); + + // Input-shape errors return 400 — restoring the legacy /api/supply-chain/v1/ + // country-products contract which predated the sebuf migration. Empty-payload-200 + // is reserved for the PRO-gate deny path (intentional contract shift), not for + // caller bugs (malformed/missing fields). Distinguishing the two matters for + // logging, external API consumers, and silent-failure detection. + if (!/^[A-Z]{2}$/.test(iso2)) { + throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase ISO country code' }]); + } + + const isPro = await isCallerPremium(ctx.request); + const empty: GetCountryProductsResponse = { iso2, products: [], fetchedAt: '' }; + if (!isPro) return empty; + + // Seeder writes via raw key (no env-prefix) — match it on read. + const key = `comtrade:bilateral-hs4:${iso2}:v1`; + const payload = await getCachedJson(key, true).catch(() => null) as BilateralHs4Payload | null; + if (!payload) return empty; + + return { + iso2, + products: Array.isArray(payload.products) ? payload.products : [], + fetchedAt: payload.fetchedAt ?? '', + }; +} diff --git a/server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts b/server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts new file mode 100644 index 000000000..5b4c9af25 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts @@ -0,0 +1,129 @@ +import type { + ServerContext, + GetMultiSectorCostShockRequest, + GetMultiSectorCostShockResponse, + ChokepointInfo, + MultiSectorCostShock, + WarRiskTier, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; +import { ValidationError } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { getCachedJson } from '../../../_shared/redis'; +import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry'; +import { CHOKEPOINT_STATUS_KEY } from '../../../_shared/cache-keys'; +import { + aggregateAnnualImportsByHs2, + clampClosureDays, + computeMultiSectorShocks, + MULTI_SECTOR_HS2_LABELS, + SEEDED_HS2_CODES, + type SeededProduct, +} from './_multi-sector-shock'; + +interface CountryProductsCache { + iso2: string; + products?: SeededProduct[]; + fetchedAt?: string; +} + +function emptySectorSkeleton(closureDays: number): MultiSectorCostShock[] { + return SEEDED_HS2_CODES.map(hs2 => ({ + hs2, + hs2Label: MULTI_SECTOR_HS2_LABELS[hs2] ?? `HS ${hs2}`, + importValueAnnual: 0, + freightAddedPctPerTon: 0, + warRiskPremiumBps: 0, + addedTransitDays: 0, + totalCostShockPerDay: 0, + totalCostShock30Days: 0, + totalCostShock90Days: 0, + totalCostShock: 0, + closureDays, + })); +} + +function emptyResponse( + iso2: string, + chokepointId: string, + closureDays: number, + warRiskTier: WarRiskTier = 'WAR_RISK_TIER_UNSPECIFIED', + unavailableReason = '', + sectors: MultiSectorCostShock[] = [], +): GetMultiSectorCostShockResponse { + return { + iso2, + chokepointId, + closureDays, + warRiskTier, + sectors, + totalAddedCost: 0, + fetchedAt: new Date().toISOString(), + unavailableReason, + }; +} + +export async function getMultiSectorCostShock( + ctx: ServerContext, + req: GetMultiSectorCostShockRequest, +): Promise { + const iso2 = (req.iso2 ?? '').trim().toUpperCase(); + const chokepointId = (req.chokepointId ?? '').trim().toLowerCase(); + const closureDays = clampClosureDays(req.closureDays ?? 30); + + // Input-shape errors return 400 — restoring the legacy /api/supply-chain/v1/ + // multi-sector-cost-shock contract. Empty-payload-200 is reserved for the + // PRO-gate deny path (intentional contract shift), not for caller bugs + // (malformed or missing fields). Distinguishing the two matters for external + // API consumers, tests, and silent-failure detection in logs. + if (!/^[A-Z]{2}$/.test(iso2)) { + throw new ValidationError([{ field: 'iso2', description: 'iso2 must be a 2-letter uppercase ISO country code' }]); + } + if (!chokepointId) { + throw new ValidationError([{ field: 'chokepointId', description: 'chokepointId is required' }]); + } + if (!CHOKEPOINT_REGISTRY.some(c => c.id === chokepointId)) { + throw new ValidationError([{ field: 'chokepointId', description: `Unknown chokepointId: ${chokepointId}` }]); + } + + const isPro = await isCallerPremium(ctx.request); + if (!isPro) return emptyResponse(iso2, chokepointId, closureDays); + + // Seeder writes the products payload via raw key (no env-prefix) — read raw. + const productsKey = `comtrade:bilateral-hs4:${iso2}:v1`; + const [productsCache, statusCache] = await Promise.all([ + getCachedJson(productsKey, true).catch(() => null) as Promise, + getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null) as Promise<{ chokepoints?: ChokepointInfo[] } | null>, + ]); + + const products = Array.isArray(productsCache?.products) ? productsCache.products : []; + const importsByHs2 = aggregateAnnualImportsByHs2(products); + const hasAnyImports = Object.values(importsByHs2).some(v => v > 0); + const warRiskTier = (statusCache?.chokepoints?.find(c => c.id === chokepointId)?.warRiskTier + ?? 'WAR_RISK_TIER_NORMAL') as WarRiskTier; + + if (!hasAnyImports) { + return emptyResponse( + iso2, + chokepointId, + closureDays, + warRiskTier, + 'No seeded import data available for this country', + emptySectorSkeleton(closureDays), + ); + } + + const sectors = computeMultiSectorShocks(importsByHs2, chokepointId, warRiskTier, closureDays); + const totalAddedCost = sectors.reduce((sum, s) => sum + s.totalCostShock, 0); + + return { + iso2, + chokepointId, + closureDays, + warRiskTier, + sectors, + totalAddedCost, + fetchedAt: new Date().toISOString(), + unavailableReason: '', + }; +} diff --git a/server/worldmonitor/supply-chain/v1/handler.ts b/server/worldmonitor/supply-chain/v1/handler.ts index ed9795e22..ddf70470f 100644 --- a/server/worldmonitor/supply-chain/v1/handler.ts +++ b/server/worldmonitor/supply-chain/v1/handler.ts @@ -8,6 +8,8 @@ import { getShippingStress } from './get-shipping-stress'; import { getCountryChokepointIndex } from './get-country-chokepoint-index'; import { getBypassOptions } from './get-bypass-options'; import { getCountryCostShock } from './get-country-cost-shock'; +import { getCountryProducts } from './get-country-products'; +import { getMultiSectorCostShock } from './get-multi-sector-cost-shock'; import { getSectorDependency } from './get-sector-dependency'; import { getRouteExplorerLane } from './get-route-explorer-lane'; import { getRouteImpact } from './get-route-impact'; @@ -21,6 +23,8 @@ export const supplyChainHandler: SupplyChainServiceHandler = { getCountryChokepointIndex, getBypassOptions, getCountryCostShock, + getCountryProducts, + getMultiSectorCostShock, getSectorDependency, getRouteExplorerLane, getRouteImpact, diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 07f82e7b3..bb89ddf7f 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -1212,10 +1212,14 @@ async function dispatch(requestUrl, req, routes, context) { } // Registration — call Convex directly when CONVEX_URL is available (self-hosted), // otherwise proxy to cloud (desktop sidecar never has CONVEX_URL). + // Keeps the legacy /api/register-interest local path so older desktop builds + // continue to work; cloud fallback rewrites to the new sebuf RPC path. if (requestUrl.pathname === '/api/register-interest' && req.method === 'POST') { const convexUrl = process.env.CONVEX_URL; if (!convexUrl) { - const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'no CONVEX_URL'); + const cloudUrl = new URL(requestUrl); + cloudUrl.pathname = '/api/leads/v1/register-interest'; + const cloudResponse = await tryCloudFallback(cloudUrl, req, context, 'no CONVEX_URL'); if (cloudResponse) return cloudResponse; return json({ error: 'Registration service unavailable' }, 503); } diff --git a/src/components/SupplyChainPanel.ts b/src/components/SupplyChainPanel.ts index 9212334d1..17cc0282f 100644 --- a/src/components/SupplyChainPanel.ts +++ b/src/components/SupplyChainPanel.ts @@ -17,7 +17,7 @@ import { isDesktopRuntime } from '@/services/runtime'; import { getAuthState, subscribeAuthState } from '@/services/auth-state'; import { hasPremiumAccess } from '@/services/panel-gating'; import { trackGateHit } from '@/services/analytics'; -import { premiumFetch } from '@/services/premium-fetch'; +import { runScenario, getScenarioStatus } from '@/services/scenario'; type TabId = 'chokepoints' | 'shipping' | 'indicators' | 'minerals' | 'stress'; @@ -847,14 +847,8 @@ export class SupplyChainPanel extends Panel { // Hard timeout on POST /run so a hanging edge function can't leave // the button in "Computing…" indefinitely. const runSignal = AbortSignal.any([signal, AbortSignal.timeout(20_000)]); - const runResp = await premiumFetch('/api/scenario/v1/run', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scenarioId }), - signal: runSignal, - }); - if (!runResp.ok) throw new Error(`Run failed: ${runResp.status}`); - const { jobId } = await runResp.json() as { jobId: string }; + const runResp = await runScenario({ scenarioId, iso2: '' }, { signal: runSignal }); + const jobId = runResp.jobId; let result: ScenarioResult | null = null; // 60 × 1s = 60s max (worker typically completes in <1s). 1s poll keeps // the perceived latency <2s in the common case. First iteration polls @@ -864,9 +858,7 @@ export class SupplyChainPanel extends Panel { if (signal.aborted) { resetButton('Simulate Closure'); return; } if (!this.content.isConnected) return; // panel gone — nothing to update if (i > 0) await new Promise(r => setTimeout(r, 1000)); - const statusResp = await premiumFetch(`/api/scenario/v1/status?jobId=${encodeURIComponent(jobId)}`, { signal }); - if (!statusResp.ok) throw new Error(`Status poll failed: ${statusResp.status}`); - const status = await statusResp.json() as { status: string; result?: ScenarioResult }; + const status = await getScenarioStatus(jobId, { signal }); if (status.status === 'done') { const r = status.result; if (!r || !Array.isArray(r.topImpactCountries)) throw new Error('done without valid result'); diff --git a/src/generated/client/worldmonitor/leads/v1/service_client.ts b/src/generated/client/worldmonitor/leads/v1/service_client.ts new file mode 100644 index 000000000..e1f01259f --- /dev/null +++ b/src/generated/client/worldmonitor/leads/v1/service_client.ts @@ -0,0 +1,149 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: worldmonitor/leads/v1/service.proto + +export interface SubmitContactRequest { + email: string; + name: string; + organization: string; + phone: string; + message: string; + source: string; + website: string; + turnstileToken: string; +} + +export interface SubmitContactResponse { + status: string; + emailSent: boolean; +} + +export interface RegisterInterestRequest { + email: string; + source: string; + appVersion: string; + referredBy: string; + website: string; + turnstileToken: string; +} + +export interface RegisterInterestResponse { + status: string; + referralCode: string; + referralCount: number; + position: number; + emailSuppressed: boolean; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface LeadsServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface LeadsServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class LeadsServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: LeadsServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async submitContact(req: SubmitContactRequest, options?: LeadsServiceCallOptions): Promise { + let path = "/api/leads/v1/submit-contact"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(req), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as SubmitContactResponse; + } + + async registerInterest(req: RegisterInterestRequest, options?: LeadsServiceCallOptions): Promise { + let path = "/api/leads/v1/register-interest"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(req), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as RegisterInterestResponse; + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/src/generated/client/worldmonitor/maritime/v1/service_client.ts b/src/generated/client/worldmonitor/maritime/v1/service_client.ts index 6ecdea60c..4dcf5bd9e 100644 --- a/src/generated/client/worldmonitor/maritime/v1/service_client.ts +++ b/src/generated/client/worldmonitor/maritime/v1/service_client.ts @@ -7,6 +7,7 @@ export interface GetVesselSnapshotRequest { neLon: number; swLat: number; swLon: number; + includeCandidates: boolean; } export interface GetVesselSnapshotResponse { @@ -17,6 +18,9 @@ export interface VesselSnapshot { snapshotAt: number; densityZones: AisDensityZone[]; disruptions: AisDisruption[]; + sequence: number; + status?: AisSnapshotStatus; + candidateReports: SnapshotCandidateReport[]; } export interface AisDensityZone { @@ -48,6 +52,24 @@ export interface AisDisruption { description: string; } +export interface AisSnapshotStatus { + connected: boolean; + vessels: number; + messages: number; +} + +export interface SnapshotCandidateReport { + mmsi: string; + name: string; + lat: number; + lon: number; + shipType: number; + heading: number; + speed: number; + course: number; + timestamp: number; +} + export interface ListNavigationalWarningsRequest { pageSize: number; cursor: string; @@ -134,6 +156,7 @@ export class MaritimeServiceClient { if (req.neLon != null && req.neLon !== 0) params.set("ne_lon", String(req.neLon)); if (req.swLat != null && req.swLat !== 0) params.set("sw_lat", String(req.swLat)); if (req.swLon != null && req.swLon !== 0) params.set("sw_lon", String(req.swLon)); + if (req.includeCandidates) params.set("include_candidates", String(req.includeCandidates)); const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); const headers: Record = { diff --git a/src/generated/client/worldmonitor/scenario/v1/service_client.ts b/src/generated/client/worldmonitor/scenario/v1/service_client.ts new file mode 100644 index 000000000..b61d4b530 --- /dev/null +++ b/src/generated/client/worldmonitor/scenario/v1/service_client.ts @@ -0,0 +1,197 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: worldmonitor/scenario/v1/service.proto + +export interface RunScenarioRequest { + scenarioId: string; + iso2: string; +} + +export interface RunScenarioResponse { + jobId: string; + status: string; + statusUrl: string; +} + +export interface GetScenarioStatusRequest { + jobId: string; +} + +export interface GetScenarioStatusResponse { + status: string; + result?: ScenarioResult; + error: string; +} + +export interface ScenarioResult { + affectedChokepointIds: string[]; + topImpactCountries: ScenarioImpactCountry[]; + template?: ScenarioResultTemplate; +} + +export interface ScenarioImpactCountry { + iso2: string; + totalImpact: number; + impactPct: number; +} + +export interface ScenarioResultTemplate { + name: string; + disruptionPct: number; + durationDays: number; + costShockMultiplier: number; +} + +export interface ListScenarioTemplatesRequest { +} + +export interface ListScenarioTemplatesResponse { + templates: ScenarioTemplate[]; +} + +export interface ScenarioTemplate { + id: string; + name: string; + affectedChokepointIds: string[]; + disruptionPct: number; + durationDays: number; + affectedHs2: string[]; + costShockMultiplier: number; +} + +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 ScenarioServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface ScenarioServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class ScenarioServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: ScenarioServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async runScenario(req: RunScenarioRequest, options?: ScenarioServiceCallOptions): Promise { + let path = "/api/scenario/v1/run-scenario"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(req), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as RunScenarioResponse; + } + + async getScenarioStatus(req: GetScenarioStatusRequest, options?: ScenarioServiceCallOptions): Promise { + let path = "/api/scenario/v1/get-scenario-status"; + const params = new URLSearchParams(); + if (req.jobId != null && req.jobId !== "") params.set("jobId", String(req.jobId)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as GetScenarioStatusResponse; + } + + async listScenarioTemplates(req: ListScenarioTemplatesRequest, options?: ScenarioServiceCallOptions): Promise { + let path = "/api/scenario/v1/list-scenario-templates"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as ListScenarioTemplatesResponse; + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/src/generated/client/worldmonitor/shipping/v2/service_client.ts b/src/generated/client/worldmonitor/shipping/v2/service_client.ts new file mode 100644 index 000000000..e92a7a458 --- /dev/null +++ b/src/generated/client/worldmonitor/shipping/v2/service_client.ts @@ -0,0 +1,205 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-client. DO NOT EDIT. +// source: worldmonitor/shipping/v2/service.proto + +export interface RouteIntelligenceRequest { + fromIso2: string; + toIso2: string; + cargoType: string; + hs2: string; +} + +export interface RouteIntelligenceResponse { + fromIso2: string; + toIso2: string; + cargoType: string; + hs2: string; + primaryRouteId: string; + chokepointExposures: ChokepointExposure[]; + bypassOptions: BypassOption[]; + warRiskTier: string; + disruptionScore: number; + fetchedAt: string; +} + +export interface ChokepointExposure { + chokepointId: string; + chokepointName: string; + exposurePct: number; +} + +export interface BypassOption { + id: string; + name: string; + type: string; + addedTransitDays: number; + addedCostMultiplier: number; + activationThreshold: string; +} + +export interface RegisterWebhookRequest { + callbackUrl: string; + chokepointIds: string[]; + alertThreshold: number; +} + +export interface RegisterWebhookResponse { + subscriberId: string; + secret: string; +} + +export interface ListWebhooksRequest { +} + +export interface ListWebhooksResponse { + webhooks: WebhookSummary[]; +} + +export interface WebhookSummary { + subscriberId: string; + callbackUrl: string; + chokepointIds: string[]; + alertThreshold: number; + createdAt: string; + active: 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 ShippingV2ServiceClientOptions { + fetch?: typeof fetch; + defaultHeaders?: Record; +} + +export interface ShippingV2ServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class ShippingV2ServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: ShippingV2ServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async routeIntelligence(req: RouteIntelligenceRequest, options?: ShippingV2ServiceCallOptions): Promise { + let path = "/api/v2/shipping/route-intelligence"; + const params = new URLSearchParams(); + if (req.fromIso2 != null && req.fromIso2 !== "") params.set("fromIso2", String(req.fromIso2)); + if (req.toIso2 != null && req.toIso2 !== "") params.set("toIso2", String(req.toIso2)); + if (req.cargoType != null && req.cargoType !== "") params.set("cargoType", String(req.cargoType)); + if (req.hs2 != null && req.hs2 !== "") params.set("hs2", String(req.hs2)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as RouteIntelligenceResponse; + } + + async registerWebhook(req: RegisterWebhookRequest, options?: ShippingV2ServiceCallOptions): Promise { + let path = "/api/v2/shipping/webhooks"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(req), + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as RegisterWebhookResponse; + } + + async listWebhooks(req: ListWebhooksRequest, options?: ShippingV2ServiceCallOptions): Promise { + let path = "/api/v2/shipping/webhooks"; + const url = this.baseURL + path; + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as ListWebhooksResponse; + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} + diff --git a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts index 325371fde..3dc7c8eaa 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -229,6 +229,62 @@ export interface GetCountryCostShockResponse { fetchedAt: string; } +export interface GetCountryProductsRequest { + iso2: string; +} + +export interface GetCountryProductsResponse { + iso2: string; + products: CountryProduct[]; + fetchedAt: string; +} + +export interface CountryProduct { + hs4: string; + description: string; + totalValue: number; + topExporters: ProductExporter[]; + year: number; +} + +export interface ProductExporter { + partnerCode: number; + partnerIso2: string; + value: number; + share: number; +} + +export interface GetMultiSectorCostShockRequest { + iso2: string; + chokepointId: string; + closureDays: number; +} + +export interface GetMultiSectorCostShockResponse { + iso2: string; + chokepointId: string; + closureDays: number; + warRiskTier: WarRiskTier; + sectors: MultiSectorCostShock[]; + totalAddedCost: number; + fetchedAt: string; + unavailableReason: string; +} + +export interface MultiSectorCostShock { + hs2: string; + hs2Label: string; + importValueAnnual: number; + freightAddedPctPerTon: number; + warRiskPremiumBps: number; + addedTransitDays: number; + totalCostShockPerDay: number; + totalCostShock30Days: number; + totalCostShock90Days: number; + totalCostShock: number; + closureDays: number; +} + export interface GetSectorDependencyRequest { iso2: string; hs2: string; @@ -577,6 +633,58 @@ export class SupplyChainServiceClient { return await resp.json() as GetCountryCostShockResponse; } + async getCountryProducts(req: GetCountryProductsRequest, options?: SupplyChainServiceCallOptions): Promise { + let path = "/api/supply-chain/v1/get-country-products"; + const params = new URLSearchParams(); + if (req.iso2 != null && req.iso2 !== "") params.set("iso2", String(req.iso2)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as GetCountryProductsResponse; + } + + async getMultiSectorCostShock(req: GetMultiSectorCostShockRequest, options?: SupplyChainServiceCallOptions): Promise { + let path = "/api/supply-chain/v1/get-multi-sector-cost-shock"; + const params = new URLSearchParams(); + if (req.iso2 != null && req.iso2 !== "") params.set("iso2", String(req.iso2)); + if (req.chokepointId != null && req.chokepointId !== "") params.set("chokepointId", String(req.chokepointId)); + if (req.closureDays != null && req.closureDays !== 0) params.set("closureDays", String(req.closureDays)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as GetMultiSectorCostShockResponse; + } + async getSectorDependency(req: GetSectorDependencyRequest, options?: SupplyChainServiceCallOptions): Promise { let path = "/api/supply-chain/v1/get-sector-dependency"; const params = new URLSearchParams(); diff --git a/src/generated/server/worldmonitor/leads/v1/service_server.ts b/src/generated/server/worldmonitor/leads/v1/service_server.ts new file mode 100644 index 000000000..0daad2c2b --- /dev/null +++ b/src/generated/server/worldmonitor/leads/v1/service_server.ts @@ -0,0 +1,180 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/leads/v1/service.proto + +export interface SubmitContactRequest { + email: string; + name: string; + organization: string; + phone: string; + message: string; + source: string; + website: string; + turnstileToken: string; +} + +export interface SubmitContactResponse { + status: string; + emailSent: boolean; +} + +export interface RegisterInterestRequest { + email: string; + source: string; + appVersion: string; + referredBy: string; + website: string; + turnstileToken: string; +} + +export interface RegisterInterestResponse { + status: string; + referralCode: string; + referralCount: number; + position: number; + emailSuppressed: boolean; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface LeadsServiceHandler { + submitContact(ctx: ServerContext, req: SubmitContactRequest): Promise; + registerInterest(ctx: ServerContext, req: RegisterInterestRequest): Promise; +} + +export function createLeadsServiceRoutes( + handler: LeadsServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/leads/v1/submit-contact", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as SubmitContactRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("submitContact", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.submitContact(ctx, body); + return new Response(JSON.stringify(result as SubmitContactResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + { + method: "POST", + path: "/api/leads/v1/register-interest", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as RegisterInterestRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("registerInterest", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.registerInterest(ctx, body); + return new Response(JSON.stringify(result as RegisterInterestResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/src/generated/server/worldmonitor/maritime/v1/service_server.ts b/src/generated/server/worldmonitor/maritime/v1/service_server.ts index 9887cca52..267f0c05b 100644 --- a/src/generated/server/worldmonitor/maritime/v1/service_server.ts +++ b/src/generated/server/worldmonitor/maritime/v1/service_server.ts @@ -7,6 +7,7 @@ export interface GetVesselSnapshotRequest { neLon: number; swLat: number; swLon: number; + includeCandidates: boolean; } export interface GetVesselSnapshotResponse { @@ -17,6 +18,9 @@ export interface VesselSnapshot { snapshotAt: number; densityZones: AisDensityZone[]; disruptions: AisDisruption[]; + sequence: number; + status?: AisSnapshotStatus; + candidateReports: SnapshotCandidateReport[]; } export interface AisDensityZone { @@ -48,6 +52,24 @@ export interface AisDisruption { description: string; } +export interface AisSnapshotStatus { + connected: boolean; + vessels: number; + messages: number; +} + +export interface SnapshotCandidateReport { + mmsi: string; + name: string; + lat: number; + lon: number; + shipType: number; + heading: number; + speed: number; + course: number; + timestamp: number; +} + export interface ListNavigationalWarningsRequest { pageSize: number; cursor: string; @@ -146,6 +168,7 @@ export function createMaritimeServiceRoutes( neLon: Number(params.get("ne_lon") ?? "0"), swLat: Number(params.get("sw_lat") ?? "0"), swLon: Number(params.get("sw_lon") ?? "0"), + includeCandidates: params.get("include_candidates") === "true", }; if (options?.validateRequest) { const bodyViolations = options.validateRequest("getVesselSnapshot", body); diff --git a/src/generated/server/worldmonitor/scenario/v1/service_server.ts b/src/generated/server/worldmonitor/scenario/v1/service_server.ts new file mode 100644 index 000000000..c234150b2 --- /dev/null +++ b/src/generated/server/worldmonitor/scenario/v1/service_server.ts @@ -0,0 +1,246 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/scenario/v1/service.proto + +export interface RunScenarioRequest { + scenarioId: string; + iso2: string; +} + +export interface RunScenarioResponse { + jobId: string; + status: string; + statusUrl: string; +} + +export interface GetScenarioStatusRequest { + jobId: string; +} + +export interface GetScenarioStatusResponse { + status: string; + result?: ScenarioResult; + error: string; +} + +export interface ScenarioResult { + affectedChokepointIds: string[]; + topImpactCountries: ScenarioImpactCountry[]; + template?: ScenarioResultTemplate; +} + +export interface ScenarioImpactCountry { + iso2: string; + totalImpact: number; + impactPct: number; +} + +export interface ScenarioResultTemplate { + name: string; + disruptionPct: number; + durationDays: number; + costShockMultiplier: number; +} + +export interface ListScenarioTemplatesRequest { +} + +export interface ListScenarioTemplatesResponse { + templates: ScenarioTemplate[]; +} + +export interface ScenarioTemplate { + id: string; + name: string; + affectedChokepointIds: string[]; + disruptionPct: number; + durationDays: number; + affectedHs2: string[]; + costShockMultiplier: number; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface ScenarioServiceHandler { + runScenario(ctx: ServerContext, req: RunScenarioRequest): Promise; + getScenarioStatus(ctx: ServerContext, req: GetScenarioStatusRequest): Promise; + listScenarioTemplates(ctx: ServerContext, req: ListScenarioTemplatesRequest): Promise; +} + +export function createScenarioServiceRoutes( + handler: ScenarioServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/scenario/v1/run-scenario", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as RunScenarioRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("runScenario", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.runScenario(ctx, body); + return new Response(JSON.stringify(result as RunScenarioResponse), { + 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: "GET", + path: "/api/scenario/v1/get-scenario-status", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: GetScenarioStatusRequest = { + jobId: params.get("jobId") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getScenarioStatus", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getScenarioStatus(ctx, body); + return new Response(JSON.stringify(result as GetScenarioStatusResponse), { + 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: "GET", + path: "/api/scenario/v1/list-scenario-templates", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = {} as ListScenarioTemplatesRequest; + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.listScenarioTemplates(ctx, body); + return new Response(JSON.stringify(result as ListScenarioTemplatesResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/src/generated/server/worldmonitor/shipping/v2/service_server.ts b/src/generated/server/worldmonitor/shipping/v2/service_server.ts new file mode 100644 index 000000000..772d36353 --- /dev/null +++ b/src/generated/server/worldmonitor/shipping/v2/service_server.ts @@ -0,0 +1,254 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/shipping/v2/service.proto + +export interface RouteIntelligenceRequest { + fromIso2: string; + toIso2: string; + cargoType: string; + hs2: string; +} + +export interface RouteIntelligenceResponse { + fromIso2: string; + toIso2: string; + cargoType: string; + hs2: string; + primaryRouteId: string; + chokepointExposures: ChokepointExposure[]; + bypassOptions: BypassOption[]; + warRiskTier: string; + disruptionScore: number; + fetchedAt: string; +} + +export interface ChokepointExposure { + chokepointId: string; + chokepointName: string; + exposurePct: number; +} + +export interface BypassOption { + id: string; + name: string; + type: string; + addedTransitDays: number; + addedCostMultiplier: number; + activationThreshold: string; +} + +export interface RegisterWebhookRequest { + callbackUrl: string; + chokepointIds: string[]; + alertThreshold: number; +} + +export interface RegisterWebhookResponse { + subscriberId: string; + secret: string; +} + +export interface ListWebhooksRequest { +} + +export interface ListWebhooksResponse { + webhooks: WebhookSummary[]; +} + +export interface WebhookSummary { + subscriberId: string; + callbackUrl: string; + chokepointIds: string[]; + alertThreshold: number; + createdAt: string; + active: boolean; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface ShippingV2ServiceHandler { + routeIntelligence(ctx: ServerContext, req: RouteIntelligenceRequest): Promise; + registerWebhook(ctx: ServerContext, req: RegisterWebhookRequest): Promise; + listWebhooks(ctx: ServerContext, req: ListWebhooksRequest): Promise; +} + +export function createShippingV2ServiceRoutes( + handler: ShippingV2ServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/v2/shipping/route-intelligence", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: RouteIntelligenceRequest = { + fromIso2: params.get("fromIso2") ?? "", + toIso2: params.get("toIso2") ?? "", + cargoType: params.get("cargoType") ?? "", + hs2: params.get("hs2") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("routeIntelligence", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.routeIntelligence(ctx, body); + return new Response(JSON.stringify(result as RouteIntelligenceResponse), { + 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/v2/shipping/webhooks", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as RegisterWebhookRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("registerWebhook", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.registerWebhook(ctx, body); + return new Response(JSON.stringify(result as RegisterWebhookResponse), { + 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: "GET", + path: "/api/v2/shipping/webhooks", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = {} as ListWebhooksRequest; + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.listWebhooks(ctx, body); + return new Response(JSON.stringify(result as ListWebhooksResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} + diff --git a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts index c332da78d..c028e49df 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -229,6 +229,62 @@ export interface GetCountryCostShockResponse { fetchedAt: string; } +export interface GetCountryProductsRequest { + iso2: string; +} + +export interface GetCountryProductsResponse { + iso2: string; + products: CountryProduct[]; + fetchedAt: string; +} + +export interface CountryProduct { + hs4: string; + description: string; + totalValue: number; + topExporters: ProductExporter[]; + year: number; +} + +export interface ProductExporter { + partnerCode: number; + partnerIso2: string; + value: number; + share: number; +} + +export interface GetMultiSectorCostShockRequest { + iso2: string; + chokepointId: string; + closureDays: number; +} + +export interface GetMultiSectorCostShockResponse { + iso2: string; + chokepointId: string; + closureDays: number; + warRiskTier: WarRiskTier; + sectors: MultiSectorCostShock[]; + totalAddedCost: number; + fetchedAt: string; + unavailableReason: string; +} + +export interface MultiSectorCostShock { + hs2: string; + hs2Label: string; + importValueAnnual: number; + freightAddedPctPerTon: number; + warRiskPremiumBps: number; + addedTransitDays: number; + totalCostShockPerDay: number; + totalCostShock30Days: number; + totalCostShock90Days: number; + totalCostShock: number; + closureDays: number; +} + export interface GetSectorDependencyRequest { iso2: string; hs2: string; @@ -385,6 +441,8 @@ export interface SupplyChainServiceHandler { getCountryChokepointIndex(ctx: ServerContext, req: GetCountryChokepointIndexRequest): Promise; getBypassOptions(ctx: ServerContext, req: GetBypassOptionsRequest): Promise; getCountryCostShock(ctx: ServerContext, req: GetCountryCostShockRequest): Promise; + getCountryProducts(ctx: ServerContext, req: GetCountryProductsRequest): Promise; + getMultiSectorCostShock(ctx: ServerContext, req: GetMultiSectorCostShockRequest): Promise; getSectorDependency(ctx: ServerContext, req: GetSectorDependencyRequest): Promise; getRouteExplorerLane(ctx: ServerContext, req: GetRouteExplorerLaneRequest): Promise; getRouteImpact(ctx: ServerContext, req: GetRouteImpactRequest): Promise; @@ -736,6 +794,102 @@ export function createSupplyChainServiceRoutes( } }, }, + { + method: "GET", + path: "/api/supply-chain/v1/get-country-products", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: GetCountryProductsRequest = { + iso2: params.get("iso2") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getCountryProducts", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getCountryProducts(ctx, body); + return new Response(JSON.stringify(result as GetCountryProductsResponse), { + 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: "GET", + path: "/api/supply-chain/v1/get-multi-sector-cost-shock", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: GetMultiSectorCostShockRequest = { + iso2: params.get("iso2") ?? "", + chokepointId: params.get("chokepointId") ?? "", + closureDays: Number(params.get("closureDays") ?? "0"), + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getMultiSectorCostShock", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getMultiSectorCostShock(ctx, body); + return new Response(JSON.stringify(result as GetMultiSectorCostShockResponse), { + 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: "GET", path: "/api/supply-chain/v1/get-sector-dependency", diff --git a/src/services/maritime/index.ts b/src/services/maritime/index.ts index 7bb435e1a..4eccc6a23 100644 --- a/src/services/maritime/index.ts +++ b/src/services/maritime/index.ts @@ -4,14 +4,13 @@ import { type AisDensityZone as ProtoDensityZone, type AisDisruption as ProtoDisruption, type GetVesselSnapshotResponse, + type SnapshotCandidateReport as ProtoCandidateReport, } from '@/generated/client/worldmonitor/maritime/v1/service_client'; import { createCircuitBreaker } from '@/utils'; import type { AisDisruptionEvent, AisDensityZone, AisDisruptionType } from '@/types'; import { dataFreshness } from '../data-freshness'; import { isFeatureAvailable } from '../runtime-config'; -import { startSmartPollLoop, toApiUrl, type SmartPollLoopHandle } from '../runtime'; - -// ---- Proto fallback (desktop safety when relay URL is unavailable) ---- +import { startSmartPollLoop, type SmartPollLoopHandle } from '../runtime'; const client = new MaritimeServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); const snapshotBreaker = createCircuitBreaker({ name: 'Maritime Snapshot', cacheTtlMs: 10 * 60 * 1000, persistCache: true }); @@ -28,14 +27,24 @@ const SEVERITY_REVERSE: Record = { AIS_DISRUPTION_SEVERITY_HIGH: 'high', }; -function toDisruptionEvent(proto: ProtoDisruption): AisDisruptionEvent { +/** + * Convert a proto disruption to the app shape. Returns null when either enum + * is UNSPECIFIED / unknown — the legacy silent fallbacks mislabeled unknown + * values as `gap_spike` / `low`, which would have polluted the dashboard the + * first time the proto adds a new enum value the client doesn't know about. + * Filtering at the mapping boundary is safer than shipping wrong data. + */ +function toDisruptionEvent(proto: ProtoDisruption): AisDisruptionEvent | null { + const type = DISRUPTION_TYPE_REVERSE[proto.type]; + const severity = SEVERITY_REVERSE[proto.severity]; + if (!type || !severity) return null; return { id: proto.id, name: proto.name, - type: DISRUPTION_TYPE_REVERSE[proto.type] || 'gap_spike', + type, lat: proto.location?.latitude ?? 0, lon: proto.location?.longitude ?? 0, - severity: SEVERITY_REVERSE[proto.severity] || 'low', + severity, changePct: proto.changePct, windowHours: proto.windowHours, darkShips: proto.darkShips, @@ -58,6 +67,20 @@ function toDensityZone(proto: ProtoDensityZone): AisDensityZone { }; } +function toLegacyCandidateReport(proto: ProtoCandidateReport): SnapshotCandidateReport { + return { + mmsi: proto.mmsi, + name: proto.name, + lat: proto.lat, + lon: proto.lon, + shipType: proto.shipType || undefined, + heading: proto.heading || undefined, + speed: proto.speed || undefined, + course: proto.course || undefined, + timestamp: proto.timestamp, + }; +} + // ---- Feature Gating ---- const isClientRuntime = typeof window !== 'undefined'; @@ -92,19 +115,6 @@ interface SnapshotCandidateReport extends AisPositionData { timestamp: number; } -interface AisSnapshotResponse { - sequence?: number; - timestamp?: string; - status?: { - connected?: boolean; - vessels?: number; - messages?: number; - }; - disruptions?: AisDisruptionEvent[]; - density?: AisDensityZone[]; - candidateReports?: SnapshotCandidateReport[]; -} - // ---- Callback System ---- type AisCallback = (data: AisPositionData) => void; @@ -134,103 +144,47 @@ const SNAPSHOT_STALE_MS = 6 * 60 * 1000; const CALLBACK_RETENTION_MS = 2 * 60 * 60 * 1000; // 2 hours const MAX_CALLBACK_TRACKED_VESSELS = 20000; -// ---- Raw Relay URL (for candidate reports path) ---- - -const SNAPSHOT_PROXY_URL = toApiUrl('/api/ais-snapshot'); -const wsRelayUrl = import.meta.env.VITE_WS_RELAY_URL || ''; -const DIRECT_RAILWAY_SNAPSHOT_URL = wsRelayUrl - ? wsRelayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, '') + '/ais/snapshot' - : ''; -const LOCAL_SNAPSHOT_FALLBACK = 'http://localhost:3004/ais/snapshot'; -const isLocalhost = isClientRuntime && window.location.hostname === 'localhost'; - // ---- Internal Helpers ---- function shouldIncludeCandidates(): boolean { return positionCallbacks.size > 0; } -function parseSnapshot(data: unknown): { +interface ParsedSnapshot { sequence: number; status: SnapshotStatus; disruptions: AisDisruptionEvent[]; density: AisDensityZone[]; candidateReports: SnapshotCandidateReport[]; -} | null { - if (!data || typeof data !== 'object') return null; - const raw = data as AisSnapshotResponse; +} - if (!Array.isArray(raw.disruptions) || !Array.isArray(raw.density)) return null; +async function fetchSnapshotPayload(includeCandidates: boolean, signal?: AbortSignal): Promise { + const response = await snapshotBreaker.execute( + async () => client.getVesselSnapshot( + { neLat: 0, neLon: 0, swLat: 0, swLon: 0, includeCandidates }, + { signal }, + ), + emptySnapshotFallback, + ); + + const snapshot = response.snapshot; + if (!snapshot) return null; - const status = raw.status || {}; return { - sequence: Number.isFinite(raw.sequence as number) ? Number(raw.sequence) : 0, + sequence: snapshot.sequence, status: { - connected: Boolean(status.connected), - vessels: Number.isFinite(status.vessels as number) ? Number(status.vessels) : 0, - messages: Number.isFinite(status.messages as number) ? Number(status.messages) : 0, + connected: snapshot.status?.connected ?? false, + vessels: snapshot.status?.vessels ?? 0, + messages: snapshot.status?.messages ?? 0, }, - disruptions: raw.disruptions, - density: raw.density, - candidateReports: Array.isArray(raw.candidateReports) ? raw.candidateReports : [], + disruptions: snapshot.disruptions + .map(toDisruptionEvent) + .filter((e): e is AisDisruptionEvent => e !== null), + density: snapshot.densityZones.map(toDensityZone), + candidateReports: snapshot.candidateReports.map(toLegacyCandidateReport), }; } -// ---- Hybrid Fetch Strategy ---- - -async function fetchRawRelaySnapshot(includeCandidates: boolean, signal?: AbortSignal): Promise { - const query = `?candidates=${includeCandidates ? 'true' : 'false'}`; - - try { - const proxied = await fetch(`${SNAPSHOT_PROXY_URL}${query}`, { headers: { Accept: 'application/json' }, signal }); - if (proxied.ok) return proxied.json(); - } catch { /* Proxy unavailable -- fall through */ } - - // Local development fallback only. - if (isLocalhost && DIRECT_RAILWAY_SNAPSHOT_URL) { - try { - const railway = await fetch(`${DIRECT_RAILWAY_SNAPSHOT_URL}${query}`, { headers: { Accept: 'application/json' }, signal }); - if (railway.ok) return railway.json(); - } catch { /* Railway unavailable -- fall through */ } - } - - if (isLocalhost) { - const local = await fetch(`${LOCAL_SNAPSHOT_FALLBACK}${query}`, { headers: { Accept: 'application/json' }, signal }); - if (local.ok) return local.json(); - } - - throw new Error('AIS raw relay snapshot unavailable'); -} - -async function fetchSnapshotPayload(includeCandidates: boolean, signal?: AbortSignal): Promise { - if (includeCandidates) { - // Candidate reports are only available on the raw relay endpoint. - return fetchRawRelaySnapshot(true, signal); - } - - try { - // Prefer direct relay path to avoid normal web traffic double-hop via Vercel. - return await fetchRawRelaySnapshot(false, signal); - } catch (rawError) { - // Desktop fallback: use proto route when relay URL/local relay is unavailable. - const response = await snapshotBreaker.execute(async () => { - return client.getVesselSnapshot({ neLat: 0, neLon: 0, swLat: 0, swLon: 0 }); - }, emptySnapshotFallback); - - if (response.snapshot) { - return { - sequence: 0, // Proto payload does not include relay sequence. - status: { connected: true, vessels: 0, messages: 0 }, - disruptions: response.snapshot.disruptions.map(toDisruptionEvent), - density: response.snapshot.densityZones.map(toDensityZone), - candidateReports: [], - }; - } - - throw rawError; - } -} - // ---- Callback Emission ---- function pruneCallbackTimestampIndex(now: number): void { @@ -304,8 +258,7 @@ async function pollSnapshot(force = false, signal?: AbortSignal): Promise inFlight = true; try { const includeCandidates = shouldIncludeCandidates(); - const payload = await fetchSnapshotPayload(includeCandidates, signal); - const snapshot = parseSnapshot(payload); + const snapshot = await fetchSnapshotPayload(includeCandidates, signal); if (!snapshot) throw new Error('Invalid snapshot payload'); latestDisruptions = snapshot.disruptions; diff --git a/src/services/military-flights.ts b/src/services/military-flights.ts index 010c00f7c..2a1c88e7d 100644 --- a/src/services/military-flights.ts +++ b/src/services/military-flights.ts @@ -9,6 +9,13 @@ import { MILITARY_QUERY_REGIONS, } from '@/config/military'; import type { QueryRegion } from '@/config/military'; +import { + MilitaryServiceClient, + type MilitaryFlight as ProtoMilitaryFlight, + type MilitaryAircraftType as ProtoMilitaryAircraftType, + type MilitaryOperator as ProtoMilitaryOperator, +} from '@/generated/client/worldmonitor/military/v1/service_client'; +import { getRpcBaseUrl } from '@/services/rpc-client'; import { getAircraftDetailsBatch, analyzeAircraftDetails, @@ -17,6 +24,46 @@ import { import { isFeatureAvailable } from './runtime-config'; import { isDesktopRuntime, toApiUrl } from './runtime'; +const militaryClient = new MilitaryServiceClient(getRpcBaseUrl(), { + fetch: (...args) => globalThis.fetch(...args), +}); + +const AIRCRAFT_TYPE_REVERSE: Partial> = { + MILITARY_AIRCRAFT_TYPE_FIGHTER: 'fighter', + MILITARY_AIRCRAFT_TYPE_BOMBER: 'bomber', + MILITARY_AIRCRAFT_TYPE_TRANSPORT: 'transport', + MILITARY_AIRCRAFT_TYPE_TANKER: 'tanker', + MILITARY_AIRCRAFT_TYPE_AWACS: 'awacs', + MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE: 'reconnaissance', + MILITARY_AIRCRAFT_TYPE_HELICOPTER: 'helicopter', + MILITARY_AIRCRAFT_TYPE_DRONE: 'drone', + MILITARY_AIRCRAFT_TYPE_PATROL: 'patrol', + MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS: 'special_ops', + MILITARY_AIRCRAFT_TYPE_VIP: 'vip', +}; + +const OPERATOR_REVERSE: Partial> = { + MILITARY_OPERATOR_USAF: 'usaf', + MILITARY_OPERATOR_USN: 'usn', + MILITARY_OPERATOR_USMC: 'usmc', + MILITARY_OPERATOR_USA: 'usa', + MILITARY_OPERATOR_RAF: 'raf', + MILITARY_OPERATOR_RN: 'rn', + MILITARY_OPERATOR_FAF: 'faf', + MILITARY_OPERATOR_GAF: 'gaf', + MILITARY_OPERATOR_PLAAF: 'plaaf', + MILITARY_OPERATOR_PLAN: 'plan', + MILITARY_OPERATOR_VKS: 'vks', + MILITARY_OPERATOR_IAF: 'iaf', + MILITARY_OPERATOR_NATO: 'nato', +}; + +const CONFIDENCE_REVERSE: Record = { + MILITARY_CONFIDENCE_HIGH: 'high', + MILITARY_CONFIDENCE_MEDIUM: 'medium', + MILITARY_CONFIDENCE_LOW: 'low', +}; + // Desktop: direct OpenSky proxy path (relay or Vercel) const OPENSKY_PROXY_URL = toApiUrl('/api/opensky'); const wsRelayUrl = import.meta.env.VITE_WS_RELAY_URL || ''; @@ -69,69 +116,92 @@ const breaker = createCircuitBreaker<{ flights: MilitaryFlight[]; clusters: Mili }), }); -interface MilitaryFlightsResponse { - flights: Array<{ - id: string; - callsign: string; - hexCode: string; - lat: number; - lon: number; - altitude: number; - heading: number; - speed: number; - verticalRate?: number; - onGround: boolean; - squawk?: string; - aircraftType: MilitaryAircraftType; - operator: MilitaryOperator; - operatorCountry: string; - confidence: 'high' | 'medium' | 'low'; - isInteresting?: boolean; - note?: string; - lastSeenMs: number; - }>; - fetchedAt: number; - stats: { total: number; byType: Record }; +function mapProtoFlight(pf: ProtoMilitaryFlight, nowDate: Date): MilitaryFlight | null { + const lat = pf.location?.latitude; + const lon = pf.location?.longitude; + if (lat == null || lon == null) return null; + + const positions = upsertFlightHistory(pf.hexCode.toLowerCase(), lat, lon); + + return { + id: pf.id, + callsign: pf.callsign, + hexCode: pf.hexCode, + registration: pf.registration || undefined, + aircraftType: AIRCRAFT_TYPE_REVERSE[pf.aircraftType] || 'unknown', + aircraftModel: pf.aircraftModel || undefined, + operator: OPERATOR_REVERSE[pf.operator] || 'other', + operatorCountry: pf.operatorCountry, + lat, + lon, + altitude: pf.altitude, + heading: pf.heading, + speed: pf.speed, + verticalRate: pf.verticalRate || undefined, + onGround: pf.onGround, + squawk: pf.squawk || undefined, + origin: pf.origin || undefined, + destination: pf.destination || undefined, + lastSeen: pf.lastSeenAt ? new Date(pf.lastSeenAt) : nowDate, + firstSeen: pf.firstSeenAt ? new Date(pf.firstSeenAt) : undefined, + track: positions.length > 1 ? [...positions] : undefined, + confidence: CONFIDENCE_REVERSE[pf.confidence] || 'low', + isInteresting: pf.isInteresting || undefined, + note: pf.note || undefined, + enriched: pf.enrichment ? { + manufacturer: pf.enrichment.manufacturer || undefined, + owner: pf.enrichment.owner || undefined, + operatorName: pf.enrichment.operatorName || undefined, + typeCode: pf.enrichment.typeCode || undefined, + builtYear: pf.enrichment.builtYear || undefined, + confirmedMilitary: pf.enrichment.confirmedMilitary, + militaryBranch: pf.enrichment.militaryBranch || undefined, + } : undefined, + }; } -async function fetchFromRedis(): Promise { - const resp = await fetch(toApiUrl('/api/military-flights'), { - headers: { Accept: 'application/json' }, - }); - if (!resp.ok) { - throw new Error(`military-flights API ${resp.status}`); +async function fetchViaProto(): Promise { + // Iterate the same PACIFIC/WESTERN regions the server-side seed cron uses + // so dashboard coverage matches the analytic pipeline. The proto handler + // caches per-bbox, so parallel region calls warm independent cache keys. + const results = await Promise.all( + MILITARY_QUERY_REGIONS.map(async (region) => { + try { + const resp = await militaryClient.listMilitaryFlights({ + pageSize: 0, + cursor: '', + neLat: region.lamax, + neLon: region.lomax, + swLat: region.lamin, + swLon: region.lomin, + operator: '' as ProtoMilitaryOperator, + aircraftType: '' as ProtoMilitaryAircraftType, + }); + return resp.flights ?? []; + } catch { + return []; + } + }), + ); + + const now = new Date(); + const seen = new Set(); + const flights: MilitaryFlight[] = []; + + for (const regionFlights of results) { + for (const pf of regionFlights) { + if (seen.has(pf.hexCode)) continue; + seen.add(pf.hexCode); + const mapped = mapProtoFlight(pf, now); + if (mapped) flights.push(mapped); + } } - const data: MilitaryFlightsResponse = await resp.json(); - if (!data.flights || data.flights.length === 0) { + + if (flights.length === 0) { throw new Error('No flights returned — upstream may be down'); } - const now = new Date(); - return data.flights.map((f) => { - const positions = upsertFlightHistory(f.hexCode.toLowerCase(), f.lat, f.lon); - - return { - id: f.id, - callsign: f.callsign, - hexCode: f.hexCode, - aircraftType: f.aircraftType, - operator: f.operator, - operatorCountry: f.operatorCountry, - lat: f.lat, - lon: f.lon, - altitude: f.altitude, - heading: f.heading, - speed: f.speed, - verticalRate: f.verticalRate, - onGround: f.onGround, - squawk: f.squawk, - lastSeen: f.lastSeenMs ? new Date(f.lastSeenMs) : now, - track: positions.length > 1 ? [...positions] : undefined, - confidence: f.confidence, - isInteresting: f.isInteresting, - note: f.note, - } satisfies MilitaryFlight; - }); + return flights; } // ─── Desktop-only: OpenSky direct path ──────────────────────── @@ -444,7 +514,7 @@ export async function fetchMilitaryFlights(): Promise<{ return { flights: flightCache.data, clusters }; } - let flights = desktop ? await fetchFromOpenSky() : await fetchFromRedis(); + let flights = desktop ? await fetchFromOpenSky() : await fetchViaProto(); if (flights.length === 0) { throw new Error('No flights returned — upstream may be down'); diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 2e68b43d2..86c221ff1 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -546,7 +546,10 @@ function isLocalOnlyApiTarget(target: string): boolean { } function isKeyFreeApiTarget(target: string): boolean { - return target.startsWith('/api/register-interest') || target.startsWith('/api/version'); + return target.startsWith('/api/register-interest') + || target.startsWith('/api/leads/v1/register-interest') + || target.startsWith('/api/leads/v1/submit-contact') + || target.startsWith('/api/version'); } async function fetchLocalWithStartupRetry( diff --git a/src/services/satellites.ts b/src/services/satellites.ts index 0a9533277..9b94665c3 100644 --- a/src/services/satellites.ts +++ b/src/services/satellites.ts @@ -11,10 +11,13 @@ // - Historical Pass Log: which sats passed over a location in the last 24h // (useful for identifying imaging windows after events) -import { toApiUrl } from '@/services/runtime'; +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { IntelligenceServiceClient } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; import { twoline2satrec, propagate, eciToGeodetic, gstime, degreesLong, degreesLat } from 'satellite.js'; import type { SatRec } from 'satellite.js'; +const intelligenceClient = new IntelligenceServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); + export interface SatelliteTLE { noradId: string; name: string; @@ -57,13 +60,25 @@ export async function fetchSatelliteTLEs(): Promise { if (cachedData && now - cachedAt < CACHE_TTL) return cachedData; try { - const resp = await fetch(toApiUrl('/api/satellites'), { - signal: AbortSignal.timeout(20_000), - }); - if (!resp.ok) return cachedData; - - const raw = await resp.json(); - const satellites = (raw.satellites ?? []) as SatelliteTLE[]; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20_000); + let resp; + try { + resp = await intelligenceClient.listSatellites({ country: '' }, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + // Proto returns `id` (the NORAD identifier); local SatelliteTLE uses `noradId`. + // `alt`/`velocity`/`inclination` in the proto are unused by the propagation + // client — we compute them ourselves from the TLE via satellite.js. + const satellites: SatelliteTLE[] = (resp.satellites ?? []).map((s) => ({ + noradId: s.id, + name: s.name, + line1: s.line1, + line2: s.line2, + type: s.type, + country: s.country, + })); cachedData = satellites; cachedAt = now; failures = 0; diff --git a/src/services/scenario/index.ts b/src/services/scenario/index.ts new file mode 100644 index 000000000..6ffcd6fac --- /dev/null +++ b/src/services/scenario/index.ts @@ -0,0 +1,56 @@ +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { premiumFetch } from '@/services/premium-fetch'; +import { + ScenarioServiceClient, + type RunScenarioRequest, + type RunScenarioResponse, + type GetScenarioStatusResponse, + type ListScenarioTemplatesResponse, + type ScenarioResult, + type ScenarioImpactCountry, + type ScenarioResultTemplate, + type ScenarioTemplate, +} from '@/generated/client/worldmonitor/scenario/v1/service_client'; + +export type { + RunScenarioRequest, + RunScenarioResponse, + GetScenarioStatusResponse, + ListScenarioTemplatesResponse, + ScenarioResult, + ScenarioImpactCountry, + ScenarioResultTemplate, + ScenarioTemplate, +}; + +// RunScenario + GetScenarioStatus are PRO-gated — premiumFetch injects the +// Clerk Bearer token / API key. ListScenarioTemplates is public but harmless +// to route through the same fetch wrapper. +const client = new ScenarioServiceClient(getRpcBaseUrl(), { fetch: premiumFetch }); + +/** + * Enqueue a scenario job and return the resulting job id. Server validates + * scenarioId against the template registry (400 on unknown) and enforces + * per-IP 10/min rate limiting at the gateway. + */ +export async function runScenario( + req: RunScenarioRequest, + options?: { signal?: AbortSignal }, +): Promise { + return client.runScenario(req, { signal: options?.signal }); +} + +/** + * Poll a scenario job's lifecycle state. Returns status ∈ + * {pending, processing, done, failed}; result is populated only when done. + */ +export async function getScenarioStatus( + jobId: string, + options?: { signal?: AbortSignal }, +): Promise { + return client.getScenarioStatus({ jobId }, { signal: options?.signal }); +} + +export async function listScenarioTemplates(): Promise { + return client.listScenarioTemplates({}); +} diff --git a/src/services/supply-chain/index.ts b/src/services/supply-chain/index.ts index 89a24f5c1..0a4dc18f5 100644 --- a/src/services/supply-chain/index.ts +++ b/src/services/supply-chain/index.ts @@ -1,4 +1,5 @@ import { getRpcBaseUrl } from '@/services/rpc-client'; +import { premiumFetch } from '@/services/premium-fetch'; import type { CargoType } from '@/config/bypass-corridors'; import { SupplyChainServiceClient, @@ -10,6 +11,8 @@ import { type GetCountryChokepointIndexResponse, type GetBypassOptionsResponse, type GetCountryCostShockResponse, + type GetCountryProductsResponse, + type GetMultiSectorCostShockResponse, type GetSectorDependencyResponse, type GetRouteExplorerLaneResponse, type GetRouteImpactResponse, @@ -21,6 +24,9 @@ import { type ChokepointExposureEntry, type BypassOption, type TransitDayCount, + type CountryProduct, + type ProductExporter, + type MultiSectorCostShock, } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import { createCircuitBreaker } from '@/utils'; import { getHydratedData } from '@/services/bootstrap'; @@ -34,6 +40,8 @@ export type { GetCountryChokepointIndexResponse, GetBypassOptionsResponse, GetCountryCostShockResponse, + GetCountryProductsResponse, + GetMultiSectorCostShockResponse, GetSectorDependencyResponse, GetRouteExplorerLaneResponse, GetRouteImpactResponse, @@ -45,9 +53,26 @@ export type { ChokepointExposureEntry, BypassOption, TransitDayCount, + CountryProduct, + ProductExporter, + MultiSectorCostShock, }; -const client = new SupplyChainServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); +// Legacy aliases consumed by CountryBriefPanel + CountryDeepDivePanel — match the +// proto-generated shapes exactly so callsites compile without churn. +export type CountryProductsResponse = GetCountryProductsResponse; +export type MultiSectorShockResponse = GetMultiSectorCostShockResponse; +export type MultiSectorShock = MultiSectorCostShock; + +// premiumFetch for the whole client: 8 of 13 methods target paths in +// PREMIUM_RPC_PATHS. The gateway runs validateApiKey with forceKey=true on +// those paths *before* isCallerPremium; globalThis.fetch here would 401 for +// signed-in browser pros (no Clerk bearer / no WM key injected) and the +// generated client's try/catch would swallow the 401, returning the empty +// fallbacks below. premiumFetch no-ops safely when no credentials are +// available, so the 5 non-premium methods (shippingRates, chokepointStatus, +// chokepointHistory, criticalMinerals, shippingStress) keep working as before. +const client = new SupplyChainServiceClient(getRpcBaseUrl(), { fetch: premiumFetch }); const shippingBreaker = createCircuitBreaker({ name: 'Shipping Rates', cacheTtlMs: 60 * 60 * 1000, persistCache: true }); const chokepointBreaker = createCircuitBreaker({ name: 'Chokepoint Status', cacheTtlMs: 90 * 60 * 1000, persistCache: true }); @@ -307,70 +332,17 @@ export async function fetchRouteImpact( } } -export interface ProductExporter { - partnerCode: number; - partnerIso2: string; - value: number; - share: number; -} +const emptyProducts: GetCountryProductsResponse = { iso2: '', products: [], fetchedAt: '' }; -export interface CountryProduct { - hs4: string; - description: string; - totalValue: number; - topExporters: ProductExporter[]; - year: number; -} - -export interface CountryProductsResponse { - iso2: string; - products: CountryProduct[]; - fetchedAt: string; -} - -const emptyProducts: CountryProductsResponse = { iso2: '', products: [], fetchedAt: '' }; - -export async function fetchCountryProducts(iso2: string): Promise { +export async function fetchCountryProducts(iso2: string): Promise { try { - const { premiumFetch } = await import('@/services/premium-fetch'); - const { toApiUrl } = await import('@/services/runtime'); - const resp = await premiumFetch( - toApiUrl(`/api/supply-chain/v1/country-products?iso2=${encodeURIComponent(iso2)}`), - ); - if (!resp.ok) return { ...emptyProducts, iso2 }; - return await resp.json() as CountryProductsResponse; + return await client.getCountryProducts({ iso2 }); } catch { return { ...emptyProducts, iso2 }; } } -export interface MultiSectorShock { - hs2: string; - hs2Label: string; - importValueAnnual: number; - freightAddedPctPerTon: number; - warRiskPremiumBps: number; - addedTransitDays: number; - totalCostShockPerDay: number; - totalCostShock30Days: number; - totalCostShock90Days: number; - /** Cost for the currently-requested closure duration (server-clamped). */ - totalCostShock: number; - closureDays: number; -} - -export interface MultiSectorShockResponse { - iso2: string; - chokepointId: string; - closureDays: number; - warRiskTier: string; - sectors: MultiSectorShock[]; - totalAddedCost: number; - fetchedAt: string; - unavailableReason: string; -} - -const emptyMultiSectorShock: MultiSectorShockResponse = { +const emptyMultiSectorShock: GetMultiSectorCostShockResponse = { iso2: '', chokepointId: '', closureDays: 30, @@ -383,25 +355,19 @@ const emptyMultiSectorShock: MultiSectorShockResponse = { /** * Fetch multi-sector cost shock for a country+chokepoint+closureDays window. - * PRO-gated: non-premium callers receive HTTP 403 and this function returns an empty response. + * PRO-gated: non-premium callers get an empty payload from the handler. */ export async function fetchMultiSectorCostShock( iso2: string, chokepointId: string, closureDays: number, options?: { signal?: AbortSignal }, -): Promise { +): Promise { try { - const { premiumFetch } = await import('@/services/premium-fetch'); - const { toApiUrl } = await import('@/services/runtime'); - const url = toApiUrl( - `/api/supply-chain/v1/multi-sector-cost-shock?iso2=${encodeURIComponent(iso2)}` - + `&chokepointId=${encodeURIComponent(chokepointId)}` - + `&closureDays=${encodeURIComponent(String(closureDays))}`, + return await client.getMultiSectorCostShock( + { iso2, chokepointId, closureDays }, + { signal: options?.signal }, ); - const resp = await premiumFetch(url, { signal: options?.signal }); - if (!resp.ok) return { ...emptyMultiSectorShock, iso2, chokepointId, closureDays }; - return await resp.json() as MultiSectorShockResponse; } catch { return { ...emptyMultiSectorShock, iso2, chokepointId, closureDays }; } diff --git a/src/shared/premium-paths.ts b/src/shared/premium-paths.ts index ab0c703f0..f81742df3 100644 --- a/src/shared/premium-paths.ts +++ b/src/shared/premium-paths.ts @@ -22,12 +22,15 @@ export const PREMIUM_RPC_PATHS = new Set([ '/api/supply-chain/v1/get-country-cost-shock', '/api/supply-chain/v1/get-route-explorer-lane', '/api/supply-chain/v1/get-route-impact', + '/api/supply-chain/v1/get-country-products', + '/api/supply-chain/v1/get-multi-sector-cost-shock', '/api/supply-chain/v1/get-sector-dependency', - '/api/supply-chain/v1/multi-sector-cost-shock', '/api/economic/v1/get-national-debt', '/api/sanctions/v1/list-sanctions-pressure', '/api/trade/v1/list-comtrade-flows', '/api/trade/v1/get-tariff-trends', - '/api/scenario/v1/run', - '/api/scenario/v1/status', + '/api/scenario/v1/run-scenario', + '/api/scenario/v1/get-scenario-status', + '/api/v2/shipping/route-intelligence', + '/api/v2/shipping/webhooks', ]); diff --git a/tests/api-eia-petroleum.test.mjs b/tests/api-eia-petroleum.test.mjs deleted file mode 100644 index fb800de16..000000000 --- a/tests/api-eia-petroleum.test.mjs +++ /dev/null @@ -1,113 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it, beforeEach, afterEach } from 'node:test'; - -const originalFetch = globalThis.fetch; -const originalEnv = { ...process.env }; - -const SAMPLE_PAYLOAD = { - wti: { current: 76.23, previous: 75.10, date: '2026-04-11', unit: 'dollars per barrel' }, - brent: { current: 81.02, previous: 80.44, date: '2026-04-11', unit: 'dollars per barrel' }, - production: { current: 13100, previous: 13050, date: '2026-04-11', unit: 'MBBL' }, - inventory: { current: 458_100, previous: 459_200, date: '2026-04-11', unit: 'MBBL' }, -}; - -const ENVELOPE = { - _seed: { - fetchedAt: 1_700_000_000_000, - recordCount: 4, - sourceVersion: 'eia-petroleum-v1', - schemaVersion: 1, - state: 'OK', - }, - data: SAMPLE_PAYLOAD, -}; - -function makeRequest(path, opts = {}) { - return new Request(`https://worldmonitor.app/api/eia${path}`, { - method: opts.method || 'GET', - headers: { origin: 'https://worldmonitor.app', ...(opts.headers || {}) }, - }); -} - -let handler; - -describe('api/eia/[[...path]] — petroleum reader', () => { - beforeEach(async () => { - process.env.UPSTASH_REDIS_REST_URL = 'https://fake-upstash.io'; - process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token'; - const mod = await import(`../api/eia/%5B%5B...path%5D%5D.js?t=${Date.now()}`); - handler = mod.default; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - Object.keys(process.env).forEach(k => { - if (!(k in originalEnv)) delete process.env[k]; - }); - Object.assign(process.env, originalEnv); - }); - - it('OPTIONS returns 204 with CORS headers', async () => { - const res = await handler(makeRequest('/petroleum', { method: 'OPTIONS' })); - assert.equal(res.status, 204); - assert.ok(res.headers.get('access-control-allow-origin')); - }); - - it('disallowed origin returns 403', async () => { - const res = await handler(makeRequest('/petroleum', { headers: { origin: 'https://evil.example' } })); - assert.equal(res.status, 403); - }); - - it('non-GET returns 405', async () => { - const res = await handler(makeRequest('/petroleum', { method: 'POST' })); - assert.equal(res.status, 405); - }); - - it('/health returns configured:true', async () => { - const res = await handler(makeRequest('/health')); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body.configured, true); - }); - - it('/petroleum returns 200 with data on Upstash hit (envelope unwrapped)', async () => { - globalThis.fetch = async (url) => { - assert.match(String(url), /fake-upstash\.io\/get\/energy%3Aeia-petroleum%3Av1/); - return new Response(JSON.stringify({ result: JSON.stringify(ENVELOPE) }), { status: 200 }); - }; - const res = await handler(makeRequest('/petroleum')); - assert.equal(res.status, 200); - const body = await res.json(); - assert.deepEqual(body, SAMPLE_PAYLOAD); - assert.match(res.headers.get('cache-control') || '', /max-age=1800/); - assert.match(res.headers.get('cache-control') || '', /stale-while-revalidate=86400/); - }); - - it('/petroleum returns 503 with hint when Redis key is missing', async () => { - globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 }); - const res = await handler(makeRequest('/petroleum')); - assert.equal(res.status, 503); - const body = await res.json(); - assert.match(body.error, /not yet seeded/i); - assert.ok(body.hint); - assert.equal(res.headers.get('cache-control'), 'no-store'); - assert.equal(res.headers.get('retry-after'), '300'); - }); - - it('/petroleum returns 503 (not 504) when Upstash itself errors', async () => { - globalThis.fetch = async () => new Response('bad gateway', { status: 502 }); - const res = await handler(makeRequest('/petroleum')); - assert.equal(res.status, 503); - }); - - it('/petroleum returns 503 when Upstash throws', async () => { - globalThis.fetch = async () => { throw new Error('connection refused'); }; - const res = await handler(makeRequest('/petroleum')); - assert.equal(res.status, 503); - }); - - it('unknown path returns 404', async () => { - const res = await handler(makeRequest('/unknown')); - assert.equal(res.status, 404); - }); -}); diff --git a/tests/comtrade-bilateral-hs4.test.mjs b/tests/comtrade-bilateral-hs4.test.mjs index 1aec47c5b..8d134c87d 100644 --- a/tests/comtrade-bilateral-hs4.test.mjs +++ b/tests/comtrade-bilateral-hs4.test.mjs @@ -5,130 +5,58 @@ import assert from 'node:assert/strict'; const root = join(import.meta.dirname, '..'); -// ─── Edge endpoint ────────────────────────────────────────────────────────── +// ─── sebuf handler ─────────────────────────────────────────────────────────── -describe('Country products endpoint (api/supply-chain/v1/country-products.ts)', () => { - const filePath = join(root, 'api', 'supply-chain', 'v1', 'country-products.ts'); +describe('getCountryProducts sebuf handler (server/worldmonitor/supply-chain/v1/get-country-products.ts)', () => { + const filePath = join(root, 'server', 'worldmonitor', 'supply-chain', 'v1', 'get-country-products.ts'); const src = readFileSync(filePath, 'utf-8'); - it('exports edge config with runtime: edge', () => { + it('exports getCountryProducts as the sebuf handler entry point', () => { assert.ok( - src.includes("runtime: 'edge'"), - 'country-products.ts: must export edge config with runtime: "edge"', + /export\s+async\s+function\s+getCountryProducts/.test(src), + 'must export an async getCountryProducts(ctx, req) handler', ); }); - it('has a default export handler function', () => { - assert.ok( - /export\s+default\s+async\s+function\s+handler/.test(src), - 'country-products.ts: must have a default async function handler export', - ); - }); - - it('returns 405 for non-GET requests', () => { - assert.ok( - src.includes("req.method !== 'GET'") || src.includes('req.method !== "GET"'), - 'country-products.ts: must check for GET method and return 405 for other methods', - ); - assert.ok( - src.includes('status: 405'), - 'country-products.ts: must return 405 status for non-GET', - ); - }); - - it('validates iso2 parameter with /^[A-Z]{2}$/ pattern', () => { + it('validates iso2 with the /^[A-Z]{2}$/ pattern', () => { assert.ok( src.includes('[A-Z]{2}'), - 'country-products.ts: must validate iso2 with a two-uppercase-letter regex', + 'must validate iso2 with a two-uppercase-letter regex', ); }); - it('returns 400 for invalid iso2', () => { - assert.ok( - src.includes('status: 400'), - 'country-products.ts: must return 400 for invalid or missing iso2', - ); - }); - - it('uses isCallerPremium for PRO gating', () => { + it('uses isCallerPremium for PRO gating against ctx.request', () => { assert.ok( src.includes('isCallerPremium'), - 'country-products.ts: must use isCallerPremium for PRO-gating', + 'must use isCallerPremium for PRO-gating', ); - const importIdx = src.indexOf('isCallerPremium'); - const callIdx = src.indexOf('isCallerPremium(req)'); assert.ok( - importIdx !== -1 && callIdx !== -1, - 'country-products.ts: must import and invoke isCallerPremium(req)', + src.includes('isCallerPremium(ctx.request)'), + 'must invoke isCallerPremium(ctx.request) so the sebuf gateway request is authorised', ); }); - it('returns 403 for non-PRO users', () => { + it('returns the typed empty payload for both non-PRO and invalid-iso2 paths', () => { assert.ok( - src.includes('status: 403'), - 'country-products.ts: must return 403 for non-PRO callers', + /products: \[\], fetchedAt: ''/.test(src), + 'empty fallback must have empty products array and empty fetchedAt', ); + const proIdx = src.indexOf('isPro'); + const validIdx = src.indexOf('[A-Z]{2}'); + assert.ok(proIdx !== -1 && validIdx !== -1, 'must reference both PRO and validation gates'); + }); + + it('reads from raw Upstash Redis (skip env-prefix) so seeder writes resolve', () => { assert.ok( - src.includes('PRO subscription required'), - 'country-products.ts: 403 response must include descriptive error message', + /getCachedJson\([^,]+,\s*true\)/.test(src), + 'must call getCachedJson(key, true) so the raw seeder key is read', ); }); - it('uses private Cache-Control (not public) for successful responses', () => { + it('reads the comtrade:bilateral-hs4 key keyed by iso2', () => { assert.ok( - src.includes("'Cache-Control': 'private"), - 'country-products.ts: Cache-Control for PRO data must be private, not public', - ); - assert.ok( - !src.includes("'Cache-Control': 'public"), - 'country-products.ts: must not use public Cache-Control for PRO-gated data', - ); - }); - - it('Vary header includes Authorization', () => { - assert.ok( - src.includes("'Vary'") || src.includes('"Vary"'), - 'country-products.ts: must include Vary header', - ); - assert.ok( - src.includes('Authorization'), - 'country-products.ts: Vary header must include Authorization for PRO-gated responses', - ); - }); - - it('non-PRO/empty-data path uses no-store cache control', () => { - assert.ok( - src.includes('no-store'), - 'country-products.ts: empty data / fallback path must use no-store cache control', - ); - }); - - it('reads from Upstash Redis via readJsonFromUpstash', () => { - assert.ok( - src.includes('readJsonFromUpstash'), - 'country-products.ts: must read cached data from Upstash Redis', - ); - }); - - it('passes a timeout to readJsonFromUpstash', () => { - const match = src.match(/readJsonFromUpstash\(\s*key\s*,\s*(\d[\d_]*)\s*\)/); - assert.ok( - match, - 'country-products.ts: must pass a timeout parameter to readJsonFromUpstash to bound Redis reads', - ); - const timeout = Number(match[1].replace(/_/g, '')); - assert.ok( - timeout > 0 && timeout <= 10_000, - `country-products.ts: readJsonFromUpstash timeout should be reasonable (got ${timeout}ms)`, - ); - }); - - it('iso2 validation happens after PRO gate (prevents free users probing keys)', () => { - const proIdx = src.indexOf('isCallerPremium'); - const isoIdx = src.indexOf('[A-Z]{2}'); - assert.ok( - proIdx < isoIdx, - 'country-products.ts: PRO gate must come before iso2 validation to prevent free users probing parameter patterns', + /comtrade:bilateral-hs4:\$\{iso2\}:v1/.test(src), + 'must read comtrade:bilateral-hs4:${iso2}:v1', ); }); }); @@ -345,37 +273,37 @@ describe('fetchCountryProducts service (src/services/supply-chain/index.ts)', () ); }); - it('CountryProductsResponse type is exported', () => { + it('CountryProductsResponse alias is exported for legacy callsites', () => { assert.ok( - src.includes('export interface CountryProductsResponse'), - 'supply-chain/index.ts: must export CountryProductsResponse interface', + src.includes('export type CountryProductsResponse = GetCountryProductsResponse'), + 'supply-chain/index.ts: must export CountryProductsResponse as alias of GetCountryProductsResponse', ); }); - it('CountryProduct type is exported', () => { + it('CountryProduct type is re-exported from the generated client', () => { assert.ok( - src.includes('export interface CountryProduct'), - 'supply-chain/index.ts: must export CountryProduct interface', + /export type \{[\s\S]*?\bCountryProduct\b/.test(src), + 'supply-chain/index.ts: must re-export CountryProduct from the generated sebuf client', ); }); - it('ProductExporter type is exported', () => { + it('ProductExporter type is re-exported from the generated client', () => { assert.ok( - src.includes('export interface ProductExporter'), - 'supply-chain/index.ts: must export ProductExporter interface', + /export type \{[\s\S]*?\bProductExporter\b/.test(src), + 'supply-chain/index.ts: must re-export ProductExporter from the generated sebuf client', ); }); - it('uses premiumFetch (not plain fetch) for PRO-gated data', () => { + it('calls the generated sebuf client.getCountryProducts (not premiumFetch)', () => { const fnStart = src.indexOf('async function fetchCountryProducts'); const fnBody = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3); assert.ok( - fnBody.includes('premiumFetch'), - 'fetchCountryProducts: must use premiumFetch to attach auth credentials', + fnBody.includes('client.getCountryProducts('), + 'fetchCountryProducts: must call the generated client.getCountryProducts', ); assert.ok( - !fnBody.includes('globalThis.fetch('), - 'fetchCountryProducts: must not use globalThis.fetch directly for PRO endpoints', + !fnBody.includes('premiumFetch'), + 'fetchCountryProducts: must not bypass the typed client with premiumFetch', ); }); @@ -396,10 +324,15 @@ describe('fetchCountryProducts service (src/services/supply-chain/index.ts)', () ); }); - it('CountryProduct interface has expected fields', () => { - const ifaceStart = src.indexOf('export interface CountryProduct'); - const ifaceEnd = src.indexOf('}', ifaceStart); - const iface = src.slice(ifaceStart, ifaceEnd + 1); + it('CountryProduct generated interface has expected fields', () => { + const generated = readFileSync( + join(root, 'src', 'generated', 'client', 'worldmonitor', 'supply_chain', 'v1', 'service_client.ts'), + 'utf-8', + ); + const ifaceStart = generated.indexOf('export interface CountryProduct'); + assert.ok(ifaceStart !== -1, 'generated client must define CountryProduct interface'); + const ifaceEnd = generated.indexOf('}', ifaceStart); + const iface = generated.slice(ifaceStart, ifaceEnd + 1); assert.ok(iface.includes('hs4: string'), 'CountryProduct must have hs4: string'); assert.ok(iface.includes('description: string'), 'CountryProduct must have description: string'); assert.ok(iface.includes('totalValue: number'), 'CountryProduct must have totalValue: number'); diff --git a/tests/contact-handler.test.mjs b/tests/contact-handler.test.mjs index 11ac21de1..0541b3434 100644 --- a/tests/contact-handler.test.mjs +++ b/tests/contact-handler.test.mjs @@ -1,192 +1,187 @@ +/** + * Functional tests for LeadsService.SubmitContact handler. + * Tests the typed handler directly (not the HTTP gateway). + */ + import { strict as assert } from 'node:assert'; -import { describe, it, beforeEach, afterEach, mock } from 'node:test'; +import { describe, it, beforeEach, afterEach } from 'node:test'; const originalFetch = globalThis.fetch; const originalEnv = { ...process.env }; -function makeRequest(body, opts = {}) { - return new Request('https://worldmonitor.app/api/contact', { - method: opts.method || 'POST', - headers: { - 'Content-Type': 'application/json', - 'origin': 'https://worldmonitor.app', - ...(opts.headers || {}), - }, - body: body ? JSON.stringify(body) : undefined, +function makeCtx(headers = {}) { + const req = new Request('https://worldmonitor.app/api/leads/v1/submit-contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, }); + return { request: req, pathParams: {}, headers }; } -function validBody(overrides = {}) { +function validReq(overrides = {}) { return { - name: 'Test User', email: 'test@example.com', + name: 'Test User', organization: 'TestCorp', phone: '+1 555 123 4567', message: 'Hello', source: 'enterprise-contact', + website: '', turnstileToken: 'valid-token', ...overrides, }; } -let handler; +let submitContact; +let ValidationError; +let ApiError; -describe('api/contact', () => { +describe('LeadsService.submitContact', () => { beforeEach(async () => { process.env.CONVEX_URL = 'https://fake-convex.cloud'; process.env.TURNSTILE_SECRET_KEY = 'test-secret'; process.env.RESEND_API_KEY = 'test-resend-key'; process.env.VERCEL_ENV = 'production'; - // Re-import to get fresh module state (rate limiter) - const mod = await import(`../api/contact.js?t=${Date.now()}`); - handler = mod.default; + // Handler + error classes share one module instance so `instanceof` works. + const mod = await import('../server/worldmonitor/leads/v1/submit-contact.ts'); + submitContact = mod.submitContact; + const gen = await import('../src/generated/server/worldmonitor/leads/v1/service_server.ts'); + ValidationError = gen.ValidationError; + ApiError = gen.ApiError; }); afterEach(() => { globalThis.fetch = originalFetch; - Object.keys(process.env).forEach(k => { + Object.keys(process.env).forEach((k) => { if (!(k in originalEnv)) delete process.env[k]; }); Object.assign(process.env, originalEnv); }); describe('validation', () => { - it('rejects GET requests', async () => { - const res = await handler(new Request('https://worldmonitor.app/api/contact', { - method: 'GET', - headers: { origin: 'https://worldmonitor.app' }, - })); - assert.equal(res.status, 405); - }); - - it('rejects missing email', async () => { + it('rejects missing email with ValidationError', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ email: '' }))); - assert.equal(res.status, 400); - const data = await res.json(); - assert.match(data.error, /email/i); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ email: '' })), + (err) => err instanceof ValidationError && err.violations[0].field === 'email', + ); }); it('rejects invalid email format', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ email: 'not-an-email' }))); - assert.equal(res.status, 400); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ email: 'not-an-email' })), + (err) => err instanceof ValidationError, + ); }); it('rejects missing name', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ name: '' }))); - assert.equal(res.status, 400); - const data = await res.json(); - assert.match(data.error, /name/i); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ name: '' })), + (err) => err instanceof ValidationError && err.violations[0].field === 'name', + ); }); - it('rejects free email domains with 422', async () => { + it('rejects free email domains with 422 ApiError', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ email: 'test@gmail.com' }))); - assert.equal(res.status, 422); - const data = await res.json(); - assert.match(data.error, /work email/i); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ email: 'test@gmail.com' })), + (err) => err instanceof ApiError && err.statusCode === 422 && /work email/i.test(err.message), + ); }); it('rejects missing organization', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ organization: '' }))); - assert.equal(res.status, 400); - const data = await res.json(); - assert.match(data.error, /company/i); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ organization: '' })), + (err) => err instanceof ValidationError && err.violations[0].field === 'organization', + ); }); it('rejects missing phone', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ phone: '' }))); - assert.equal(res.status, 400); - const data = await res.json(); - assert.match(data.error, /phone/i); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ phone: '' })), + (err) => err instanceof ValidationError && err.violations[0].field === 'phone', + ); }); it('rejects invalid phone format', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody({ phone: '(((((' }))); - assert.equal(res.status, 400); + await assert.rejects( + () => submitContact(makeCtx(), validReq({ phone: '(((((' })), + (err) => err instanceof ValidationError, + ); }); - it('rejects disallowed origins', async () => { - const req = new Request('https://worldmonitor.app/api/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json', origin: 'https://evil.com' }, - body: JSON.stringify(validBody()), - }); - const res = await handler(req); - assert.equal(res.status, 403); - }); - - it('silently accepts honeypot submissions', async () => { - const res = await handler(makeRequest(validBody({ website: 'http://spam.com' }))); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.status, 'sent'); + it('silently accepts honeypot submissions without calling upstreams', async () => { + let fetchCalled = false; + globalThis.fetch = async () => { fetchCalled = true; return new Response('{}'); }; + const res = await submitContact(makeCtx(), validReq({ website: 'http://spam.com' })); + assert.equal(res.status, 'sent'); + assert.equal(res.emailSent, false); + assert.equal(fetchCalled, false); }); }); describe('Turnstile handling', () => { it('rejects when Turnstile verification fails', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) { + if (typeof url === 'string' && url.includes('turnstile')) { return new Response(JSON.stringify({ success: false })); } return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 403); - const data = await res.json(); - assert.match(data.error, /bot/i); + await assert.rejects( + () => submitContact(makeCtx(), validReq()), + (err) => err instanceof ApiError && err.statusCode === 403 && /bot/i.test(err.message), + ); }); it('rejects in production when TURNSTILE_SECRET_KEY is unset', async () => { delete process.env.TURNSTILE_SECRET_KEY; process.env.VERCEL_ENV = 'production'; globalThis.fetch = async () => new Response('{}'); - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 403); + await assert.rejects( + () => submitContact(makeCtx(), validReq()), + (err) => err instanceof ApiError && err.statusCode === 403, + ); }); it('allows in development when TURNSTILE_SECRET_KEY is unset', async () => { delete process.env.TURNSTILE_SECRET_KEY; process.env.VERCEL_ENV = 'development'; - let convexCalled = false; - globalThis.fetch = async (url, _opts) => { - if (url.includes('fake-convex')) { - convexCalled = true; + globalThis.fetch = async (url) => { + if (typeof url === 'string' && url.includes('fake-convex')) { return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); } - if (url.includes('resend')) return new Response(JSON.stringify({ id: '1' })); + if (typeof url === 'string' && url.includes('resend')) return new Response(JSON.stringify({ id: '1' })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 200); + const res = await submitContact(makeCtx(), validReq()); + assert.equal(res.status, 'sent'); }); }); @@ -194,79 +189,72 @@ describe('api/contact', () => { it('returns emailSent: false when RESEND_API_KEY is missing', async () => { delete process.env.RESEND_API_KEY; globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); - if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.status, 'sent'); - assert.equal(data.emailSent, false); + const res = await submitContact(makeCtx(), validReq()); + assert.equal(res.status, 'sent'); + assert.equal(res.emailSent, false); }); it('returns emailSent: false when Resend API returns error', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); - if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); - if (url.includes('resend')) return new Response('Rate limited', { status: 429 }); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); + if (typeof url === 'string' && url.includes('resend')) return new Response('Rate limited', { status: 429 }); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.status, 'sent'); - assert.equal(data.emailSent, false); + const res = await submitContact(makeCtx(), validReq()); + assert.equal(res.status, 'sent'); + assert.equal(res.emailSent, false); }); it('returns emailSent: true on successful notification', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); - if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); - if (url.includes('resend')) return new Response(JSON.stringify({ id: 'msg_123' })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); + if (typeof url === 'string' && url.includes('resend')) return new Response(JSON.stringify({ id: 'msg_123' })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.status, 'sent'); - assert.equal(data.emailSent, true); + const res = await submitContact(makeCtx(), validReq()); + assert.equal(res.status, 'sent'); + assert.equal(res.emailSent, true); }); it('still succeeds (stores in Convex) even when email fails', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); - if (url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); - if (url.includes('resend')) throw new Error('Network failure'); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('fake-convex')) return new Response(JSON.stringify({ status: 'success', value: { status: 'sent' } })); + if (typeof url === 'string' && url.includes('resend')) throw new Error('Network failure'); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.status, 'sent'); - assert.equal(data.emailSent, false); + const res = await submitContact(makeCtx(), validReq()); + assert.equal(res.status, 'sent'); + assert.equal(res.emailSent, false); }); }); describe('Convex storage', () => { - it('returns 503 when CONVEX_URL is missing', async () => { + it('throws 503 ApiError when CONVEX_URL is missing', async () => { delete process.env.CONVEX_URL; globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 503); + await assert.rejects( + () => submitContact(makeCtx(), validReq()), + (err) => err instanceof ApiError && err.statusCode === 503, + ); }); - it('returns 500 when Convex mutation fails', async () => { + it('propagates Convex failure', async () => { globalThis.fetch = async (url) => { - if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); - if (url.includes('fake-convex')) return new Response('Internal error', { status: 500 }); + if (typeof url === 'string' && url.includes('turnstile')) return new Response(JSON.stringify({ success: true })); + if (typeof url === 'string' && url.includes('fake-convex')) return new Response('Internal error', { status: 500 }); return new Response('{}'); }; - const res = await handler(makeRequest(validBody())); - assert.equal(res.status, 500); + await assert.rejects(() => submitContact(makeCtx(), validReq())); }); }); }); diff --git a/tests/edge-functions.test.mjs b/tests/edge-functions.test.mjs index 0e0facec8..bc5d7c9c5 100644 --- a/tests/edge-functions.test.mjs +++ b/tests/edge-functions.test.mjs @@ -92,60 +92,10 @@ describe('Edge Function no node: built-ins', () => { } }); -describe('Legacy api/*.js endpoint allowlist', () => { - const ALLOWED_LEGACY_ENDPOINTS = new Set([ - 'ais-snapshot.js', - 'bootstrap.js', - 'cache-purge.js', - 'contact.js', - 'download.js', - 'fwdstart.js', - 'geo.js', - 'gpsjam.js', - 'health.js', - 'military-flights.js', - 'og-story.js', - 'opensky.js', - 'oref-alerts.js', - 'polymarket.js', - 'product-catalog.js', - 'register-interest.js', - 'reverse-geocode.js', - 'mcp-proxy.js', - 'rss-proxy.js', - 'satellites.js', - 'seed-health.js', - 'story.js', - 'telegram-feed.js', - 'sanctions-entity-search.js', - 'version.js', - ]); - - const currentEndpoints = readdirSync(apiDir).filter( - (f) => f.endsWith('.js') && !f.startsWith('_'), - ); - - for (const file of currentEndpoints) { - it(`${file} is in the legacy endpoint allowlist`, () => { - assert.ok( - ALLOWED_LEGACY_ENDPOINTS.has(file), - `${file} is a new api/*.js endpoint not in the allowlist. ` + - 'New data endpoints must use the sebuf protobuf RPC pattern ' + - '(proto definition → buf generate → handler in server/worldmonitor/{domain}/v1/ → wired in handler.ts). ' + - 'If this is a non-data ops endpoint, add it to ALLOWED_LEGACY_ENDPOINTS in tests/edge-functions.test.mjs.', - ); - }); - } - - it('allowlist has no stale entries (all listed files exist)', () => { - for (const file of ALLOWED_LEGACY_ENDPOINTS) { - assert.ok( - existsSync(join(apiDir, file)), - `${file} is in ALLOWED_LEGACY_ENDPOINTS but does not exist in api/ — remove it from the allowlist.`, - ); - } - }); -}); +// The legacy api/*.js allowlist that previously lived here was replaced by +// api/api-route-exceptions.json + scripts/enforce-sebuf-api-contract.mjs (see +// docs/adding-endpoints.mdx). The new check covers nested paths and .ts files, +// which this block missed. describe('reverse-geocode Redis write', () => { const geocodePath = join(apiDir, 'reverse-geocode.js'); @@ -363,102 +313,9 @@ describe('Edge Function module isolation', () => { } }); -describe('Scenario run endpoint (api/scenario/v1/run.ts)', () => { - const runPath = join(root, 'api', 'scenario', 'v1', 'run.ts'); - - it('exports edge config with runtime: edge', () => { - const src = readFileSync(runPath, 'utf-8'); - assert.ok( - src.includes("runtime: 'edge'") || src.includes('runtime: "edge"'), - 'run.ts: must export config with runtime: edge', - ); - }); - - it('has a default export handler function', () => { - const src = readFileSync(runPath, 'utf-8'); - assert.ok( - src.includes('export default') && src.includes('function handler'), - 'run.ts: must have a default export handler function', - ); - }); - - it('returns 405 for non-POST requests', () => { - const src = readFileSync(runPath, 'utf-8'); - assert.ok( - src.includes('405'), - 'run.ts: must return 405 for non-POST requests', - ); - assert.ok( - src.includes("!== 'POST'") || src.includes('!== "POST"'), - 'run.ts: must check for POST method and reject other methods with 405', - ); - }); - - it('validates scenarioId is required', () => { - const src = readFileSync(runPath, 'utf-8'); - assert.ok( - src.includes('scenarioId'), - 'run.ts: must validate scenarioId field', - ); - assert.ok( - src.includes('400'), - 'run.ts: must return 400 for invalid/missing scenarioId', - ); - }); - - it('uses per-user rate limiting', () => { - const src = readFileSync(runPath, 'utf-8'); - assert.ok( - src.includes('rate') && src.includes('429'), - 'run.ts: must implement rate limiting with 429 response', - ); - }); - - it('uses AbortSignal.timeout on Redis fetch', () => { - const src = readFileSync(runPath, 'utf-8'); - assert.ok( - src.includes('AbortSignal.timeout'), - 'run.ts: all Redis fetches must have AbortSignal.timeout to prevent hanging edge isolates', - ); - }); -}); - -describe('Scenario status endpoint (api/scenario/v1/status.ts)', () => { - const statusPath = join(root, 'api', 'scenario', 'v1', 'status.ts'); - - it('exports edge config with runtime: edge', () => { - const src = readFileSync(statusPath, 'utf-8'); - assert.ok( - src.includes("runtime: 'edge'") || src.includes('runtime: "edge"'), - 'status.ts: must export config with runtime: edge', - ); - }); - - it('returns 400 for missing or invalid jobId', () => { - const src = readFileSync(statusPath, 'utf-8'); - assert.ok( - src.includes('400'), - 'status.ts: must return 400 for missing or invalid jobId', - ); - assert.ok( - src.includes('jobId'), - 'status.ts: must validate jobId query parameter', - ); - }); - - it('validates jobId format to guard against path traversal', () => { - const src = readFileSync(statusPath, 'utf-8'); - assert.ok( - src.includes('JOB_ID_RE') || src.includes('/^scenario:'), - 'status.ts: must validate jobId against a regex to prevent path traversal attacks (e.g. ../../etc/passwd)', - ); - }); - - it('uses AbortSignal.timeout on Redis fetch', () => { - const src = readFileSync(statusPath, 'utf-8'); - assert.ok( - src.includes('AbortSignal.timeout'), - 'status.ts: Redis fetch must have AbortSignal.timeout to prevent hanging edge isolates', - ); - }); -}); +// Scenario endpoints (run / status / templates) were migrated from literal-filename +// edge functions to ScenarioService RPCs in PR #3207 commit 7. See +// tests/scenario-handler.test.mjs for the handler-level coverage that preserves +// the security invariants (405/POST guard via sebuf service-config, scenarioId + +// iso2 validation, JOB_ID_RE path-traversal guard, per-IP 10/min rate limit via +// gateway, queue-depth backpressure, AbortSignal.timeout on Redis pipelines). diff --git a/tests/email-validation.test.mjs b/tests/email-validation.test.mjs index 830b2ef13..f075e6193 100644 --- a/tests/email-validation.test.mjs +++ b/tests/email-validation.test.mjs @@ -14,7 +14,7 @@ function mockFetch(mxResponse) { } // Import after fetch is available (module is Edge-compatible, no node: imports) -const { validateEmail } = await import('../api/_email-validation.js'); +const { validateEmail } = await import('../server/_shared/email-validation.ts'); describe('validateEmail', () => { beforeEach(() => { diff --git a/tests/multi-sector-cost-shock.test.mjs b/tests/multi-sector-cost-shock.test.mjs index fc0c44763..ec28f84a3 100644 --- a/tests/multi-sector-cost-shock.test.mjs +++ b/tests/multi-sector-cost-shock.test.mjs @@ -232,26 +232,25 @@ describe('computeMultiSectorShocks', () => { }); // ======================================================================== -// 2. Edge function: api/supply-chain/v1/multi-sector-cost-shock.ts +// 2. sebuf handler: server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts // ======================================================================== -describe('multi-sector-cost-shock edge function', () => { - const src = readSrc('api/supply-chain/v1/multi-sector-cost-shock.ts'); +describe('getMultiSectorCostShock sebuf handler', () => { + const src = readSrc('server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts'); - it('is declared as a Vercel edge function', () => { - assert.match(src, /export const config = \{ runtime: 'edge' \}/); + it('exports getMultiSectorCostShock as the sebuf handler entry point', () => { + assert.match(src, /export async function getMultiSectorCostShock/); }); - it('calls isCallerPremium for PRO-gating', () => { - assert.match(src, /isCallerPremium/); - assert.match(src, /PRO subscription required/); + it('uses isCallerPremium for PRO-gating', () => { + assert.match(src, /isCallerPremium\(ctx\.request\)/); }); it('validates iso2 with a 2-letter regex', () => { assert.match(src, /\/\^\[A-Z\]\{2\}\$\/\.test/); }); - it('validates chokepointId against the registry', () => { + it('validates chokepointId against the chokepoint registry', () => { assert.match(src, /CHOKEPOINT_REGISTRY\.some/); }); @@ -259,22 +258,25 @@ describe('multi-sector-cost-shock edge function', () => { assert.match(src, /clampClosureDays/); }); - it('reads country products from comtrade:bilateral-hs4 key', () => { + it('reads seeded country products from the raw bilateral-hs4 Redis key', () => { assert.match(src, /comtrade:bilateral-hs4:\$\{iso2\}:v1/); + assert.match(src, /getCachedJson\(productsKey, true\)/); }); it('reads chokepoint status for war risk tier', () => { assert.match(src, /CHOKEPOINT_STATUS_KEY/); }); - it('returns JSON with sectors, totalAddedCost, and closureDays', () => { + it('returns sectors, totalAddedCost, closureDays, and warRiskTier on the response', () => { assert.match(src, /sectors/); assert.match(src, /totalAddedCost/); assert.match(src, /closureDays/); + assert.match(src, /warRiskTier/); }); - it('uses short private Cache-Control (slider state is user-controlled)', () => { - assert.match(src, /private, max-age=60/); + it('emits the empty sector skeleton when no seeded import data exists', () => { + assert.match(src, /emptySectorSkeleton/); + assert.match(src, /No seeded import data available for this country/); }); }); @@ -289,35 +291,34 @@ describe('supply-chain client service: fetchMultiSectorCostShock', () => { assert.match(src, /export async function fetchMultiSectorCostShock/); }); - it('exports MultiSectorShock and MultiSectorShockResponse interfaces', () => { - assert.match(src, /export interface MultiSectorShock/); - assert.match(src, /export interface MultiSectorShockResponse/); + it('re-exports MultiSectorShock and MultiSectorShockResponse aliases for callsites', () => { + assert.match(src, /export type MultiSectorShockResponse = GetMultiSectorCostShockResponse/); + assert.match(src, /export type MultiSectorShock = MultiSectorCostShock/); }); - it('uses premiumFetch for PRO-gated access', () => { - assert.match(src, /fetchMultiSectorCostShock[\s\S]*?premiumFetch/); + it('calls the generated sebuf client.getMultiSectorCostShock', () => { + assert.match(src, /client\.getMultiSectorCostShock\(/); }); - it('passes iso2, chokepointId, and closureDays as query params', () => { - assert.match(src, /iso2=\$\{encodeURIComponent\(iso2\)\}/); - assert.match(src, /chokepointId=\$\{encodeURIComponent\(chokepointId\)\}/); - assert.match(src, /closureDays=\$\{encodeURIComponent\(String\(closureDays\)\)\}/); + it('passes iso2, chokepointId, and closureDays through the typed RPC request', () => { + assert.match(src, /\{ iso2, chokepointId, closureDays \}/); }); - it('supports AbortSignal passthrough', () => { + it('supports AbortSignal passthrough via call options', () => { assert.match(src, /signal\?: AbortSignal/); + assert.match(src, /signal: options\?\.signal/); }); }); // ======================================================================== -// 4. Premium paths: multi-sector-cost-shock is PRO-gated at the gateway. +// 4. Premium paths: get-multi-sector-cost-shock is PRO-gated at the gateway. // ======================================================================== -describe('premium-paths: multi-sector-cost-shock registration', () => { +describe('premium-paths: get-multi-sector-cost-shock registration', () => { const src = readSrc('src/shared/premium-paths.ts'); - it('includes /api/supply-chain/v1/multi-sector-cost-shock', () => { - assert.match(src, /\/api\/supply-chain\/v1\/multi-sector-cost-shock/); + it('includes /api/supply-chain/v1/get-multi-sector-cost-shock', () => { + assert.match(src, /\/api\/supply-chain\/v1\/get-multi-sector-cost-shock/); }); }); diff --git a/tests/redis-caching.test.mjs b/tests/redis-caching.test.mjs index fa5ab00b8..77bc9eec1 100644 --- a/tests/redis-caching.test.mjs +++ b/tests/redis-caching.test.mjs @@ -812,8 +812,8 @@ describe('military flights bbox behavior', { concurrency: 1 }, () => { const result = await module.listMilitaryFlights({}, request); assert.deepEqual( result.flights.map((flight) => flight.id), - ['in-bounds'], - 'response should not include out-of-viewport flights', + ['IN-BOUNDS'], + 'response should not include out-of-viewport flights (hex_code canonical form is uppercase)', ); assert.equal(fetchUrls.length, 1); diff --git a/tests/scenario-handler.test.mjs b/tests/scenario-handler.test.mjs new file mode 100644 index 000000000..426d20b37 --- /dev/null +++ b/tests/scenario-handler.test.mjs @@ -0,0 +1,267 @@ +/** + * Functional tests for ScenarioService handlers. Tests the typed handlers + * directly (not the HTTP gateway). Covers the security invariants the legacy + * edge functions enforced: + * - run-scenario: 405 (via sebuf service-config method=POST), scenarioId + * validation against the template registry, iso2 regex, queue-depth + * backpressure, PRO gate, AbortSignal.timeout on Redis fetches. + * - get-scenario-status: JOB_ID_RE path-traversal guard, PRO gate. + * - list-scenario-templates: catalog shape preservation. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +const originalFetch = globalThis.fetch; +const originalEnv = { ...process.env }; + +function makeCtx(headers = {}) { + const req = new Request('https://worldmonitor.app/api/scenario/v1/run-scenario', { + method: 'POST', + headers, + }); + return { request: req, pathParams: {}, headers }; +} + +function proCtx() { + return makeCtx({ 'X-WorldMonitor-Key': 'pro-test-key' }); +} + +let runScenario; +let getScenarioStatus; +let listScenarioTemplates; +let ValidationError; +let ApiError; + +describe('ScenarioService handlers', () => { + beforeEach(async () => { + process.env.WORLDMONITOR_VALID_KEYS = 'pro-test-key'; + process.env.UPSTASH_REDIS_REST_URL = 'https://fake-upstash.example'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token'; + + const runMod = await import('../server/worldmonitor/scenario/v1/run-scenario.ts'); + const statusMod = await import('../server/worldmonitor/scenario/v1/get-scenario-status.ts'); + const templatesMod = await import('../server/worldmonitor/scenario/v1/list-scenario-templates.ts'); + runScenario = runMod.runScenario; + getScenarioStatus = statusMod.getScenarioStatus; + listScenarioTemplates = templatesMod.listScenarioTemplates; + const gen = await import('../src/generated/server/worldmonitor/scenario/v1/service_server.ts'); + ValidationError = gen.ValidationError; + ApiError = gen.ApiError; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.keys(process.env).forEach((k) => { + if (!(k in originalEnv)) delete process.env[k]; + }); + Object.assign(process.env, originalEnv); + }); + + describe('runScenario', () => { + it('rejects non-PRO callers with 403', async () => { + await assert.rejects( + () => runScenario(makeCtx(), { scenarioId: 'taiwan-strait-full-closure', iso2: '' }), + (err) => err instanceof ApiError && err.statusCode === 403, + ); + }); + + it('rejects missing scenarioId with ValidationError', async () => { + await assert.rejects( + () => runScenario(proCtx(), { scenarioId: '', iso2: '' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'scenarioId', + ); + }); + + it('rejects unknown scenarioId with ValidationError', async () => { + await assert.rejects( + () => runScenario(proCtx(), { scenarioId: 'not-a-real-scenario', iso2: '' }), + (err) => err instanceof ValidationError && /Unknown scenario/.test(err.violations[0].description), + ); + }); + + it('rejects malformed iso2 with ValidationError', async () => { + await assert.rejects( + () => runScenario(proCtx(), { scenarioId: 'taiwan-strait-full-closure', iso2: 'usa' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'iso2', + ); + }); + + it('accepts empty iso2 (treated as scope-all)', async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), body: init?.body }); + const body = JSON.parse(String(init?.body)); + // Pipeline format: [[CMD, ...args]]; LLEN returns 0, RPUSH returns new length 1. + const results = body.map((cmd) => cmd[0] === 'LLEN' ? { result: 0 } : { result: 1 }); + return new Response(JSON.stringify(results), { status: 200 }); + }; + const res = await runScenario(proCtx(), { scenarioId: 'taiwan-strait-full-closure', iso2: '' }); + assert.match(res.jobId, /^scenario:\d{13}:[a-z0-9]{8}$/); + assert.equal(res.status, 'pending'); + // statusUrl preserved from the legacy v1 contract — server-computed, + // URL-encoded jobId, safe for callers to follow directly. Locked in + // because sebuf's 200-only convention breaks the 202/202-body pairing + // from the pre-migration contract, and statusUrl is the safe alternative. + assert.equal( + res.statusUrl, + `/api/scenario/v1/get-scenario-status?jobId=${encodeURIComponent(res.jobId)}`, + ); + const pushCall = calls.find((c) => String(c.body).includes('RPUSH')); + assert.ok(pushCall, 'RPUSH pipeline must be dispatched'); + const pushed = JSON.parse(pushCall.body); + assert.equal(pushed[0][0], 'RPUSH'); + assert.equal(pushed[0][1], 'scenario-queue:pending'); + const payload = JSON.parse(pushed[0][2]); + assert.equal(payload.scenarioId, 'taiwan-strait-full-closure'); + assert.equal(payload.iso2, null); + }); + + it('rejects when queue depth exceeds 100 with 429 ApiError', async () => { + globalThis.fetch = async () => + new Response(JSON.stringify([{ result: 101 }]), { status: 200 }); + await assert.rejects( + () => runScenario(proCtx(), { scenarioId: 'taiwan-strait-full-closure', iso2: '' }), + (err) => err instanceof ApiError && err.statusCode === 429 && /capacity/i.test(err.message), + ); + }); + + it('returns 502 when Upstash RPUSH fails', async () => { + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(String(init?.body)); + if (body[0][0] === 'LLEN') { + return new Response(JSON.stringify([{ result: 0 }]), { status: 200 }); + } + // RPUSH fails — pipeline helper returns []; handler surfaces as 502. + return new Response('upstream error', { status: 500 }); + }; + await assert.rejects( + () => runScenario(proCtx(), { scenarioId: 'taiwan-strait-full-closure', iso2: '' }), + (err) => err instanceof ApiError && err.statusCode === 502, + ); + }); + + it('Redis pipeline fetches carry AbortSignal.timeout (source assertion)', () => { + const src = readFileSync(join(root, 'server/_shared/redis.ts'), 'utf8'); + assert.match( + src, + /runRedisPipeline[\s\S]*?AbortSignal\.timeout/, + 'runRedisPipeline must use AbortSignal.timeout to prevent hanging edge isolates', + ); + }); + }); + + describe('getScenarioStatus', () => { + it('rejects non-PRO callers with 403', async () => { + await assert.rejects( + () => getScenarioStatus(makeCtx(), { jobId: 'scenario:1712345678901:abcdefgh' }), + (err) => err instanceof ApiError && err.statusCode === 403, + ); + }); + + it('rejects missing jobId with ValidationError', async () => { + await assert.rejects( + () => getScenarioStatus(proCtx(), { jobId: '' }), + (err) => err instanceof ValidationError, + ); + }); + + it('rejects path-traversal jobId with ValidationError', async () => { + await assert.rejects( + () => getScenarioStatus(proCtx(), { jobId: '../../etc/passwd' }), + (err) => err instanceof ValidationError, + ); + }); + + it('rejects malformed jobId (wrong suffix charset)', async () => { + await assert.rejects( + () => getScenarioStatus(proCtx(), { jobId: 'scenario:1712345678901:ABCDEFGH' }), + (err) => err instanceof ValidationError, + ); + }); + + it('returns pending when Redis key is absent', async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ result: null }), { status: 200 }); + const res = await getScenarioStatus(proCtx(), { jobId: 'scenario:1712345678901:abcdefgh' }); + assert.equal(res.status, 'pending'); + assert.equal(res.result, undefined); + }); + + it('passes through processing status', async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ result: JSON.stringify({ status: 'processing', startedAt: 123 }) }), + { status: 200 }, + ); + const res = await getScenarioStatus(proCtx(), { jobId: 'scenario:1712345678901:abcdefgh' }); + assert.equal(res.status, 'processing'); + }); + + it('returns shaped ScenarioResult when worker marks status=done', async () => { + const workerResult = { + scenarioId: 'taiwan-strait-full-closure', + template: { name: 'taiwan_strait', disruptionPct: 100, durationDays: 30, costShockMultiplier: 1.45 }, + affectedChokepointIds: ['taiwan_strait'], + currentDisruptionScores: { taiwan_strait: 42 }, + topImpactCountries: [{ iso2: 'JP', totalImpact: 1500, impactPct: 100 }], + }; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + result: JSON.stringify({ status: 'done', result: workerResult, completedAt: 456 }), + }), + { status: 200 }, + ); + const res = await getScenarioStatus(proCtx(), { jobId: 'scenario:1712345678901:abcdefgh' }); + assert.equal(res.status, 'done'); + assert.ok(res.result); + assert.deepEqual(res.result.affectedChokepointIds, ['taiwan_strait']); + assert.equal(res.result.topImpactCountries.length, 1); + assert.equal(res.result.topImpactCountries[0].iso2, 'JP'); + assert.equal(res.result.topImpactCountries[0].impactPct, 100); + assert.equal(res.result.template?.disruptionPct, 100); + }); + + it('returns failed status with error message', async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + result: JSON.stringify({ status: 'failed', error: 'computation_error', failedAt: 789 }), + }), + { status: 200 }, + ); + const res = await getScenarioStatus(proCtx(), { jobId: 'scenario:1712345678901:abcdefgh' }); + assert.equal(res.status, 'failed'); + assert.equal(res.error, 'computation_error'); + }); + }); + + describe('listScenarioTemplates', () => { + it('returns catalog with the core template fields', async () => { + const res = await listScenarioTemplates(makeCtx(), {}); + assert.ok(Array.isArray(res.templates)); + assert.ok(res.templates.length >= 6, 'catalog seeded with 6 templates'); + const taiwan = res.templates.find((t) => t.id === 'taiwan-strait-full-closure'); + assert.ok(taiwan); + assert.equal(taiwan.disruptionPct, 100); + assert.equal(taiwan.durationDays, 30); + assert.deepEqual(taiwan.affectedChokepointIds, ['taiwan_strait']); + assert.deepEqual(taiwan.affectedHs2, ['84', '85', '87']); + }); + + it('empty affectedHs2 array means ALL sectors (preserves null-as-wildcard)', async () => { + const res = await listScenarioTemplates(makeCtx(), {}); + const suez = res.templates.find((t) => t.id === 'suez-bab-simultaneous'); + assert.ok(suez); + // Template declares affectedHs2: null (all sectors); wire emits []. + assert.deepEqual(suez.affectedHs2, []); + }); + }); +}); diff --git a/tests/server-handlers.test.mjs b/tests/server-handlers.test.mjs index 99a6dee1a..33f3198cc 100644 --- a/tests/server-handlers.test.mjs +++ b/tests/server-handlers.test.mjs @@ -192,10 +192,13 @@ describe('getCacheKey determinism', () => { describe('getVesselSnapshot caching (HIGH-1)', () => { const src = readSrc('server/worldmonitor/maritime/v1/get-vessel-snapshot.ts'); - it('has in-memory cache variables at module scope', () => { - assert.match(src, /let cachedSnapshot/); - assert.match(src, /let cacheTimestamp/); - assert.match(src, /let inFlightRequest/); + it('has per-variant cache slots (candidates=on vs off)', () => { + assert.match(src, /cache:\s*Record<'with'\s*\|\s*'without'/, + 'Cache should split on include_candidates so the large/small payloads do not share a slot'); + assert.match(src, /with:\s*\{\s*snapshot:\s*undefined/, + 'with-candidates slot should be initialized empty'); + assert.match(src, /without:\s*\{\s*snapshot:\s*undefined/, + 'without-candidates slot should be initialized empty'); }); it('has 5-minute TTL cache', () => { @@ -204,27 +207,27 @@ describe('getVesselSnapshot caching (HIGH-1)', () => { }); it('checks cache before calling relay', () => { - // fetchVesselSnapshot should check cachedSnapshot before fetchVesselSnapshotFromRelay - const cacheCheckIdx = src.indexOf('cachedSnapshot && (now - cacheTimestamp)'); - const relayCallIdx = src.indexOf('fetchVesselSnapshotFromRelay()'); - assert.ok(cacheCheckIdx > -1, 'Should check cache'); + // fetchVesselSnapshot should check slot freshness before fetchVesselSnapshotFromRelay + const cacheCheckIdx = src.indexOf('slot.snapshot && (now - slot.timestamp)'); + const relayCallIdx = src.indexOf('fetchVesselSnapshotFromRelay(includeCandidates)'); + assert.ok(cacheCheckIdx > -1, 'Should check slot freshness'); assert.ok(relayCallIdx > -1, 'Should have relay fetch function'); assert.ok(cacheCheckIdx < relayCallIdx, 'Cache check should come before relay call'); }); - it('has in-flight dedup via shared promise', () => { - assert.match(src, /if\s*\(inFlightRequest\)/, - 'Should check for in-flight request'); - assert.match(src, /inFlightRequest\s*=\s*fetchVesselSnapshotFromRelay/, - 'Should assign in-flight promise'); - assert.match(src, /inFlightRequest\s*=\s*null/, + it('has in-flight dedup via per-slot promise', () => { + assert.match(src, /if\s*\(slot\.inFlight\)/, + 'Should check for in-flight request on the selected slot'); + assert.match(src, /slot\.inFlight\s*=\s*fetchVesselSnapshotFromRelay/, + 'Should assign in-flight promise on the slot'); + assert.match(src, /slot\.inFlight\s*=\s*null/, 'Should clear in-flight promise in finally block'); }); it('serves stale snapshot when relay fetch fails', () => { - assert.match(src, /return\s+result\s*\?\?\s*cachedSnapshot/, - 'Should return stale cached snapshot when fresh relay fetch fails'); + assert.match(src, /return\s+result\s*\?\?\s*slot\.snapshot/, + 'Should return stale cached snapshot from the selected slot when fresh relay fetch fails'); }); // NOTE: Full integration test (mocking fetch, verifying cache hits) requires diff --git a/tests/shipping-v2-handler.test.mjs b/tests/shipping-v2-handler.test.mjs new file mode 100644 index 000000000..b0ac3ee29 --- /dev/null +++ b/tests/shipping-v2-handler.test.mjs @@ -0,0 +1,356 @@ +/** + * Functional tests for ShippingV2Service handlers. Tests the typed handlers + * directly (not the HTTP gateway). Covers the security invariants the legacy + * edge functions enforced: + * - routeIntelligence: PRO gate, iso2 regex, hs2 non-digit stripping, + * cargoType coercion to legal enum, wire-shape byte-for-byte with partner + * contract (camelCase field names, ISO-8601 fetchedAt). + * - registerWebhook: PRO gate, SSRF guards (https-only, private IP, cloud + * metadata), chokepointIds whitelist, alertThreshold 0-100 range, + * subscriberId / secret format (wh_ + 24 hex / 64 hex), 30-day TTL + * atomic pipeline (SET + SADD + EXPIRE). + * - listWebhooks: PRO gate, owner-filter isolation, `secret` never in response. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; + +const originalFetch = globalThis.fetch; +const originalEnv = { ...process.env }; + +function makeCtx(headers = {}) { + const req = new Request('https://worldmonitor.app/api/v2/shipping/route-intelligence', { + method: 'GET', + headers, + }); + return { request: req, pathParams: {}, headers }; +} + +function proCtx() { + return makeCtx({ 'X-WorldMonitor-Key': 'pro-test-key' }); +} + +let routeIntelligence; +let registerWebhook; +let listWebhooks; +let webhookShared; +let ValidationError; +let ApiError; + +describe('ShippingV2Service handlers', () => { + beforeEach(async () => { + process.env.WORLDMONITOR_VALID_KEYS = 'pro-test-key'; + process.env.UPSTASH_REDIS_REST_URL = 'https://fake-upstash.example'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token'; + + const riMod = await import('../server/worldmonitor/shipping/v2/route-intelligence.ts'); + const rwMod = await import('../server/worldmonitor/shipping/v2/register-webhook.ts'); + const lwMod = await import('../server/worldmonitor/shipping/v2/list-webhooks.ts'); + webhookShared = await import('../server/worldmonitor/shipping/v2/webhook-shared.ts'); + routeIntelligence = riMod.routeIntelligence; + registerWebhook = rwMod.registerWebhook; + listWebhooks = lwMod.listWebhooks; + const gen = await import('../src/generated/server/worldmonitor/shipping/v2/service_server.ts'); + ValidationError = gen.ValidationError; + ApiError = gen.ApiError; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.keys(process.env).forEach((k) => { + if (!(k in originalEnv)) delete process.env[k]; + }); + Object.assign(process.env, originalEnv); + }); + + describe('routeIntelligence', () => { + it('rejects non-PRO callers with 403', async () => { + await assert.rejects( + () => routeIntelligence(makeCtx(), { fromIso2: 'AE', toIso2: 'NL', cargoType: '', hs2: '' }), + (err) => err instanceof ApiError && err.statusCode === 403, + ); + }); + + it('rejects malformed fromIso2 with ValidationError', async () => { + // Stub redis GET for CHOKEPOINT_STATUS_KEY so the handler never panics. + globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 }); + // 'usa' uppercases to 'USA' (3 chars) — regex `^[A-Z]{2}$` rejects. + await assert.rejects( + () => routeIntelligence(proCtx(), { fromIso2: 'usa', toIso2: 'NL', cargoType: '', hs2: '' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'fromIso2', + ); + }); + + it('preserves partner wire shape with ISO-8601 fetchedAt and camelCase fields', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 }); + const before = Date.now(); + const res = await routeIntelligence(proCtx(), { + fromIso2: 'AE', + toIso2: 'NL', + cargoType: 'tanker', + hs2: '27', + }); + const after = Date.now(); + + // Partner-visible top-level fields — exact names, camelCase, full set. + assert.deepEqual(new Set(Object.keys(res)).size, 10); + assert.equal(res.fromIso2, 'AE'); + assert.equal(res.toIso2, 'NL'); + assert.equal(res.cargoType, 'tanker'); + assert.equal(res.hs2, '27'); + assert.equal(typeof res.primaryRouteId, 'string'); + assert.ok(Array.isArray(res.chokepointExposures)); + assert.ok(Array.isArray(res.bypassOptions)); + assert.match(res.warRiskTier, /^WAR_RISK_TIER_/); + assert.equal(typeof res.disruptionScore, 'number'); + + // fetchedAt must be ISO-8601, NOT epoch ms — partners parse this string directly. + assert.match(res.fetchedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/); + const parsedTs = Date.parse(res.fetchedAt); + assert.ok(parsedTs >= before && parsedTs <= after, 'fetchedAt within request window'); + }); + + it('defaults hs2 to "27" when blank or all non-digits', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 }); + const res1 = await routeIntelligence(proCtx(), { fromIso2: 'AE', toIso2: 'NL', cargoType: '', hs2: '' }); + const res2 = await routeIntelligence(proCtx(), { fromIso2: 'AE', toIso2: 'NL', cargoType: '', hs2: 'abc' }); + assert.equal(res1.hs2, '27'); + assert.equal(res2.hs2, '27'); + }); + + it('coerces unknown cargoType to container', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 }); + const res = await routeIntelligence(proCtx(), { + fromIso2: 'AE', + toIso2: 'NL', + cargoType: 'spaceship', + hs2: '', + }); + assert.equal(res.cargoType, 'container'); + }); + }); + + describe('registerWebhook', () => { + // Capture pipeline commands dispatched to Upstash for the happy-path Redis stub. + function stubRedisOk() { + const calls = []; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(String(init?.body)); + calls.push(body); + // Upstash pipeline returns one result per command. + return new Response( + JSON.stringify(body.map(() => ({ result: 'OK' }))), + { status: 200 }, + ); + }; + return calls; + } + + it('rejects callers without an API key with 401 (tenant-isolation gate)', async () => { + // Without this gate, Clerk-authenticated pro callers with no X-WorldMonitor-Key + // collapse into a shared 'anon' fingerprint bucket and can see each other's + // webhooks. Must fire before any premium check. + await assert.rejects( + () => registerWebhook(makeCtx(), { + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: [], + alertThreshold: 50, + }), + (err) => err instanceof ApiError && err.statusCode === 401, + ); + }); + + it('rejects missing callbackUrl with ValidationError', async () => { + await assert.rejects( + () => registerWebhook(proCtx(), { callbackUrl: '', chokepointIds: [], alertThreshold: 50 }), + (err) => err instanceof ValidationError && err.violations[0].field === 'callbackUrl', + ); + }); + + it('SSRF guards reject http:// (must be https)', async () => { + await assert.rejects( + () => registerWebhook(proCtx(), { + callbackUrl: 'http://hooks.example.com/wm', + chokepointIds: [], + alertThreshold: 50, + }), + (err) => err instanceof ValidationError && /https/.test(err.violations[0].description), + ); + }); + + it('SSRF guards reject localhost, RFC1918, and cloud metadata hostnames', async () => { + const blockedHosts = [ + 'https://localhost/hook', + 'https://127.0.0.1/hook', + 'https://10.0.0.1/hook', + 'https://192.168.1.1/hook', + 'https://169.254.169.254/latest/meta-data/', + 'https://metadata.google.internal/', + ]; + for (const callbackUrl of blockedHosts) { + await assert.rejects( + () => registerWebhook(proCtx(), { callbackUrl, chokepointIds: [], alertThreshold: 50 }), + (err) => err instanceof ValidationError, + `expected SSRF block for ${callbackUrl}`, + ); + } + }); + + it('rejects unknown chokepointIds', async () => { + await assert.rejects( + () => registerWebhook(proCtx(), { + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: ['not_a_real_chokepoint'], + alertThreshold: 50, + }), + (err) => err instanceof ValidationError && /Unknown chokepoint/.test(err.violations[0].description), + ); + }); + + it('rejects alertThreshold > 100 with ValidationError', async () => { + await assert.rejects( + () => registerWebhook(proCtx(), { + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: [], + alertThreshold: 150, + }), + (err) => err instanceof ValidationError && err.violations[0].field === 'alertThreshold', + ); + }); + + it('happy path returns wh_-prefixed subscriberId and 64-char hex secret; issues SET + SADD + EXPIRE pipeline with 30-day TTL', async () => { + const calls = stubRedisOk(); + const res = await registerWebhook(proCtx(), { + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: [], + alertThreshold: 60, + }); + + // Partner-visible shape: subscriberId + secret only (no extras). + assert.deepEqual(Object.keys(res).sort(), ['secret', 'subscriberId']); + assert.match(res.subscriberId, /^wh_[0-9a-f]{24}$/); + assert.match(res.secret, /^[0-9a-f]{64}$/); + + // Exactly one Redis pipeline call with 3 commands in order. + assert.equal(calls.length, 1); + const pipeline = calls[0]; + assert.equal(pipeline.length, 3); + assert.equal(pipeline[0][0], 'SET'); + assert.ok(pipeline[0][1].startsWith('webhook:sub:wh_'), 'SET key is webhook:sub:wh_*:v1'); + assert.equal(pipeline[0][3], 'EX'); + assert.equal(pipeline[0][4], String(86400 * 30), '30-day TTL on the webhook record'); + assert.equal(pipeline[1][0], 'SADD'); + assert.ok(pipeline[1][1].startsWith('webhook:owner:'), 'SADD key is webhook:owner:*:v1'); + assert.equal(pipeline[2][0], 'EXPIRE'); + assert.equal(pipeline[2][1], pipeline[1][1], 'EXPIRE targets same owner index key'); + assert.equal(pipeline[2][2], String(86400 * 30)); + }); + + it('alertThreshold 0 (proto default) coerces to legacy default 50', async () => { + const calls = stubRedisOk(); + await registerWebhook(proCtx(), { + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: [], + alertThreshold: 0, + }); + const record = JSON.parse(calls[0][0][2]); + assert.equal(record.alertThreshold, 50); + }); + + it('empty chokepointIds subscribes to the full CHOKEPOINT_REGISTRY', async () => { + const calls = stubRedisOk(); + await registerWebhook(proCtx(), { + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: [], + alertThreshold: 50, + }); + const record = JSON.parse(calls[0][0][2]); + assert.ok(record.chokepointIds.length > 0, 'empty list expands to registry'); + assert.equal(record.chokepointIds.length, webhookShared.VALID_CHOKEPOINT_IDS.size); + }); + }); + + describe('listWebhooks', () => { + it('rejects callers without an API key with 401 (tenant-isolation gate)', async () => { + // Mirror of registerWebhook: the defense-in-depth ownerTag check collapses + // when callers fall through to 'anon', so we reject unauthenticated callers + // before hitting Redis. + await assert.rejects( + () => listWebhooks(makeCtx(), {}), + (err) => err instanceof ApiError && err.statusCode === 401, + ); + }); + + it('returns empty webhooks array when SMEMBERS is empty', async () => { + globalThis.fetch = async () => + new Response(JSON.stringify([{ result: [] }]), { status: 200 }); + const res = await listWebhooks(proCtx(), {}); + assert.deepEqual(res, { webhooks: [] }); + }); + + it('filters out records whose ownerTag does not match the caller fingerprint (cross-tenant isolation)', async () => { + const otherOwnerRecord = { + subscriberId: 'wh_deadbeef000000000000beef', + ownerTag: 'someone-elses-hash', + callbackUrl: 'https://other.example/hook', + chokepointIds: ['hormuz_strait'], + alertThreshold: 50, + createdAt: '2026-04-01T00:00:00.000Z', + active: true, + secret: 'other-caller-secret-never-returned', + }; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(String(init?.body)); + if (body.length === 1 && body[0][0] === 'SMEMBERS') { + return new Response( + JSON.stringify([{ result: ['wh_deadbeef000000000000beef'] }]), + { status: 200 }, + ); + } + return new Response( + JSON.stringify(body.map(() => ({ result: JSON.stringify(otherOwnerRecord) }))), + { status: 200 }, + ); + }; + const res = await listWebhooks(proCtx(), {}); + assert.deepEqual(res.webhooks, [], 'mismatched ownerTag must not leak across callers'); + }); + + it('omits `secret` from matched records — partner contract invariant', async () => { + // Build a record whose ownerTag matches the caller's SHA-256 fingerprint. + const key = 'pro-test-key'; + const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key)); + const ownerTag = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); + + const record = { + subscriberId: 'wh_abc123456789012345678901', + ownerTag, + callbackUrl: 'https://hooks.example.com/wm', + chokepointIds: ['hormuz_strait'], + alertThreshold: 60, + createdAt: '2026-04-01T00:00:00.000Z', + active: true, + secret: 'must-not-be-in-response', + }; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(String(init?.body)); + if (body.length === 1 && body[0][0] === 'SMEMBERS') { + return new Response( + JSON.stringify([{ result: [record.subscriberId] }]), + { status: 200 }, + ); + } + return new Response( + JSON.stringify(body.map(() => ({ result: JSON.stringify(record) }))), + { status: 200 }, + ); + }; + const res = await listWebhooks(proCtx(), {}); + assert.equal(res.webhooks.length, 1); + const [summary] = res.webhooks; + assert.equal(summary.subscriberId, record.subscriberId); + assert.equal(summary.callbackUrl, record.callbackUrl); + assert.ok(!('secret' in summary), '`secret` must never appear in ListWebhooks response'); + }); + }); +}); diff --git a/tests/supply-chain-validation.test.mjs b/tests/supply-chain-validation.test.mjs new file mode 100644 index 000000000..5d12e17fc --- /dev/null +++ b/tests/supply-chain-validation.test.mjs @@ -0,0 +1,115 @@ +/** + * Regression tests for input-shape validation on supply-chain handlers. + * Locks in the "400 on bad input / empty-200 on deny / empty-200 on no data" + * three-way contract after koala73 review HIGH(new) #2 on #3242. + * + * Prior state (bug): malformed iso2 / missing chokepointId / unknown + * chokepointId all collapsed to empty-200, indistinguishable from the + * legitimate non-pro deny path and from genuine "no data for this country". + * + * Fix: input-shape errors throw ValidationError (sebuf → HTTP 400). + * PRO-gate deny stays as empty-200 (intentional contract shift, called out + * in the original migration commits). + */ + +import { strict as assert } from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; + +const originalFetch = globalThis.fetch; +const originalEnv = { ...process.env }; + +function makeCtx(headers = {}, path = '/api/supply-chain/v1/get-country-products') { + const req = new Request(`https://worldmonitor.app${path}`, { method: 'GET', headers }); + return { request: req, pathParams: {}, headers }; +} +function proCtx(path) { + return makeCtx({ 'X-WorldMonitor-Key': 'pro-test-key' }, path); +} + +let getCountryProducts; +let getMultiSectorCostShock; +let ValidationError; + +describe('supply-chain handlers: input-shape validation returns 400, not empty-200', () => { + beforeEach(async () => { + process.env.WORLDMONITOR_VALID_KEYS = 'pro-test-key'; + process.env.UPSTASH_REDIS_REST_URL = 'https://fake-upstash.example'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token'; + + const gcpMod = await import('../server/worldmonitor/supply-chain/v1/get-country-products.ts'); + const gmscMod = await import('../server/worldmonitor/supply-chain/v1/get-multi-sector-cost-shock.ts'); + getCountryProducts = gcpMod.getCountryProducts; + getMultiSectorCostShock = gmscMod.getMultiSectorCostShock; + const gen = await import('../src/generated/server/worldmonitor/supply_chain/v1/service_server.ts'); + ValidationError = gen.ValidationError; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.keys(process.env).forEach((k) => { if (!(k in originalEnv)) delete process.env[k]; }); + Object.assign(process.env, originalEnv); + }); + + describe('getCountryProducts', () => { + it('throws ValidationError on blank iso2', async () => { + await assert.rejects( + () => getCountryProducts(proCtx('/api/supply-chain/v1/get-country-products'), { iso2: '' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'iso2', + ); + }); + + it('throws ValidationError on 3-letter iso codes (legacy contract required ISO-2)', async () => { + await assert.rejects( + () => getCountryProducts(proCtx('/api/supply-chain/v1/get-country-products'), { iso2: 'USA' }), + (err) => err instanceof ValidationError, + ); + }); + + it('PRO-gate deny on a well-formed iso2 still returns empty-200 (not 400) — intentional contract shift preserved', async () => { + // No X-WorldMonitor-Key header → isCallerPremium returns false. + const res = await getCountryProducts(makeCtx({}, '/api/supply-chain/v1/get-country-products'), { iso2: 'SG' }); + assert.deepEqual(res, { iso2: 'SG', products: [], fetchedAt: '' }); + }); + }); + + describe('getMultiSectorCostShock', () => { + const validReq = { iso2: 'SG', chokepointId: 'hormuz_strait', closureDays: 30 }; + + it('throws ValidationError on 3-letter iso code (legacy contract required ISO-2)', async () => { + await assert.rejects( + () => getMultiSectorCostShock(proCtx('/api/supply-chain/v1/get-multi-sector-cost-shock'), { ...validReq, iso2: 'USA' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'iso2', + ); + }); + + it('throws ValidationError on blank iso2', async () => { + await assert.rejects( + () => getMultiSectorCostShock(proCtx('/api/supply-chain/v1/get-multi-sector-cost-shock'), { ...validReq, iso2: '' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'iso2', + ); + }); + + it('throws ValidationError on missing chokepointId', async () => { + await assert.rejects( + () => getMultiSectorCostShock(proCtx('/api/supply-chain/v1/get-multi-sector-cost-shock'), { ...validReq, chokepointId: '' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'chokepointId' && /required/i.test(err.violations[0].description), + ); + }); + + it('throws ValidationError on unknown chokepointId', async () => { + await assert.rejects( + () => getMultiSectorCostShock(proCtx('/api/supply-chain/v1/get-multi-sector-cost-shock'), { ...validReq, chokepointId: 'not_a_real_chokepoint' }), + (err) => err instanceof ValidationError && err.violations[0].field === 'chokepointId' && /Unknown/.test(err.violations[0].description), + ); + }); + + it('PRO-gate deny on valid inputs still returns empty-200 (not 400) — contract shift preserved', async () => { + const res = await getMultiSectorCostShock(makeCtx({}, '/api/supply-chain/v1/get-multi-sector-cost-shock'), validReq); + assert.equal(res.iso2, 'SG'); + assert.equal(res.chokepointId, 'hormuz_strait'); + assert.equal(res.closureDays, 30); + assert.equal(res.totalAddedCost, 0); + assert.ok(Array.isArray(res.sectors), 'sectors is an array'); + }); + }); +}); diff --git a/api/_turnstile.test.mjs b/tests/turnstile.test.mjs similarity index 96% rename from api/_turnstile.test.mjs rename to tests/turnstile.test.mjs index 530b1aebd..20a4203c6 100644 --- a/api/_turnstile.test.mjs +++ b/tests/turnstile.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { getClientIp, verifyTurnstile } from './_turnstile.js'; +import { getClientIp, verifyTurnstile } from '../server/_shared/turnstile.ts'; const originalFetch = globalThis.fetch; const originalEnv = { ...process.env }; diff --git a/vite.config.ts b/vite.config.ts index ccc3604e0..4ee2316e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -195,6 +195,9 @@ function sebufApiPlugin(): Plugin { supplyChainServerMod, supplyChainHandlerMod, naturalServerMod, naturalHandlerMod, resilienceServerMod, resilienceHandlerMod, + leadsServerMod, leadsHandlerMod, + scenarioServerMod, scenarioHandlerMod, + shippingV2ServerMod, shippingV2HandlerMod, ] = await Promise.all([ import('./server/router'), import('./server/cors'), @@ -245,6 +248,12 @@ 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'), + import('./src/generated/server/worldmonitor/scenario/v1/service_server'), + import('./server/worldmonitor/scenario/v1/handler'), + import('./src/generated/server/worldmonitor/shipping/v2/service_server'), + import('./server/worldmonitor/shipping/v2/handler'), ]); const serverOptions = { onError: errorMod.mapErrorToResponse }; @@ -272,6 +281,9 @@ function sebufApiPlugin(): Plugin { ...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions), ...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions), ...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions), + ...leadsServerMod.createLeadsServiceRoutes(leadsHandlerMod.leadsHandler, serverOptions), + ...scenarioServerMod.createScenarioServiceRoutes(scenarioHandlerMod.scenarioHandler, serverOptions), + ...shippingV2ServerMod.createShippingV2ServiceRoutes(shippingV2HandlerMod.shippingV2Handler, serverOptions), ]; cachedCorsMod = corsMod; return routerMod.createRouter(allRoutes); @@ -287,12 +299,35 @@ function sebufApiPlugin(): Plugin { } }); + // Legacy v1 URL aliases → new sebuf RPC paths (mirror of the alias files + // in api/scenario/v1/ + api/supply-chain/v1/). Vercel serves the alias + // files directly; vite dev has no file-based routing for api/, so we + // rewrite the pathname here before the router lookup. + const V1_ALIASES: Record = { + '/api/scenario/v1/run': '/api/scenario/v1/run-scenario', + '/api/scenario/v1/status': '/api/scenario/v1/get-scenario-status', + '/api/scenario/v1/templates': '/api/scenario/v1/list-scenario-templates', + '/api/supply-chain/v1/country-products': '/api/supply-chain/v1/get-country-products', + '/api/supply-chain/v1/multi-sector-cost-shock': '/api/supply-chain/v1/get-multi-sector-cost-shock', + }; + server.middlewares.use(async (req, res, next) => { - // Only intercept sebuf routes: /api/{domain}/v1/* (domain may contain hyphens) - if (!req.url || !/^\/api\/[a-z-]+\/v1\//.test(req.url)) { + // Intercept sebuf routes in two forms: + // - standard /api/{domain}/v{N}/* (domain-first, e.g. /api/market/v1/...) + // - partner-URL-preservation /api/v{N}/{domain}/* (version-first, e.g. + // /api/v2/shipping/...). Only the second form applies when the + // external contract already uses a reversed layout. + if (!req.url || !/^\/api\/(?:[a-z][a-z0-9-]*\/v\d+|v\d+\/[a-z][a-z0-9-]*)\//.test(req.url)) { return next(); } + // Rewrite documented v1 URL → new sebuf path if this is an alias. + const [pathOnly, queryOnly] = req.url.split('?', 2); + const aliasTarget = pathOnly ? V1_ALIASES[pathOnly] : undefined; + if (aliasTarget) { + req.url = queryOnly ? `${aliasTarget}?${queryOnly}` : aliasTarget; + } + try { // Build router once, reuse across requests (H-13 fix) if (!cachedRouter) {