mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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)
154 lines
5.7 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
}
|