Files
worldmonitor/api/widget-agent.ts
Elie Habib de769ce8e1 fix(api): unblock Pro API clients at edge + accept x-api-key alias (#3155)
* fix(api): unblock Pro API clients at edge + accept x-api-key alias

Fixes #3146: Pro API subscriber getting 403 when calling from Railway.
Two independent layers were blocking server-side callers:

1. Vercel Edge Middleware (middleware.ts) blocks any UA matching
   /bot|curl\/|python-requests|go-http|java\//, which killed every
   legitimate server-to-server API client before the gateway even saw
   the request. Add bypass: requests carrying an `x-worldmonitor-key`
   or `x-api-key` header that starts with `wm_` skip the UA gate.
   The prefix is a cheap client-side signal, not auth — downstream
   server/gateway.ts still hashes the key and validates against the
   Convex `userApiKeys` table + entitlement check.

2. Header name mismatch. Docs/gateway only accepted
   `X-WorldMonitor-Key`, but most API clients default to `x-api-key`.
   Accept both header names in:
     - api/_api-key.js (legacy static-key allowlist)
     - server/gateway.ts (user-issued Convex-backed keys)
     - server/_shared/premium-check.ts (isCallerPremium)
   Add `X-Api-Key` to CORS Allow-Headers in server/cors.ts and
   api/_cors.js so browser preflights succeed.

Follow-up outside this PR (Cloudflare dashboard, not in repo):
- Extend the "Allow api access with WM" custom WAF rule to also match
  `starts_with(http.request.headers["x-api-key"][0], "wm_")`, so CF
  Managed Rules don't block requests using the x-api-key header name.
- Update the api-cors-preflight CF Worker's corsHeaders to include
  `X-Api-Key` (memory: cors-cloudflare-worker.md — Worker overrides
  repo CORS on api.worldmonitor.app).

* fix(api): tighten middleware bypass shape + finish x-api-key alias coverage

Addresses review findings on #3155:

1. middleware.ts bypass was too loose. "Starts with wm_" let any caller
   send X-Api-Key: wm_fake and skip the UA gate, shifting unauthenticated
   scraper load onto the gateway's Convex lookup. Tighten to the exact
   key format emitted by src/services/api-keys.ts:generateKey —
   `^wm_[a-f0-9]{40}$` (wm_ + 20 random bytes as hex). Still a cheap
   edge heuristic (no hash lookup in middleware), but raises spoofing
   from trivial prefix match to a specific 43-char shape.

2. Alias was incomplete on bespoke endpoints outside the shared gateway:
   - api/v2/shipping/route-intelligence.ts: async wm_ user-key fallback
     now reads X-Api-Key as well
   - api/v2/shipping/webhooks.ts: webhook ownership fingerprint now
     reads X-Api-Key as well (same key value → same SHA-256 → same
     ownerTag, so a user registering with either header can manage
     their webhook from the other)
   - api/widget-agent.ts: accept X-Api-Key in the auth read AND in the
     OPTIONS Allow-Headers list
   - api/chat-analyst.ts: add X-Api-Key to the OPTIONS Allow-Headers
     list (auth path goes through shared helpers already aliased)
2026-04-18 08:18:49 +04:00

154 lines
5.7 KiB
TypeScript

/**
* Vercel edge proxy for the widget agent.
*
* Auth paths:
* 1. Clerk JWT (Authorization: Bearer <token>) — validates plan === 'pro',
* then injects real server keys and proxies to the Railway relay.
* 2. Browser tester key (X-WorldMonitor-Key) — validated against
* WORLDMONITOR_VALID_KEYS so one browser-held key can unlock premium
* testing paths across the app.
* 3. Legacy tester keys (X-Widget-Key / X-Pro-Key) — validated directly here
* so the relay's WIDGET_AGENT_KEY / PRO_WIDGET_KEY are never exposed
* to the browser.
*
* GET → proxy to relay /widget-agent/health
* POST → proxy SSE stream to relay /widget-agent
*/
export const config = { runtime: 'edge' };
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders } from './_cors.js';
import { validateBearerToken } from '../server/auth-session';
const RELAY_BASE = 'https://proxy.worldmonitor.app';
const WIDGET_AGENT_KEY = process.env.WIDGET_AGENT_KEY ?? '';
const PRO_WIDGET_KEY = process.env.PRO_WIDGET_KEY ?? '';
const WORLDMONITOR_VALID_KEY_SET = new Set(
(process.env.WORLDMONITOR_VALID_KEYS ?? '')
.split(',')
.map((v) => v.trim())
.filter(Boolean),
);
function hasValidWorldMonitorKey(key: string): boolean {
return Boolean(key) && WORLDMONITOR_VALID_KEY_SET.has(key);
}
function json(body: unknown, status: number, cors: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
export default async function handler(req: Request): Promise<Response> {
const corsHeaders = getCorsHeaders(req) as Record<string, string>;
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...corsHeaders,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key',
},
});
}
// ── Auth ──────────────────────────────────────────────────────────────────
let isPro = false;
const worldMonitorKey =
req.headers.get('X-WorldMonitor-Key') ??
req.headers.get('X-Api-Key') ??
'';
if (hasValidWorldMonitorKey(worldMonitorKey)) {
isPro = true;
} else {
const authHeader = req.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
// Clerk JWT path (web users with active subscription)
const session = await validateBearerToken(authHeader.slice(7));
if (!session.valid) {
return json({ error: 'Invalid or expired session' }, 401, corsHeaders);
}
if (session.role !== 'pro') {
return json({ error: 'Pro subscription required' }, 403, corsHeaders);
}
isPro = true;
} else {
// Legacy tester key path (wm-widget-key / wm-pro-key)
const widgetKey = req.headers.get('X-Widget-Key') ?? '';
const proKey = req.headers.get('X-Pro-Key') ?? '';
const hasWidgetKey = Boolean(WIDGET_AGENT_KEY && widgetKey === WIDGET_AGENT_KEY);
const hasProKey = Boolean(PRO_WIDGET_KEY && proKey === PRO_WIDGET_KEY);
if (!hasWidgetKey && !hasProKey) {
return json({ error: 'Forbidden' }, 403, corsHeaders);
}
isPro = hasProKey;
}
}
// Mirror the relay P2 fix: allow PRO-only deployments (no basic key, but PRO key present)
if (!WIDGET_AGENT_KEY && !PRO_WIDGET_KEY) {
return json({ error: 'Widget agent unavailable', ok: false, widgetKeyConfigured: false }, 503, corsHeaders);
}
// ── Build relay headers (server-side keys, never exposed to browser) ──────
const relayHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'worldmonitor-widget-edge/1.0',
...(WIDGET_AGENT_KEY ? { 'X-Widget-Key': WIDGET_AGENT_KEY } : {}),
};
if (isPro && PRO_WIDGET_KEY) {
relayHeaders['X-Pro-Key'] = PRO_WIDGET_KEY;
}
// ── Health check (GET) ────────────────────────────────────────────────────
if (req.method === 'GET') {
const healthRes = await fetch(`${RELAY_BASE}/widget-agent/health`, {
method: 'GET',
headers: relayHeaders,
});
const body = await healthRes.text();
return new Response(body, {
status: healthRes.status,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (req.method !== 'POST') {
return json({ error: 'Method not allowed' }, 405, corsHeaders);
}
// ── Agent call (POST, SSE stream) ─────────────────────────────────────────
let rawBody = await req.text();
// Normalise tier in body to match the server-validated isPro flag.
// Prevents the relay from seeing tier:pro without the matching X-Pro-Key.
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
const expectedTier = isPro ? 'pro' : 'basic';
if (parsed.tier !== expectedTier) {
rawBody = JSON.stringify({ ...parsed, tier: expectedTier });
}
} catch { /* malformed body — relay will return 400 */ }
const relayRes = await fetch(`${RELAY_BASE}/widget-agent`, {
method: 'POST',
headers: relayHeaders,
body: rawBody,
});
return new Response(relayRes.body, {
status: relayRes.status,
headers: {
'Content-Type': relayRes.headers.get('Content-Type') ?? 'text/event-stream',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
...corsHeaders,
},
});
}