Files
worldmonitor/server/_shared/premium-check.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

53 lines
2.3 KiB
TypeScript

// @ts-expect-error — JS module, no declaration file
import { validateApiKey } from '../../api/_api-key.js';
import { validateBearerToken } from '../auth-session';
import { getEntitlements } from './entitlement-check';
import { validateUserApiKey } from './user-api-key';
/**
* Returns true when the caller has a valid API key OR a PRO bearer token.
* Used by handlers where the RPC endpoint is public but certain fields
* (e.g. framework/systemAppend) should only be honored for premium callers.
*/
export async function isCallerPremium(request: Request): Promise<boolean> {
// Browser tester keys — validateApiKey returns required:false for trusted origins
// even when a valid key is present, so we check the header directly first.
const wmKey =
request.headers.get('X-WorldMonitor-Key') ??
request.headers.get('X-Api-Key') ??
'';
if (wmKey) {
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS ?? '')
.split(',').map((k) => k.trim()).filter(Boolean);
if (validKeys.length > 0 && validKeys.includes(wmKey)) return true;
// Check user-owned API keys (wm_ prefix) via Convex lookup.
// Key existence alone is not sufficient — verify the owner's entitlement.
const userKey = await validateUserApiKey(wmKey);
if (userKey) {
const ent = await getEntitlements(userKey.userId);
if (ent && ent.features.apiAccess === true) return true;
return false;
}
}
const keyCheck = validateApiKey(request, {}) as { valid: boolean; required: boolean };
// Only treat as premium when an explicit API key was validated (required: true).
// Trusted-origin short-circuits (required: false) do NOT imply PRO entitlement.
if (keyCheck.valid && keyCheck.required) return true;
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const session = await validateBearerToken(authHeader.slice(7));
if (!session.valid) return false;
if (session.role === 'pro') return true;
// Clerk role isn't 'pro' — check Dodo entitlement tier as second signal.
// A Dodo subscriber (tier >= 1) is premium regardless of Clerk role.
if (session.userId) {
const ent = await getEntitlements(session.userId);
if (ent && ent.features.tier >= 1) return true;
}
}
return false;
}