File size: 100,739 Bytes
20f83d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 | /**
* Shared gateway logic for per-domain Vercel edge functions.
*
* Each domain edge function calls `createDomainGateway(routes)` to get a
* request handler that applies CORS, API-key validation, rate limiting,
* POST-to-GET compat, error boundary, and cache-tier headers.
*
* Splitting domains into separate edge functions means Vercel bundles only the
* code for one domain per function, cutting cold-start cost by ~20Γ.
*/
import { createRouter, type RouteDescriptor } from './router';
import { getCorsHeaders, isDisallowedOrigin, isAllowedOrigin } from './cors';
import { isPublicSharedRpcRequest } from '../src/shared/public-rpc-cache';
import { PRO_FRESH_CACHE_RPC_PATHS } from '../src/shared/pro-fresh-rpc';
// @ts-expect-error β JS module, no declaration file
import { USER_API_KEY_GATEWAY_VALIDATION_ERROR, validateApiKey } from '../api/_api-key.js';
// @ts-expect-error β JS module, no declaration file
import { timingSafeEqualSecret } from '../api/_crypto.js';
// @ts-expect-error β JS module, no declaration file
import { captureSilentError } from '../api/_sentry-edge.js';
import { mapErrorToResponse } from './error-mapper';
import {
checkRateLimit,
checkEndpointRateLimit,
checkFailClosedScopedIpRateLimit,
hasEndpointRatePolicy,
} from './_shared/rate-limit';
import {
drainResponseHeaders,
drainRetryableResponse,
drainSuccessStatusOverride,
} from './_shared/response-headers';
import { projectJsonResponse } from './_shared/response-projection';
import { getRpcNoStoreReasonFromJson } from './_shared/cache-contract';
import {
checkEntitlementDetailed,
getBillingVerificationDenial,
getRequiredTier,
getEntitlements,
isEntitlementBackendConfigured,
type CachedEntitlements,
} from './_shared/entitlement-check';
import { checkProMcpAccess } from './_shared/pro-mcp-gate';
import { resolveClerkSession } from './_shared/auth-session';
import {
INTERNAL_MCP_SIG_HEADER,
INTERNAL_MCP_USER_ID_HEADER,
INTERNAL_MCP_NONCE_HEADER,
INTERNAL_MCP_VERIFIED_HEADER,
TRUSTED_USER_ID_HEADER,
INTERNAL_MCP_REPLAY_CACHE_TTL_SECONDS,
getInternalMcpVerifiedNonce,
sha256Hex,
verifyInternalMcpRequest,
} from './_shared/mcp-internal-hmac';
import { buildUsageIdentity, hashKeySync, type UsageIdentityInput } from './_shared/usage-identity';
import { runRedisPipeline } from './_shared/redis';
import {
beginIdempotency,
peekIdempotency,
IDEMPOTENCY_HEADER,
IDEMPOTENT_REPLAYED_HEADER,
type IdempotencyOutcome,
} from './_shared/idempotency';
import {
checkBurst,
reserveDailyMeter,
rateLimitHeaders,
ENTERPRISE_API_RATE_LIMIT,
} from './_shared/api-key-rate-limit';
import {
DIRECT_LLM_DAILY_QUOTA_LIMIT,
DIRECT_LLM_GATEWAY_QUOTA_PATHS,
reserveDirectLlmQuota,
} from './_shared/direct-llm-quota';
import {
deliverUsageEvents,
buildRequestEvent,
deriveRequestId,
deriveExecutionRegion,
deriveCountry,
deriveIpCity,
deriveIpRegion,
deriveReqBytes,
deriveSentryTraceId,
deriveOriginKind,
deriveUaHash,
deriveIp,
deriveUserAgent,
deriveReferer,
deriveAcceptLanguage,
deriveHost,
maybeAttachDevHealthHeader,
runWithUsageScope,
type CacheTier as UsageCacheTier,
type RequestReason,
} from './_shared/usage';
import { timingSafeEqual } from './_shared/internal-auth';
import type { ServerOptions } from '../src/generated/server/worldmonitor/seismology/v1/service_server';
export const serverOptions: ServerOptions = { onError: mapErrorToResponse };
/**
* Internal-MCP request body size cap (256 KB). Internal-MCP fetches
* carry small JSON-RPC params; this ceiling prevents the gateway from
* buffering arbitrarily large bodies on the strip / HMAC-verify paths.
*
* Applied at:
* - The trust-marker strip block (any Pro-marked inbound request)
* - The HMAC-verify block (signed internal-MCP requests)
*
* Both Content-Length AND post-buffer byte count are checked because
* Content-Length can be absent / wrong for chunked or streamed bodies.
*
* F8 (U7+U8 review pass).
*/
const MAX_INTERNAL_MCP_BODY = 256 * 1024;
type InternalMcpReplayClaim = 'fresh' | 'replay' | 'unavailable';
function getRateLimitTelemetryReason(
response: Response,
rejectedReason: RequestReason,
): RequestReason {
return response.status === 503 &&
response.headers.get('X-RateLimit-Mode') === 'degraded'
? 'rate_limit_degraded'
: rejectedReason;
}
async function claimInternalMcpReplayNonce(userId: string, nonce: string): Promise<InternalMcpReplayClaim> {
const digest = await sha256Hex(`${userId}:${nonce}`);
const key = `internal-mcp-replay:v1:${digest}`;
const result = await runRedisPipeline([
['SET', key, '1', 'EX', INTERNAL_MCP_REPLAY_CACHE_TTL_SECONDS, 'NX'],
]);
if (result.length === 0) return 'unavailable';
const claim = result[0] as { result?: unknown; error?: unknown } | undefined;
if (claim?.error) return 'unavailable';
return claim?.result === 'OK' ? 'fresh' : 'replay';
}
// --- Edge cache tier definitions ---
// NOTE: This map is shared across all domain bundles (~3KB). Kept centralised for
// single-source-of-truth maintainability; the size is negligible vs handler code.
type CacheTier = 'fast' | 'medium' | 'slow' | 'slow-browser' | 'live-browser' | 'static' | 'daily' | 'no-store' | 'live';
// Three-tier caching: browser (max-age) β CF edge (s-maxage) β Vercel CDN (CDN-Cache-Control).
// CF ignores Vary: Origin so it may pin a single ACAO value, but this is acceptable
// since production traffic is same-origin and preview deployments hit Vercel CDN directly.
//
// 'live' tier (60s) is for endpoints with strict freshness contracts β the
// energy-atlas live-tanker map layer requires position fixes to refresh on
// the order of one minute. Every shorter-than-medium tier is custom; we keep
// the existing tiers untouched so unrelated endpoints aren't impacted.
const TIER_HEADERS: Record<CacheTier, string> = {
fast: 'public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=600',
medium: 'public, max-age=120, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
slow: 'public, max-age=300, s-maxage=1800, stale-while-revalidate=300, stale-if-error=3600',
'slow-browser': 'max-age=300, stale-while-revalidate=60, stale-if-error=1800',
'live-browser': 'private, max-age=30, stale-while-revalidate=60, stale-if-error=300',
static: 'public, max-age=600, s-maxage=3600, stale-while-revalidate=600, stale-if-error=14400',
daily: 'public, max-age=3600, s-maxage=14400, stale-while-revalidate=7200, stale-if-error=172800',
'no-store': 'no-store',
live: 'public, max-age=30, s-maxage=60, stale-while-revalidate=60, stale-if-error=300',
};
// Vercel CDN-specific cache TTLs β CDN-Cache-Control overrides Cache-Control for
// Vercel's own edge cache, so Vercel can still cache aggressively (and respects
// Vary: Origin correctly) while CF sees no public s-maxage and passes through.
const TIER_CDN_CACHE: Record<CacheTier, string | null> = {
fast: 'public, s-maxage=600, stale-while-revalidate=300, stale-if-error=1200',
medium: 'public, s-maxage=1200, stale-while-revalidate=600, stale-if-error=1800',
slow: 'public, s-maxage=3600, stale-while-revalidate=900, stale-if-error=7200',
'slow-browser': 'public, s-maxage=900, stale-while-revalidate=60, stale-if-error=1800',
'live-browser': null,
static: 'public, s-maxage=14400, stale-while-revalidate=3600, stale-if-error=28800',
daily: 'public, s-maxage=86400, stale-while-revalidate=14400, stale-if-error=172800',
'no-store': null,
live: 'public, s-maxage=60, stale-while-revalidate=60, stale-if-error=300',
};
const RPC_CACHE_TIER: Record<string, CacheTier> = {
// 'live' tier β bbox-quantized + tanker-aware caching upstream of the
// 60s in-handler cache, absorbing identical-bbox requests at the CDN
// before they hit this Vercel function. Energy Atlas live-tanker layer.
'/api/maritime/v1/get-vessel-snapshot': 'live',
'/api/market/v1/list-market-quotes': 'medium',
'/api/market/v1/list-crypto-quotes': 'medium',
'/api/market/v1/list-crypto-sectors': 'slow',
'/api/market/v1/list-defi-tokens': 'slow',
'/api/market/v1/list-ai-tokens': 'slow',
'/api/market/v1/list-other-tokens': 'slow',
'/api/market/v1/list-commodity-quotes': 'medium',
'/api/market/v1/list-stablecoin-markets': 'medium',
'/api/market/v1/get-sector-summary': 'medium',
'/api/market/v1/get-fear-greed-index': 'slow',
'/api/market/v1/get-market-breadth-history': 'daily',
'/api/market/v1/list-gulf-quotes': 'medium',
'/api/market/v1/analyze-stock': 'slow',
'/api/market/v1/get-stock-analysis-history': 'medium',
'/api/market/v1/backtest-stock': 'slow',
'/api/market/v1/list-stored-stock-backtests': 'medium',
'/api/infrastructure/v1/list-service-statuses': 'slow',
'/api/seismology/v1/list-earthquakes': 'slow',
'/api/infrastructure/v1/list-internet-outages': 'slow',
'/api/infrastructure/v1/list-internet-ddos-attacks': 'slow',
'/api/infrastructure/v1/list-internet-traffic-anomalies': 'slow',
'/api/forecast/v1/get-forecast-scorecard': 'fast',
'/api/unrest/v1/list-unrest-events': 'slow',
'/api/cyber/v1/list-cyber-threats': 'static',
'/api/conflict/v1/list-acled-events': 'slow',
'/api/military/v1/get-theater-posture': 'slow',
'/api/infrastructure/v1/get-temporal-baseline': 'slow',
'/api/aviation/v1/list-airport-delays': 'static',
'/api/aviation/v1/get-airport-ops-summary': 'static',
'/api/aviation/v1/list-airport-flights': 'static',
'/api/aviation/v1/get-carrier-ops': 'slow',
'/api/aviation/v1/get-flight-status': 'fast',
'/api/aviation/v1/track-aircraft': 'no-store',
'/api/aviation/v1/search-flight-prices': 'medium',
'/api/aviation/v1/search-google-flights': 'no-store',
'/api/aviation/v1/search-google-dates': 'medium',
'/api/aviation/v1/list-aviation-news': 'slow',
'/api/market/v1/get-country-stock-index': 'slow',
'/api/natural/v1/list-natural-events': 'slow',
'/api/wildfire/v1/list-fire-detections': 'static',
'/api/maritime/v1/list-navigational-warnings': 'static',
'/api/supply-chain/v1/get-china-corridor-control-towers': 'medium',
'/api/supply-chain/v1/get-shipping-rates': 'daily',
'/api/supply-chain/v1/list-pipelines': 'static',
'/api/supply-chain/v1/get-pipeline-detail': 'static',
'/api/supply-chain/v1/list-storage-facilities': 'static',
'/api/supply-chain/v1/get-storage-facility-detail': 'static',
'/api/supply-chain/v1/list-fuel-shortages': 'medium',
'/api/supply-chain/v1/get-fuel-shortage-detail': 'medium',
'/api/supply-chain/v1/list-energy-disruptions': 'medium',
'/api/economic/v1/get-fred-series': 'static',
'/api/economic/v1/get-bls-series': 'daily',
'/api/economic/v1/get-energy-prices': 'static',
'/api/research/v1/list-arxiv-papers': 'static',
'/api/research/v1/list-trending-repos': 'static',
'/api/giving/v1/get-giving-summary': 'static',
'/api/intelligence/v1/get-country-intel-brief': 'static',
// The canonical Railway projection refreshes every 15 minutes. Keep the
// public composition route's Vercel TTL (10m on fast) inside that cadence so
// the seeder cannot keep re-publishing a two-hour-old medium-tier response.
'/api/intelligence/v1/get-china-decision-signals': 'fast',
'/api/intelligence/v1/get-gdelt-topic-timeline': 'medium',
'/api/climate/v1/list-climate-anomalies': 'daily',
'/api/climate/v1/list-climate-disasters': 'daily',
'/api/climate/v1/get-co2-monitoring': 'daily',
'/api/climate/v1/get-ocean-ice-data': 'daily',
'/api/climate/v1/list-air-quality-data': 'fast',
'/api/climate/v1/list-climate-news': 'slow',
'/api/sanctions/v1/list-sanctions-pressure': 'daily',
'/api/sanctions/v1/lookup-sanction-entity': 'no-store',
'/api/radiation/v1/list-radiation-observations': 'slow',
'/api/thermal/v1/list-thermal-escalations': 'slow',
'/api/research/v1/list-tech-events': 'daily',
'/api/military/v1/get-usni-fleet-report': 'daily',
'/api/military/v1/list-defense-patents': 'daily',
'/api/conflict/v1/list-ucdp-events': 'daily',
'/api/conflict/v1/get-humanitarian-summary': 'daily',
'/api/conflict/v1/list-iran-events': 'slow',
'/api/displacement/v1/get-displacement-summary': 'daily',
'/api/displacement/v1/get-population-exposure': 'daily',
'/api/economic/v1/get-bis-policy-rates': 'daily',
'/api/economic/v1/get-bis-exchange-rates': 'daily',
'/api/economic/v1/get-bis-credit': 'daily',
'/api/trade/v1/get-tariff-trends': 'daily',
'/api/trade/v1/get-trade-flows': 'daily',
'/api/trade/v1/get-trade-barriers': 'daily',
'/api/trade/v1/get-trade-restrictions': 'daily',
'/api/trade/v1/get-customs-revenue': 'daily',
'/api/trade/v1/list-comtrade-flows': 'daily',
'/api/economic/v1/list-world-bank-indicators': 'daily',
'/api/economic/v1/get-energy-capacity': 'daily',
'/api/economic/v1/list-grocery-basket-prices': 'daily',
'/api/economic/v1/list-bigmac-prices': 'daily',
'/api/economic/v1/list-fuel-prices': 'daily',
'/api/economic/v1/get-fao-food-price-index': 'daily',
'/api/economic/v1/get-crude-inventories': 'daily',
'/api/economic/v1/get-nat-gas-storage': 'daily',
'/api/economic/v1/get-eu-yield-curve': 'daily',
'/api/supply-chain/v1/get-critical-minerals': 'daily',
'/api/military/v1/get-aircraft-details': 'static',
'/api/military/v1/get-wingbits-status': 'static',
'/api/military/v1/get-wingbits-live-flight': 'no-store',
'/api/military/v1/list-military-flights': 'slow',
'/api/market/v1/list-etf-flows': 'slow',
'/api/research/v1/list-hackernews-items': 'slow',
'/api/intelligence/v1/get-country-risk': 'slow',
'/api/intelligence/v1/get-risk-scores': 'slow',
'/api/intelligence/v1/get-pizzint-status': 'slow',
'/api/intelligence/v1/classify-event': 'static',
'/api/intelligence/v1/search-gdelt-documents': 'slow',
'/api/infrastructure/v1/get-cable-health': 'slow',
'/api/positive-events/v1/list-positive-geo-events': 'slow',
'/api/military/v1/list-military-bases': 'daily',
'/api/economic/v1/get-macro-signals': 'medium',
'/api/economic/v1/get-national-debt': 'daily',
'/api/prediction/v1/list-prediction-markets': 'medium',
'/api/forecast/v1/get-forecasts': 'medium',
'/api/forecast/v1/get-simulation-package': 'slow',
'/api/forecast/v1/get-simulation-outcome': 'slow',
'/api/supply-chain/v1/get-chokepoint-status': 'medium',
'/api/supply-chain/v1/get-chokepoint-history': 'slow',
'/api/news/v1/list-feed-digest': 'slow',
'/api/intelligence/v1/get-country-facts': 'daily',
'/api/intelligence/v1/list-security-advisories': 'slow',
'/api/intelligence/v1/list-satellites': 'static',
'/api/intelligence/v1/list-gps-interference': 'slow',
'/api/intelligence/v1/list-cross-source-signals': 'medium',
'/api/intelligence/v1/list-oref-alerts': 'fast',
'/api/intelligence/v1/list-telegram-feed': 'fast',
'/api/intelligence/v1/get-company-enrichment': 'slow',
'/api/intelligence/v1/list-company-signals': 'slow',
'/api/intelligence/v1/search-sec-filings': 'medium',
'/api/intelligence/v1/list-material-events': 'medium',
'/api/news/v1/summarize-article-cache': 'slow',
'/api/imagery/v1/search-imagery': 'static',
'/api/infrastructure/v1/list-temporal-anomalies': 'medium',
'/api/infrastructure/v1/get-ip-geo': 'no-store',
'/api/infrastructure/v1/reverse-geocode': 'slow',
'/api/infrastructure/v1/get-bootstrap-data': 'no-store',
'/api/webcam/v1/get-webcam-image': 'no-store',
'/api/webcam/v1/list-webcams': 'no-store',
'/api/consumer-prices/v1/get-consumer-price-overview': 'slow',
'/api/consumer-prices/v1/get-consumer-price-basket-series': 'slow',
'/api/consumer-prices/v1/list-consumer-price-categories': 'slow',
'/api/consumer-prices/v1/list-consumer-price-movers': 'slow',
'/api/consumer-prices/v1/list-retailer-price-spreads': 'slow',
'/api/consumer-prices/v1/get-consumer-price-freshness': 'slow',
'/api/aviation/v1/get-youtube-live-stream-info': 'fast',
'/api/market/v1/list-earnings-calendar': 'slow',
'/api/market/v1/get-cot-positioning': 'slow',
'/api/market/v1/get-gold-intelligence': 'slow',
'/api/market/v1/get-hyperliquid-flow': 'medium',
'/api/market/v1/get-insider-transactions': 'slow',
'/api/economic/v1/get-economic-calendar': 'slow',
'/api/economic/v1/get-china-macro-snapshot': 'slow',
'/api/economic/v1/get-china-activity-nowcast': 'medium',
'/api/intelligence/v1/list-market-implications': 'slow',
'/api/economic/v1/get-ecb-fx-rates': 'slow',
'/api/economic/v1/get-eurostat-country-data': 'slow',
'/api/economic/v1/get-eu-gas-storage': 'slow',
'/api/economic/v1/get-oil-stocks-analysis': 'static',
'/api/economic/v1/get-oil-inventories': 'slow',
'/api/economic/v1/get-energy-crisis-policies': 'static',
'/api/economic/v1/list-global-tenders': 'medium',
'/api/economic/v1/get-eu-fsi': 'slow',
'/api/economic/v1/get-economic-stress': 'slow',
'/api/supply-chain/v1/get-shipping-stress': 'medium',
'/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',
'/api/intelligence/v1/get-country-energy-profile': 'slow',
'/api/intelligence/v1/compute-energy-shock': 'fast',
'/api/intelligence/v1/get-country-port-activity': 'slow',
// NOTE: get-regional-snapshot is premium-gated via PREMIUM_RPC_PATHS; the
// gateway short-circuits to 'slow-browser' before consulting this map. The
// entry below exists to satisfy the parity contract enforced by
// tests/route-cache-tier.test.mjs (every generated GET route needs a tier)
// and documents the intended tier if the endpoint ever becomes non-premium.
'/api/intelligence/v1/get-regional-snapshot': 'slow',
// get-regime-history is premium-gated same as get-regional-snapshot; this
// entry is required by tests/route-cache-tier.test.mjs even though the
// gateway short-circuits premium paths to slow-browser.
'/api/intelligence/v1/get-regime-history': 'slow',
// get-regional-brief is premium-gated; slow-browser in practice, slow entry for route-parity.
'/api/intelligence/v1/get-regional-brief': 'slow',
// Historical intelligence memory (#5694) β the timeline is a generated GET
// and therefore requires an explicit gateway cache tier. The two semantic
// reads are POSTs and cache successful results inside their handlers.
'/api/intelligence/v1/get-intel-timeline': 'slow',
'/api/resilience/v1/get-resilience-score': 'slow',
'/api/resilience/v1/get-resilience-ranking': 'slow',
'/api/resilience/v1/get-runtime-manifest': 'no-store',
// 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';
export const PUBLIC_NO_AUTH_RPC_PATHS = new Set<string>([
'/api/conflict/v1/list-acled-events',
'/api/natural/v1/list-natural-events',
'/api/intelligence/v1/get-china-decision-signals',
'/api/resilience/v1/get-runtime-manifest',
'/api/seismology/v1/list-earthquakes',
'/api/unrest/v1/list-unrest-events',
// Lead-capture RPCs serve ANONYMOUS prospects by definition: the /pro
// marketing page contact form and the waitlist/desktop signup both POST
// without a wms_ session or API key (see pro-test/src/App.tsx onSubmit and
// src/services/runtime.ts isKeyFreeApiTarget). A freely-mintable anonymous
// session token would add zero abuse protection here β the real gates live
// in the handlers: server-side Turnstile (fails closed in production),
// honeypot, free-email-domain rejection, per-IP endpoint rate limits
// (server/_shared/rate-limit.ts: 3/h and 5/h), and the Convex per-email
// throttle. Pinned by tests/leads-gateway-public.test.mts.
'/api/leads/v1/submit-contact',
'/api/leads/v1/register-interest',
]);
// Cacheable, non-premium RPC endpoints the Railway relay periodically warm-pings
// to keep their compute caches hot (so the first real user request isn't a cold
// miss). These require a browser session token or an API key in normal traffic;
// the relay is a trusted internal service with neither, so it authenticates as
// itself via WORLDMONITOR_RELAY_KEY (validated below in isRelayWarmPingRequest).
//
// Least privilege: WORLDMONITOR_RELAY_KEY is a DEDICATED relayβgateway secret β
// it does NOT need to be (and should not be) a WORLDMONITOR_VALID_KEYS enterprise
// key. It unlocks ONLY a cache-warm on these specific free endpoints β exactly
// what any session holder could already trigger β so the blast radius of the
// secret is a recompute on public data: no premium access, no entitlement bypass
// beyond anonymous-equivalent. Mirrors the isResilienceRankingSeedRefreshRequest
// internal-auth path below.
export const RELAY_WARM_PING_PATHS = new Set<string>([
'/api/infrastructure/v1/list-service-statuses',
'/api/infrastructure/v1/get-cable-health',
'/api/infrastructure/v1/list-temporal-anomalies',
'/api/intelligence/v1/get-risk-scores',
'/api/supply-chain/v1/get-chokepoint-status',
]);
/**
* Creates a Vercel Edge handler for a single domain's routes.
*
* Applies the full gateway pipeline: origin check β CORS β OPTIONS preflight β
* API key β rate limit β route match (with POSTβGET compat) β execute β cache headers.
*/
export type GatewayCtx = { waitUntil: (p: Promise<unknown>) => void };
const POST_TO_GET_MAX_BODY_BYTES = 1_048_576;
const POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY = 200;
export const REQUIRED_BBOX_QUERY_PARAMS = ['sw_lat', 'sw_lon', 'ne_lat', 'ne_lon'] as const;
// Issue #4595 is scoped to military RPCs whose handlers require bbox.
// Other bbox-capable RPCs support lookup/global modes and must not emit this diagnostic.
export const REQUIRED_BBOX_RPC_PATHS = [
'/api/military/v1/list-military-bases',
'/api/military/v1/list-military-flights',
] as const;
const REQUIRED_BBOX_RPC_PATH_SET = new Set<string>(REQUIRED_BBOX_RPC_PATHS);
const MILITARY_BBOX_DIAGNOSTIC_PATH_SET = new Set<string>(REQUIRED_BBOX_RPC_PATHS);
function isPostToGetCompatibleBodySize(headers: Headers): boolean {
const rawContentLength = headers.get('Content-Length');
if (rawContentLength === null || !/^\d+$/.test(rawContentLength)) return false;
const contentLength = Number(rawContentLength);
return Number.isSafeInteger(contentLength) && contentLength < POST_TO_GET_MAX_BODY_BYTES;
}
function getRequiredBboxQueryProblems(searchParams: URLSearchParams): { missing: string[]; invalid: string[]; allZero: boolean } {
const absent: string[] = [];
const invalid: string[] = [];
const values: number[] = [];
for (const param of REQUIRED_BBOX_QUERY_PARAMS) {
const raw = searchParams.get(param);
if (raw == null) {
absent.push(param);
continue;
}
if (raw.trim() === '') {
invalid.push(param);
continue;
}
const value = Number(raw);
if (!Number.isFinite(value)) {
invalid.push(param);
continue;
}
values.push(value);
}
const missing = absent.length === REQUIRED_BBOX_QUERY_PARAMS.length ? [...REQUIRED_BBOX_QUERY_PARAMS] : [];
return {
missing,
invalid,
allZero: absent.length === 0 && invalid.length === 0 && values.every((value) => value === 0),
};
}
type RequiredBboxDiagnostic = {
status: 'missing' | 'invalid';
missing: string[];
invalid: string[];
};
function getRequiredBboxDiagnostic(request: Request, pathname: string): RequiredBboxDiagnostic | null {
if (!REQUIRED_BBOX_RPC_PATH_SET.has(pathname)) return null;
const { searchParams } = new URL(request.url);
const { missing, invalid, allZero } = getRequiredBboxQueryProblems(searchParams);
if (missing.length === 0 && invalid.length === 0 && !allZero) return null;
return {
status: missing.length > 0 ? 'missing' : 'invalid',
missing,
invalid: allZero ? [...REQUIRED_BBOX_QUERY_PARAMS] : invalid,
};
}
function attachRequiredBboxDiagnosticHeaders(
headers: Headers,
pathname: string,
diagnostic: RequiredBboxDiagnostic | null,
): void {
if (!diagnostic) return;
headers.set('X-WorldMonitor-Bbox', diagnostic.status);
if (diagnostic.missing.length > 0) headers.set('X-WorldMonitor-Bbox-Missing', diagnostic.missing.join(','));
if (diagnostic.invalid.length > 0) headers.set('X-WorldMonitor-Bbox-Invalid', diagnostic.invalid.join(','));
if (MILITARY_BBOX_DIAGNOSTIC_PATH_SET.has(pathname)) {
// Issue #4595 explicitly requested the military alias; keep it as a stable consumer affordance.
headers.set('X-Military-Bbox', diagnostic.status);
}
}
// `TRUSTED_USER_ID_HEADER` (a.k.a. `x-user-id`) is gateway-internal: the
// gateway is the ONLY layer permitted to set it, and it must reflect an
// authenticated principal. Inbound client copies are stripped at handler
// entry (see stripClientUserIdHeader); the authenticated value is re-
// injected after Clerk / wm_ user-key / legacy bearer auth via
// withAuthenticatedUserId. The internal-MCP block below has its own
// strip-and-rebuild step that ALSO strips this header alongside
// INTERNAL_MCP_VERIFIED_HEADER β both layers are defense-in-depth.
function cloneRequestWithHeaders(request: Request, headers: Headers): Request {
return new Request(request, { headers });
}
function stripClientUserIdHeader(request: Request): Request {
if (!request.headers.has(TRUSTED_USER_ID_HEADER)) return request;
const headers = new Headers(request.headers);
headers.delete(TRUSTED_USER_ID_HEADER);
return cloneRequestWithHeaders(request, headers);
}
function withAuthenticatedUserId(request: Request, userId: string): Request {
const headers = new Headers(request.headers);
headers.set(TRUSTED_USER_ID_HEADER, userId);
return cloneRequestWithHeaders(request, headers);
}
function normalizeAuthError(error: string | undefined): string {
if (!error || error === USER_API_KEY_GATEWAY_VALIDATION_ERROR) return 'Invalid API key';
return error;
}
function createGatewayAuthErrorResponse(
status: 401 | 403,
error: string | undefined,
corsHeaders: Record<string, string>,
): Response {
return new Response(JSON.stringify({ error: normalizeAuthError(error) }), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...corsHeaders,
},
});
}
const GATEWAY_DIRECT_LLM_QUOTA_METHODS: Record<string, string> = {
'/api/intelligence/v1/classify-event': 'GET',
'/api/intelligence/v1/deduct-situation': 'POST',
'/api/intelligence/v1/get-country-intel-brief': 'GET',
'/api/market/v1/analyze-stock': 'GET',
'/api/news/v1/summarize-article': 'POST',
};
async function shouldReserveGatewayDirectLlmQuota(request: Request, pathname: string): Promise<boolean> {
if (!DIRECT_LLM_GATEWAY_QUOTA_PATHS.has(pathname)) return false;
if (GATEWAY_DIRECT_LLM_QUOTA_METHODS[pathname] !== request.method) return false;
if (pathname !== '/api/news/v1/summarize-article') return true;
const contentLength = Number(request.headers.get('Content-Length') ?? '0');
if (Number.isFinite(contentLength) && contentLength >= POST_TO_GET_MAX_BODY_BYTES) {
return true;
}
try {
const body = await request.clone().json() as { mode?: unknown };
return body.mode !== 'translate';
} catch {
// Malformed summarize requests cannot reach provider spend; let the handler
// return the established validation error without charging quota.
return false;
}
}
function createDirectLlmQuotaFailureResponse(
reservation: Awaited<ReturnType<typeof reserveDirectLlmQuota>>,
corsHeaders: Record<string, string>,
): Response {
if (reservation.ok) {
throw new Error('createDirectLlmQuotaFailureResponse called for successful reservation');
}
if (reservation.reason === 'cap-exceeded') {
return new Response(JSON.stringify({
error: 'Direct LLM daily quota exceeded',
limit: DIRECT_LLM_DAILY_QUOTA_LIMIT,
resetsAt: 'next UTC midnight',
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': String(reservation.retryAfterSec),
...corsHeaders,
},
});
}
return new Response(JSON.stringify({ error: 'Direct LLM quota unavailable' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': String(reservation.retryAfterSec),
...corsHeaders,
},
});
}
function markAuthErrorNoStore(response: Response): Response {
response.headers.set('Cache-Control', 'no-store');
response.headers.delete('CDN-Cache-Control');
response.headers.delete('Vercel-CDN-Cache-Control');
return response;
}
function hasCredentialBearingHeader(request: Request): boolean {
return Boolean(
request.headers.get('Authorization') ||
request.headers.get('X-WorldMonitor-Key') ||
request.headers.get('X-Api-Key') ||
request.headers.get('Cookie'),
);
}
async function isResilienceRankingSeedRefreshRequest(request: Request, pathname: string): Promise<boolean> {
if (pathname !== '/api/resilience/v1/get-resilience-ranking') return false;
const expected = process.env.WORLDMONITOR_SEED_REFRESH_KEY?.trim() ?? '';
if (!expected) return false;
try {
const url = new URL(request.url);
if (url.searchParams.get('refresh') !== '1') return false;
} catch {
return false;
}
const candidate = request.headers.get('X-WorldMonitor-Key') ?? '';
return timingSafeEqual(candidate, expected);
}
// Authenticate a relay warm-ping as a trusted internal caller. True only when
// the path is an explicit warm-ping target AND the request carries the dedicated
// relay secret in X-WorldMonitor-Key (timing-safe compared). Returns false when
// the secret is unset so a misconfigured deploy fails CLOSED (no bypass) rather
// than silently opening these paths. Mirrors isResilienceRankingSeedRefreshRequest.
export async function isRelayWarmPingRequest(request: Request, pathname: string): Promise<boolean> {
if (!RELAY_WARM_PING_PATHS.has(pathname)) return false;
const expected = process.env.WORLDMONITOR_RELAY_KEY?.trim() ?? '';
if (!expected) return false;
const candidate = request.headers.get('X-WorldMonitor-Key') ?? '';
return timingSafeEqual(candidate, expected);
}
function assertProMcpGatewayHmacConfig(): void {
const proGrantSecret = process.env.MCP_PRO_GRANT_HMAC_SECRET?.trim() ?? '';
const internalSecret = process.env.MCP_INTERNAL_HMAC_SECRET?.trim() ?? '';
if (proGrantSecret && !internalSecret) {
throw new Error('MCP_INTERNAL_HMAC_SECRET must be configured when MCP_PRO_GRANT_HMAC_SECRET is set');
}
}
export function createDomainGateway(
routes: RouteDescriptor[],
): (req: Request, ctx?: GatewayCtx) => Promise<Response> {
assertProMcpGatewayHmacConfig();
const router = createRouter(routes);
return async function handler(originalRequest: Request, ctx?: GatewayCtx): Promise<Response> {
let request = stripClientUserIdHeader(originalRequest);
const rawPathname = new URL(request.url).pathname;
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') : rawPathname;
const t0 = Date.now();
// Usage-telemetry identity inputs β accumulated as gateway auth resolution progresses.
// Read at every return point; null/0 defaults are valid for early returns.
//
// x-widget-key is intentionally NOT trusted here: a header is attacker-
// controllable, and emitting it as `customer_id` would let unauthenticated
// callers poison per-customer dashboards (per koala #3403 review). We only
// populate `widgetKey` after validating it against the configured
// WIDGET_AGENT_KEY β same check used in api/widget-agent.ts.
const rawWidgetKey = request.headers.get('x-widget-key') ?? null;
const widgetAgentKey = process.env.WIDGET_AGENT_KEY ?? '';
const validatedWidgetKey =
await timingSafeEqualSecret(rawWidgetKey, widgetAgentKey) ? rawWidgetKey : null;
const usage: UsageIdentityInput = {
sessionUserId: null,
isUserApiKey: false,
enterpriseApiKey: null,
widgetKey: validatedWidgetKey,
clerkOrgId: null,
userApiKeyCustomerRef: null,
tier: null,
planKey: null,
};
function recordUsageEntitlement(ent: CachedEntitlements | null): void {
if (!ent) return;
// The synthesized verification marker is not an answer about this
// caller's plan β it is free-SHAPED so the gates deny, nothing more.
// Copying its tier-0/'free' fields into usage telemetry would durably
// label unverifiable paying callers as free in Axiom, and it would do so
// precisely during the outage window this data exists to diagnose. Leave
// both fields null, which is what an unanswered lookup used to record
// back when this state arrived as a null (#5619 follow-up).
if (ent.verificationUnavailable) return;
usage.tier = typeof ent.features.tier === 'number' ? ent.features.tier : 0;
usage.planKey = ent.planKey;
}
// Domain segment for telemetry. Path layouts:
// /api/<domain>/v1/<rpc> β parts[2] = domain
// /api/v2/<domain>/<rpc> β parts[2] = "v2", parts[3] = domain
const _parts = pathname.split('/');
const domain = (/^v\d+$/.test(_parts[2] ?? '') ? _parts[3] : _parts[2]) ?? '';
const reqBytes = deriveReqBytes(request);
// #3199: in shadow mode a per-account limit that WOULD have triggered is
// recorded on the single terminal success emit (never a second event) so the
// volume signal Phase-2 pricing reuses isn't double-counted. Overrides only
// a successful terminal reason (status < 400); a real 4xx/5xx outcome wins.
let pendingShadowReason: RequestReason | null = null;
// Shared emit+return for the three billing-verification denial sites below
// (internal-MCP re-check, wm_ key, legacy bearer).
function denyForBillingVerification(
ent: CachedEntitlements | null | undefined,
cors: Record<string, string>,
capabilityCovered = false,
): Response | null {
if (capabilityCovered) return null;
const billingDenial = getBillingVerificationDenial(ent, cors);
if (!billingDenial) return null;
emitRequest(
billingDenial.status,
billingDenial.status === 503 ? 'billing_verification_503' : 'tier_403',
null,
);
return billingDenial;
}
function emitRequest(status: number, reason: RequestReason, cacheTier: UsageCacheTier | null, resBytes = 0): void {
if (!ctx?.waitUntil) return;
const effectiveReason: RequestReason =
pendingShadowReason && status < 400 ? pendingShadowReason : reason;
const identity = buildUsageIdentity(usage);
// Single ctx.waitUntil() registered synchronously in the request phase.
// The IIFE awaits ua_hash (SHA-256) then awaits delivery directly via
// deliverUsageEvents β no nested waitUntil call, which Edge runtimes
// (Cloudflare/Vercel) may drop after the response phase ends.
ctx.waitUntil((async () => {
const uaHash = await deriveUaHash(originalRequest);
await deliverUsageEvents([
buildRequestEvent({
requestId: deriveRequestId(originalRequest),
domain,
route: pathname,
method: originalRequest.method,
status,
durationMs: Date.now() - t0,
reqBytes,
resBytes,
customerId: identity.customer_id,
principalId: identity.principal_id,
authKind: identity.auth_kind,
tier: identity.tier,
planKey: identity.plan_key,
country: deriveCountry(originalRequest),
ipCity: deriveIpCity(originalRequest),
ipRegion: deriveIpRegion(originalRequest),
executionRegion: deriveExecutionRegion(originalRequest),
executionPlane: 'vercel-edge',
originKind: deriveOriginKind(originalRequest),
cacheTier,
ip: deriveIp(originalRequest),
userAgent: deriveUserAgent(originalRequest),
uaHash,
referer: deriveReferer(originalRequest),
acceptLanguage: deriveAcceptLanguage(originalRequest),
host: deriveHost(originalRequest),
sentryTraceId: deriveSentryTraceId(originalRequest),
reason: effectiveReason,
}),
]);
})());
}
// Origin check β skip CORS headers for disallowed origins
if (isDisallowedOrigin(request)) {
emitRequest(403, 'origin_403', null);
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Fail closed on CORS-header generation errors. Previous behaviour fell
// back to a wildcard ACAO, which converted the allowlist into wildcard
// CORS on the error path. Now we omit CORS headers and surface a 500
// so the browser blocks any cross-origin read. See issue #3705.
let corsHeaders: Record<string, string>;
try {
corsHeaders = getCorsHeaders(request);
} catch (err) {
// Pass the Sentry delivery promise through ctx.waitUntil so the
// Vercel Edge isolate survives long enough to actually flush the
// event. (captureSilentError uses keepalive:true as a transport
// fallback when ctx is absent, but the explicit waitUntil is the
// documented best practice.)
const captured = captureSilentError(err, {
tags: { route: 'gateway', step: 'cors_headers' },
});
ctx?.waitUntil(captured);
emitRequest(500, 'cors_error', null);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
// Prevent CDN/edge from caching the 500 β a transient CORS
// failure must not be pinned for downstream callers.
'Cache-Control': 'no-store',
},
});
}
// OPTIONS preflight
if (request.method === 'OPTIONS') {
emitRequest(204, 'preflight', null);
return new Response(null, { status: 204, headers: corsHeaders });
}
// ----------------------------------------------------------------------
// Defense-in-depth: strip client-controlled copies of the trusted
// internal-MCP markers BEFORE any other logic runs. The gateway is the
// ONLY layer permitted to set `x-wm-mcp-internal-verified` /
// `x-user-id` (the latter is also set by verified session / user-key
// paths below). Without the strip step, an attacker
// who sends `x-wm-mcp-internal-verified: 1` from outside could spoof
// premium context to any handler that reads these markers via
// `isCallerPremium`. The strip MUST run regardless of whether the
// X-WM-MCP-Internal header is present, so that the legacy
// `validateApiKey` path also receives a sanitised request.
//
// Mutation invariant: every subsequent request reconstruction in this
// function must build from the (already-stripped) `request`, not from
// `originalRequest`.
// ----------------------------------------------------------------------
{
const inboundHeaders = request.headers;
if (
inboundHeaders.has(INTERNAL_MCP_VERIFIED_HEADER) ||
inboundHeaders.has(TRUSTED_USER_ID_HEADER)
) {
const stripped = new Headers(inboundHeaders);
stripped.delete(INTERNAL_MCP_VERIFIED_HEADER);
stripped.delete(TRUSTED_USER_ID_HEADER);
// For GET/HEAD: no body to forward. For other methods: buffer the
// body bytes and pass them to the new Request β `body: request.body`
// (a ReadableStream) requires `duplex: 'half'` in Node's undici
// Request constructor, and the cleaner cross-runtime approach is
// to forward bytes. Internal-MCP payloads are small JSON RPC params.
//
// F8: cap the buffered body at MAX_INTERNAL_MCP_BODY (256 KB).
// Internal-MCP and gateway-bypass-strip paths only carry small
// JSON-RPC params; 256 KB is a safe ceiling that prevents an
// attacker from forcing the gateway to allocate megabytes of
// memory just by setting Content-Length on a forged request.
const reInit: RequestInit = { method: request.method, headers: stripped };
if (request.method !== 'GET' && request.method !== 'HEAD') {
const contentLen = parseInt(request.headers.get('Content-Length') ?? '0', 10);
if (Number.isFinite(contentLen) && contentLen > MAX_INTERNAL_MCP_BODY) {
// F14: distinct reason label for body-size rejections so
// telemetry separates this class from auth-401s.
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
try {
const bytes = await request.clone().arrayBuffer();
// Defense-in-depth: also reject if the actual buffered byte
// count exceeds the cap (Content-Length can be absent or
// wrong on chunked / streamed bodies).
if (bytes.byteLength > MAX_INTERNAL_MCP_BODY) {
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
reInit.body = bytes;
} catch {
// If we can't buffer the body, we can't safely forward the
// request without trust-markers stripped. 400 the caller.
// F14: use a distinct telemetry reason β "auth_401" was
// misleading (this is a body-buffer failure, not an auth
// outcome).
emitRequest(400, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'malformed_request' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
request = new Request(request.url, reInit);
}
}
// ----------------------------------------------------------------------
// Internal-MCP HMAC pre-check β runs BEFORE `validateApiKey` so that a
// verified Pro tool fetch never needs an `X-WorldMonitor-Key`. If
// `X-WM-MCP-Internal` is present, treat as a deliberate signed request:
// - verify β entitlement re-check β rebuild Request with trusted markers
// - verify FAILS β 401 immediately (do NOT fall through; present-but-
// invalid is a forge attempt, falling through to validateApiKey
// would let an attacker chain the legacy auth path).
// If the header is absent, fall through to the existing validateApiKey
// path with the (header-stripped) request β Starter+ wm_ keys remain
// unchanged.
//
// When this flag is true, downstream auth gates (validateApiKey, the
// PREMIUM_RPC_PATHS bearer gate, IP rate limiting) are skipped. The MCP
// edge already enforced 50/day + 60/min/userId; the gateway-level
// entitlement check for ENDPOINT_ENTITLEMENTS is also skipped here
// because we re-checked tier β₯ 1 + mcpAccess === true above.
// ----------------------------------------------------------------------
let internalMcpVerified = false;
if (request.headers.has(INTERNAL_MCP_SIG_HEADER)) {
const hmacSecret = process.env.MCP_INTERNAL_HMAC_SECRET ?? '';
if (!hmacSecret) {
// Server misconfiguration on the HMAC-attempt path. Surface as 500
// CONFIGURATION so operators see it; legacy wm_ key path is
// unaffected because we only enter this branch when the caller
// explicitly tried to use the internal-MCP route.
emitRequest(500, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'CONFIGURATION', detail: 'MCP_INTERNAL_HMAC_SECRET not configured' }),
{ status: 500, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
// Read the body bytes ONCE upfront. We need them in three places:
// 1. Inside verifyInternalMcpRequest for the bodyHash compare
// 2. To rebuild a fresh Request with trusted markers (Node's undici
// Request constructor refuses a ReadableStream body without
// `duplex: 'half'`; passing bytes sidesteps that)
// 3. To make the body re-readable by the downstream handler β once
// a stream is locked, subsequent reads throw.
// Reading then passing buffered bytes is safe for internal-MCP
// payloads (small JSON RPC params); not appropriate for streamed
// uploads, which this path doesn't carry.
let bodyBytes: ArrayBuffer | null = null;
if (request.method !== 'GET' && request.method !== 'HEAD') {
// F8: cap inbound body BEFORE buffering. Internal-MCP signed
// requests carry small JSON-RPC params; 256 KB is a safe ceiling.
const contentLen = parseInt(request.headers.get('Content-Length') ?? '0', 10);
if (Number.isFinite(contentLen) && contentLen > MAX_INTERNAL_MCP_BODY) {
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
try {
bodyBytes = await request.clone().arrayBuffer();
} catch {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'invalid_internal_mcp_signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
if (bodyBytes.byteLength > MAX_INTERNAL_MCP_BODY) {
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
// Reconstruct request from buffered bytes so verify can clone freely
// and the downstream handler can read the body normally.
request = new Request(request.url, {
method: request.method,
headers: request.headers,
body: bodyBytes,
});
}
// verifyInternalMcpRequest returns null when X-WM-MCP-User-Id is
// missing, signature header is malformed, timestamp is out of
// window, or the HMAC compare fails. All collapse to a single 401 β
// intentionally do NOT distinguish (don't leak which piece failed
// to a forge probe).
const verified = await verifyInternalMcpRequest(request, hmacSecret);
if (!verified) {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'invalid_internal_mcp_signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
const replayClaim = await claimInternalMcpReplayNonce(verified.userId, verified.nonce);
if (replayClaim === 'unavailable') {
// Fail closed: without an atomic replay-cache claim, a valid captured
// signature could be reused throughout the timestamp window.
emitRequest(503, 'replay_cache_unavailable', null);
return new Response(
JSON.stringify({ error: 'internal_mcp_replay_cache_unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
if (replayClaim === 'replay') {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'invalid_internal_mcp_signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
// Entitlement re-check at the gateway: the MCP edge already verifies
// tier β₯ 1 + mcpAccess + validUntil before signing the outbound
// fetch (api/mcp.ts). This second check defends against (a) the
// edge being bypassed (e.g. captured signature + leaked secret), (b)
// mid-request entitlement lapse, (c) future regressions where a
// non-edge caller signs requests.
//
// F1 (U7+U8 review pass): include `validUntil < Date.now()` in the
// rejection condition. The cache-hot path in `entitlement-check.ts`
// self-validates `validUntil >= Date.now()` at line 134, but the
// Convex fallback at lines 154-156 does not β without this check
// an entitlement row with stale `validUntil` would pass the gateway
// re-check via the fallback path. Mirror the per-handler runProPreChecks
// and authorize-pro entitlement guards.
const ent = await getEntitlements(verified.userId);
// Single-source Pro MCP decision. The gateway keeps its HTTP denial and
// telemetry contract; the shared gate owns access and billing precedence.
const gate = checkProMcpAccess(ent, Date.now());
const mcpCovered = gate === null;
const billingDenial = denyForBillingVerification(
ent,
corsHeaders,
mcpCovered,
);
if (billingDenial) return billingDenial;
if (!mcpCovered) {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'insufficient_entitlement' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
// Rebuild Request with trusted markers β sanitised header set
// already had inbound copies stripped above, so this is the ONLY
// place those markers can enter the downstream path. Body is
// re-supplied from the bytes we buffered (bodyBytes is null for
// GET/HEAD, in which case we omit the body field entirely).
//
// The verified-marker value is a per-process-startup random nonce,
// NOT the constant '1'. This protects direct edge functions that
// call `isCallerPremium` but don't route through this gateway β
// an attacker can't guess the nonce, so spoofing the marker on
// those endpoints fails closed.
//
// F7 (U7+U8 review pass): strip the inbound HMAC headers BEFORE
// setting the trusted markers. The gateway has consumed them via
// verifyInternalMcpRequest; downstream handlers should only see
// the trusted-marker pair, not the raw signature/userId headers.
// Defense-in-depth β handlers shouldn't have any reason to read
// the inbound HMAC.
const trusted = new Headers(request.headers);
trusted.delete(INTERNAL_MCP_SIG_HEADER);
trusted.delete(INTERNAL_MCP_USER_ID_HEADER);
trusted.delete(INTERNAL_MCP_NONCE_HEADER);
trusted.set(INTERNAL_MCP_VERIFIED_HEADER, getInternalMcpVerifiedNonce());
trusted.set(TRUSTED_USER_ID_HEADER, verified.userId);
const rebuildInit: RequestInit = { method: request.method, headers: trusted };
if (bodyBytes !== null) rebuildInit.body = bodyBytes;
request = new Request(request.url, rebuildInit);
usage.sessionUserId = verified.userId;
recordUsageEntitlement(ent);
internalMcpVerified = true;
}
// Tier gate check first β JWT resolution is expensive (JWKS + RS256) and only needed
// for tier-gated endpoints. Non-tier-gated endpoints never use sessionUserId.
//
// Internal-MCP verified path skips the tier gate / Clerk JWT resolution
// entirely: we already resolved the userId via HMAC verify and confirmed
// tier β₯ 1 + mcpAccess === true. Re-running the JWT path on a request
// that has no Authorization header would just no-op anyway.
// Two high-volume, caller-invariant dashboard reads expose an exact
// `public=1` URL shape. The marker creates a CDN key separate from the
// legacy credentialed URL, which remains session/key gated and no-store.
// Classification ignores attached credentials because a Vercel cache hit
// happens before this function sees them; the public URL must therefore
// have one response contract for every caller.
const isPublicNoAuthRpc = PUBLIC_NO_AUTH_RPC_PATHS.has(pathname)
|| isPublicSharedRpcRequest(request.url, request.method);
const seedRefreshVerified = await isResilienceRankingSeedRefreshRequest(request, pathname);
const relayWarmPingVerified = await isRelayWarmPingRequest(request, pathname);
const requiresDirectLlmQuota = !internalMcpVerified && await shouldReserveGatewayDirectLlmQuota(request, pathname);
const isTierGated = !internalMcpVerified && !isPublicNoAuthRpc && !seedRefreshVerified && !relayWarmPingVerified && getRequiredTier(pathname) !== null;
const needsLegacyProBearerGate = !internalMcpVerified && !isPublicNoAuthRpc && PREMIUM_RPC_PATHS.has(pathname) && !isTierGated;
const isProFreshCacheRpc = PRO_FRESH_CACHE_RPC_PATHS.has(pathname);
const needsProFreshnessResolution =
!internalMcpVerified &&
!isPublicNoAuthRpc &&
isProFreshCacheRpc &&
request.headers.get('Authorization')?.startsWith('Bearer ') === true;
let rateLimitPrincipalUserId: string | undefined;
// Session resolution β extract userId from bearer token (Clerk JWT) if present.
// Runs only for tier gates, direct-LLM quota, or the explicit Pro-fresh
// market allowlist to avoid JWKS lookup on every request.
let sessionUserId: string | null = null;
let sessionRole: 'free' | 'pro' | null = null;
if (isTierGated || requiresDirectLlmQuota || needsProFreshnessResolution) {
const session = await resolveClerkSession(request);
sessionUserId = session?.userId ?? null;
sessionRole = session?.role ?? null;
usage.sessionUserId = sessionUserId;
usage.clerkOrgId = session?.orgId ?? null;
if (sessionUserId) {
request = withAuthenticatedUserId(request, sessionUserId);
}
}
// API key validation β tier-gated endpoints require EITHER an API key OR a valid bearer token.
// Authenticated users (sessionUserId present) bypass the API key requirement.
//
// Internal-MCP verified path: skip validateApiKey entirely. The HMAC
// verify replaced the API key contract for this request β running
// validateApiKey would 401 every Pro tool fetch (no wm_ key on the
// request). Telemetry stays attributed via the verified userId set
// above; entitlement re-check (`features.tier β₯ 1 && mcpAccess`) was
// already performed before flipping `internalMcpVerified = true`.
let keyCheck: { valid: boolean; required: boolean; error?: string; kind?: 'enterprise' | 'session' | 'user' } = internalMcpVerified || isPublicNoAuthRpc || seedRefreshVerified || relayWarmPingVerified
? { valid: true, required: false }
: ((await validateApiKey(request, {
forceKey: (isTierGated && !sessionUserId) || needsLegacyProBearerGate,
})) as { valid: boolean; required: boolean; error?: string; kind?: 'enterprise' | 'session' | 'user' });
// User-owned API keys (wm_ prefix): when the static WORLDMONITOR_VALID_KEYS
// check fails, try async Convex-backed validation for user-issued keys.
//
// Run this before the Clerk-session override below. A request can carry both
// a valid bearer session and an X-Api-Key wm_ header; when that happens, the
// wm_ key is still an explicit authenticating credential and its owner must
// pass the #4611 apiAccess gate.
let isUserApiKey = false;
const wmKey =
request.headers.get('X-WorldMonitor-Key') ??
request.headers.get('X-Api-Key') ??
'';
if (keyCheck.required && !keyCheck.valid && wmKey.startsWith('wm_')) {
// Unknown wm_ credentials require a Convex-backed hash lookup before we
// know the account principal. Bound that unattributed work by IP first:
// otherwise an attacker can rotate syntactically-valid keys and evade the
// per-hash negative cache while every request reaches Convex. The 600/min
// ceiling matches the repo-wide global IP budget and deliberately fails
// closed when Redis is unavailable because this guard protects the auth
// backend itself.
const validationGuardResponse = await checkFailClosedScopedIpRateLimit(
request,
'user-api-key:pre-auth-validation',
600,
'60 s',
corsHeaders,
);
if (validationGuardResponse) {
const reason = getRateLimitTelemetryReason(
validationGuardResponse,
'rate_limit_429',
);
emitRequest(validationGuardResponse.status, reason, null);
return validationGuardResponse;
}
// Only destructure validateUserApiKey: several gateway unit tests mock this
// module with a partial surface. Requiring isUserApiKeyUnavailableError at
// import time breaks those mocks (vitest throws "No export is defined").
// Classify unavailability by the stable `code` field instead.
const { validateUserApiKey } = await import('./_shared/user-api-key');
try {
const userKeyResult = await validateUserApiKey(wmKey);
if (userKeyResult) {
isUserApiKey = true;
usage.isUserApiKey = true;
usage.userApiKeyCustomerRef = userKeyResult.userId;
keyCheck = { valid: true, required: true };
// Propagate the resolved key-owner identity to downstream route
// handlers via x-user-id. The entitlement check itself takes the
// userId argument directly (see checkEntitlement(sessionUserId, β¦))
// so it no longer depends on this header β the header is now for
// handler consumption + the internal-MCP `isCallerPremium` path.
sessionUserId = userKeyResult.userId;
// The Clerk role belongs to the bearer subject, not the user-key owner.
// Once the explicit wm_ key becomes the identity source, require the
// key owner's Convex entitlement to drive tier-gated access.
sessionRole = null;
usage.sessionUserId = sessionUserId;
usage.clerkOrgId = null;
request = withAuthenticatedUserId(request, sessionUserId);
}
} catch (err) {
// Transient Convex validation outage must not look like an invalid key.
// Mirror api/_user-api-key.js serviceUnavailable() (503 + Retry-After +
// X-Validation-Mode: degraded) so clients retry instead of rotating keys.
// Duck-type on `code` so partial test mocks of user-api-key still work.
const code =
typeof err === 'object' && err !== null
? (err as { code?: unknown }).code
: undefined;
if (code === 'validation_unavailable') {
emitRequest(503, 'validation_unavailable', null);
return new Response(JSON.stringify({ error: 'Service temporarily unavailable' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '5',
'X-Validation-Mode': 'degraded',
...corsHeaders,
},
});
}
throw err;
}
}
// Clerk session is itself proof of authentication (validated at line 410).
// validateApiKey is strict-no-trust-of-headers per #3541 and would 401 every
// Clerk-authenticated user who hasn't also minted a wms_ session token.
// Override: routes that deliberately resolved a sessionUserId pass this layer.
if (
(isTierGated || requiresDirectLlmQuota || needsProFreshnessResolution) &&
sessionUserId &&
keyCheck.required &&
!keyCheck.valid
) {
keyCheck = { valid: true, required: false };
}
// Enterprise API key (WORLDMONITOR_VALID_KEYS): require kind === 'enterprise'.
// Without this, anonymous wms_ tokens slipped through (validateApiKey marks
// them valid, wmKey is set, !isUserApiKey, and 'wms_' doesn't startsWith
// 'wm_'), so telemetry mislabelled them as enterprise_api_key with
// customer_id='enterprise-unmapped'. PR #3557 round-3 review.
if (keyCheck.valid && wmKey && !isUserApiKey && keyCheck.kind === 'enterprise') {
usage.enterpriseApiKey = wmKey;
}
// ββ Active-subscription gate for user API keys (#4611) ββββββββββββββββββ
// A wm_ user key that authenticated this request must map to an owner with
// ACTIVE apiAccess on EVERY keyed route β not just PREMIUM_RPC_PATHS. A
// cancelled/downgraded customer keeps a valid (un-revoked) key that still
// resolves to their userId, so without a route-wide gate the key keeps
// serving the whole paid programmatic surface for free: the API Starter
// product leaks past churn. Runs BEFORE the #3199 per-account rate-limit
// block so an expired key is rejected outright, never metered β and the
// resolved entitlement is reused there to avoid a second lookup.
//
// Scoped to isUserApiKey: the wm_ key IS the authenticating credential
// (isUserApiKey β sessionUserId is the resolved key owner, set above).
// This intentionally does NOT re-validate wm_ keys on any other route class:
// - Enterprise operator keys (kind 'enterprise', incl. legacy wm_-prefixed
// relay keys) never set isUserApiKey and carry no user entitlement row.
// - Verified internal paths (MCP / seed-refresh / relay warm-ping) never
// set isUserApiKey.
// - PUBLIC_NO_AUTH_RPC_PATHS serve free data to everyone; the key is not
// the authenticator there. Re-validating an arbitrary header key on that
// anonymous surface would add an unauthenticated Convex-lookup
// amplification vector (a rotating fake wm_ key per request defeats the
// negative cache, ahead of any rate limit) for no revenue gain β public
// data is not the paid product β and would wrongly gate the
// intentionally-anonymous lead-capture forms.
let userKeyEntitlement: CachedEntitlements | null | undefined;
if (isUserApiKey && sessionUserId) {
userKeyEntitlement = await getEntitlements(sessionUserId);
recordUsageEntitlement(userKeyEntitlement);
const apiAccessCovered = !!userKeyEntitlement &&
userKeyEntitlement.features.apiAccess &&
(userKeyEntitlement.validUntil ?? 0) >= Date.now();
const billingDenial = denyForBillingVerification(
userKeyEntitlement,
corsHeaders,
apiAccessCovered,
);
if (billingDenial) return billingDenial;
// A validated wm_ key proves key ownership, not current paid access.
// Transient lookup failures now arrive as a verificationUnavailable
// marker and were already answered with the retryable 503 by
// denyForBillingVerification above; a null here means the backend is
// unconfigured or gave a confirmed/malformed answer, and allowing it
// would turn that state into paid API access. Fail closed with a 503
// β EXCEPT when the entitlement backend itself is unconfigured: that is
// a deploy defect, not customer billing state, and 503ing every wm_ key
// fleet-wide would convert a config regression into a total API outage.
// Misconfig serves fail-open (pre-#4770 behavior) and logs loudly.
if (!userKeyEntitlement) {
if (isEntitlementBackendConfigured()) {
emitRequest(503, 'billing_verification_503', null);
return new Response(
JSON.stringify({
error: 'Unable to verify API access',
code: 'entitlement_verification_unavailable',
}),
{
status: 503,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '5',
'X-Billing-Verification': 'entitlement_verification_unavailable',
},
},
);
}
console.error(
'[gateway] entitlement backend unconfigured (CONVEX_SITE_URL / shared secret missing) β serving wm_-key request fail-open',
);
} else if (
!userKeyEntitlement.features.apiAccess ||
(userKeyEntitlement.validUntil ?? 0) < Date.now()
) {
emitRequest(403, 'tier_403', null);
return createGatewayAuthErrorResponse(
403,
'API access requires an active subscription',
corsHeaders,
);
} else {
// A validated user key plus active apiAccess is a trusted paid
// principal even on routes without an endpoint tier policy.
rateLimitPrincipalUserId = sessionUserId;
}
}
// Pro freshness is an optional paid benefit, not an access gate. Resolve
// only identities that were already verified above (Clerk bearer or a
// user-owned API key), then fail closed to the ordinary cache policy when
// entitlement state is absent, expired, or temporarily unavailable.
//
// Do not accept the Clerk role alone here: this contract is specifically
// for active plans, while role='pro' can also represent legacy/test grants.
let hasProFreshCacheAccess = internalMcpVerified && isProFreshCacheRpc;
if (!hasProFreshCacheAccess && isProFreshCacheRpc && sessionUserId) {
const ent =
userKeyEntitlement !== undefined
? userKeyEntitlement
: await getEntitlements(sessionUserId);
recordUsageEntitlement(ent);
hasProFreshCacheAccess =
!!ent &&
ent.features.tier >= 1 &&
ent.validUntil >= Date.now();
if (hasProFreshCacheAccess) {
rateLimitPrincipalUserId = sessionUserId;
}
}
if (keyCheck.required && !keyCheck.valid) {
if (needsLegacyProBearerGate) {
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const { validateBearerToken } = await import('./auth-session');
const session = await validateBearerToken(authHeader.slice(7));
if (!session.valid) {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, 'Invalid or expired session', corsHeaders);
}
// Capture identity for telemetry β legacy bearer auth bypasses the
// earlier resolveClerkSession() block (only runs for tier-gated routes),
// so without this premium bearer requests would emit as anonymous.
if (session.userId) {
sessionUserId = session.userId;
usage.sessionUserId = session.userId;
request = withAuthenticatedUserId(request, session.userId);
}
// Accept EITHER a Clerk 'pro' role OR a Convex Dodo entitlement with
// tier >= 1. The Dodo webhook pipeline writes Convex entitlements but
// does NOT sync Clerk publicMetadata.role, so a paying subscriber's
// session.role stays 'free' indefinitely. A Clerk-role-only check
// would block every paying user on legacy premium endpoints despite
// a valid Dodo subscription. This mirrors the two-signal logic in
// server/_shared/premium-check.ts::isCallerPremium so the gateway
// gate and the per-handler gate agree on who is premium β same split
// already documented at the frontend layer (panel-gating.ts:11-27).
//
// Note: validateBearerToken returns session.userId directly, so we
// use it without needing to resolveSessionUserId() β sessionUserId
// is intentionally only resolved for ENDPOINT_ENTITLEMENTS-tier-gated
// endpoints earlier (line 292) to avoid a JWKS lookup on every
// legacy premium request. validateBearerToken already does its own
// verification here (line 360) and exposes userId on the result.
let allowed = session.role === 'pro';
if (!allowed && session.userId) {
const ent = await getEntitlements(session.userId);
recordUsageEntitlement(ent);
const proCovered = !!ent &&
ent.features.tier >= 1 &&
ent.validUntil >= Date.now();
const billingDenial = denyForBillingVerification(
ent,
corsHeaders,
proCovered,
);
if (billingDenial) return billingDenial;
allowed = !!ent && ent.features.tier >= 1 && ent.validUntil >= Date.now();
}
if (!allowed) {
emitRequest(403, 'tier_403', null);
return createGatewayAuthErrorResponse(403, 'Pro subscription required', corsHeaders);
}
rateLimitPrincipalUserId = session.userId;
// Valid pro session (Clerk role OR Dodo entitlement) β fall through to route handling.
} else {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, keyCheck.error, corsHeaders);
}
} else {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, keyCheck.error, corsHeaders);
}
}
// Entitlement check β blocks tier-gated endpoints for users below required tier.
// Admin API-key holders (WORLDMONITOR_VALID_KEYS, kind: 'enterprise') bypass.
// User API keys do NOT bypass β the key owner's tier is checked normally.
// Anonymous wms_ session tokens (kind: 'session') do NOT bypass β they are
// freely mintable by any caller and are NOT user-bound (PR #3557 review).
//
// Internal-MCP verified path also bypasses: we already confirmed
// tier β₯ 1 + mcpAccess === true above. Some ENDPOINT_ENTITLEMENTS
// routes require tier 2, but Pro MCP callers only reach the gateway
// through the MCP edge's whitelisted tool set.
const isEnterpriseAuth = keyCheck.valid && wmKey && !isUserApiKey && keyCheck.kind === 'enterprise';
if (!isEnterpriseAuth && !internalMcpVerified && !seedRefreshVerified && !relayWarmPingVerified) {
const entitlementCheck = await checkEntitlementDetailed(sessionUserId, pathname, corsHeaders, {
clerkRole: sessionRole,
});
recordUsageEntitlement(entitlementCheck.entitlements);
const entitlementResponse = entitlementCheck.response;
if (entitlementResponse) {
const entReason: RequestReason =
entitlementResponse.status === 401 ? 'auth_401'
: entitlementResponse.status === 403 ? 'tier_403'
: entitlementResponse.status === 503 ? 'billing_verification_503'
: 'ok';
emitRequest(entitlementResponse.status, entReason, null);
return entitlementResponse.status === 401 || entitlementResponse.status === 403
? markAuthErrorNoStore(entitlementResponse)
: entitlementResponse;
}
// A successful tier gate proves this server-derived principal currently
// holds the paid access required by the route. Reuse that authorization
// decision for both endpoint and global limiter attribution so Pro users
// behind a NAT do not share an IP bucket with unrelated traffic.
if (sessionUserId && isTierGated) {
rateLimitPrincipalUserId = sessionUserId;
}
// #5206: summarize refreshes from multiple active Pro users can share a
// NAT/public IP and collectively exhaust the endpoint's 30/min abuse
// bucket. Keep the exact same fail-closed endpoint policy, but isolate
// confirmed active paid principals. Signed-in free, anonymous, expired,
// and unresolvable callers deliberately retain the per-IP bucket.
// requiresDirectLlmQuota intentionally limits this exception to
// spend-bearing summarize requests: translate/malformed requests do not
// spend direct LLM quota and keep ordinary per-IP behavior, while cache
// lookup is handled by its distinct route.
if (
pathname === '/api/news/v1/summarize-article' &&
requiresDirectLlmQuota &&
sessionUserId
) {
// This guard runs before the entitlement lookup needed to choose the
// final endpoint bucket. Its distinct 600/min IP namespace matches the
// repo-wide global ceiling (20x the endpoint's 30/min spend cap): enough
// NAT headroom for legitimate Pro refreshes, while bounding per-IP
// entitlement-I/O amplification and failing closed when Redis degrades.
const attributionGuardResponse = await checkFailClosedScopedIpRateLimit(
request,
'summarize-article:principal-attribution',
600,
'60 s',
corsHeaders,
);
if (attributionGuardResponse) {
const reason = getRateLimitTelemetryReason(
attributionGuardResponse,
'rate_limit_429',
);
emitRequest(attributionGuardResponse.status, reason, null);
return attributionGuardResponse;
}
const ent = entitlementCheck.entitlements ?? (
userKeyEntitlement !== undefined
? userKeyEntitlement
: await getEntitlements(sessionUserId)
);
recordUsageEntitlement(ent);
if (ent && ent.features.tier >= 1 && ent.validUntil >= Date.now()) {
rateLimitPrincipalUserId = sessionUserId;
}
}
}
// Route matching β if POST doesn't match, convert to GET for stale clients
let matchedHandler = router.match(request);
if (!matchedHandler && request.method === 'POST') {
if (isPostToGetCompatibleBodySize(request.headers)) {
const url = new URL(request.url);
let oversizedKey: string | null = null;
try {
const bodyText = await request.clone().text();
if (new TextEncoder().encode(bodyText).byteLength >= POST_TO_GET_MAX_BODY_BYTES) {
emitRequest(400, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'malformed_request' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const body = JSON.parse(bodyText);
const isScalar = (x: unknown): x is string | number | boolean =>
typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean';
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(v)) {
if (v.length > POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY) {
oversizedKey = k;
break;
}
v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
} else if (isScalar(v)) url.searchParams.set(k, String(v));
}
} catch { /* non-JSON body β preserve legacy POSTβGET fallback */ }
if (oversizedKey !== null) {
emitRequest(400, 'malformed_request', null);
return new Response(JSON.stringify({
error: 'Too many values for POST compatibility parameter',
parameter: oversizedKey,
maxValues: POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY,
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const getReq = new Request(url.toString(), { method: 'GET', headers: request.headers });
matchedHandler = router.match(getReq);
if (matchedHandler) request = getReq;
}
}
if (!matchedHandler) {
const allowed = router.allowedMethods(new URL(request.url).pathname);
if (allowed.length > 0) {
emitRequest(405, 'method_not_allowed', null);
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', Allow: allowed.join(', '), ...corsHeaders },
});
}
emitRequest(404, 'unknown_route', null);
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const requiredBboxDiagnostic = getRequiredBboxDiagnostic(request, pathname);
const identityForScope = buildUsageIdentity(usage);
// ββ Idempotency-Key support (mutation retry-safety) ββββββββββββββββββββββ
// Opt-in: only a POST carrying the header. POSTβGET-converted batch reads
// (compat block above) are already GET here and are skipped. Scope by the
// resolved principal so a key can never replay another caller's response.
// Fail-open: any Redis issue proceeds without idempotency (see the module).
let idempotency: IdempotencyOutcome | null = null;
const hasIdempotencyKey = request.method === 'POST' && request.headers.has(IDEMPOTENCY_HEADER);
const idScope = identityForScope.principal_id ?? identityForScope.customer_id;
const idempotencyScope = idScope ? `${identityForScope.auth_kind}:${idScope}` : null;
// Look up an existing idempotency record before rate-limit/quota counters.
// This lets a retry of completed work replay without charging a duplicate
// unit. A miss does NOT claim the key; fresh executions still pass through
// the normal abuse controls before `beginIdempotency()` below.
if (hasIdempotencyKey) {
const peek = await peekIdempotency({
request,
pathname,
scope: idempotencyScope,
idempotencyKey: request.headers.get(IDEMPOTENCY_HEADER) ?? '',
corsHeaders,
});
switch (peek.kind) {
case 'invalid':
emitRequest(400, 'idempotency_invalid', null);
return peek.response;
case 'replay':
emitRequest(peek.response.status, 'idempotent_replay', null);
return peek.response;
case 'conflict':
emitRequest(409, 'idempotency_conflict', null);
return peek.response;
case 'mismatch':
emitRequest(422, 'idempotency_mismatch', null);
return peek.response;
// 'miss' proceeds to rate limiting; 'disabled' preserves fail-open behavior.
}
}
// Gateway rate limiting β two-phase: endpoint-specific first, then global fallback.
// Confirmed paid principals use per-user buckets; other traffic uses IP.
//
// Internal-MCP verified requests skip this gateway layer: the MCP edge
// already enforced 50/day + 60/min per userId in api/mcp.ts. A second
// limiter here would create misleading double-counting and could 429
// legitimate Pro tool fetches that pass the upstream cap.
if (!internalMcpVerified) {
const endpointRlResponse = rateLimitPrincipalUserId
? await checkEndpointRateLimit(request, pathname, corsHeaders, {
principalUserId: rateLimitPrincipalUserId,
})
: await checkEndpointRateLimit(request, pathname, corsHeaders);
if (endpointRlResponse) {
const reason = getRateLimitTelemetryReason(
endpointRlResponse,
'rate_limit_429_endpoint',
);
emitRequest(endpointRlResponse.status, reason, null);
return endpointRlResponse;
}
// ββ Per-account API rate limit (#3199) ββββββββββββββββββββββββββββββ
// Eligible authenticated keys β a valid user key (which carries NO
// keyCheck.kind, so `isUserApiKey` is the discriminator) or an enterprise
// env key β are governed by a per-account burst + daily meter (enforced
// at the sold allowance, #4635) instead of the global fallback. In ENFORCE
// they bypass that fallback below; in SHADOW they only record telemetry
// and still fall through to it. Validated user keys use their trusted
// principal there, while enterprise keys retain IP attribution.
// Limits are NOT in scope here (checkEntitlement discards `features`), so
// user keys resolve getEntitlements explicitly (cached); enterprise keys
// carry no entitlement and use hardcoded limits.
let governedByApiKeyLayer = false;
if (keyCheck.valid && (isUserApiKey || isEnterpriseAuth)) {
const enforce = process.env.API_RATE_LIMIT_ENFORCE === 'true';
let perMinute = 0;
let allowance = -1;
let identity = '';
let planKey = ''; // #4635 β hoisted for the informative 429 (ent is block-scoped below)
if (isEnterpriseAuth) {
perMinute = ENTERPRISE_API_RATE_LIMIT; // hardcoded β no entitlement row
allowance = -1; // unlimited daily / no ceiling
planKey = 'enterprise'; // top tier β named in the 429, but no upgrade_url
usage.tier = 3; // enterprise tier β no entitlement row to read it from
// (plan_key defaults to 'enterprise' in buildUsageIdentity)
// Enterprise burst is keyed PER KEY (not per account) by design:
// these are operator-issued WORLDMONITOR_VALID_KEYS with no shared
// userId, and unlimited daily β so there's no quota to multiply by
// minting keys, and each operator key gets its own 1,000/min budget
// rather than contending for one shared bucket. (User keys below key
// on userId so a customer can't multiply their allowance.)
identity = wmKey ? hashKeySync(wmKey) : '';
} else if (sessionUserId) {
// Reuse the entitlement the #4611 gate above already resolved for this
// same user key (undefined β the gate didn't run, e.g. a Clerk-session
// caller with no wm_ key β resolve it now). Avoids a duplicate lookup
// on the hot active-key path.
const ent =
userKeyEntitlement !== undefined
? userKeyEntitlement
: await getEntitlements(sessionUserId);
if (ent) {
// #4572 β attribute the usage event to the caller's real tier +
// plan (recorded even for downgraded keys), so the limit-abuse
// audit can compare each request to the customer's actual cap.
recordUsageEntitlement(ent);
}
if (ent && ent.features.apiAccess && ent.features.apiRateLimit > 0) {
perMinute = ent.features.apiRateLimit;
// undefined β fail-open (no daily limit); -1 β unlimited.
allowance =
typeof ent.features.apiDailyAllowance === 'number'
? ent.features.apiDailyAllowance
: -1;
planKey = ent.planKey;
identity = sessionUserId;
}
// else: downgraded / null entitlement β not eligible (perMinute = 0),
// falls through to the per-IP path β never a slidingWindow(0).
}
if (perMinute > 0 && identity) {
// #4635 β informative 429 upgrade link; omitted for enterprise/top tier.
const upgradeUrl =
planKey && planKey !== 'enterprise' ? 'https://worldmonitor.app/' : undefined;
// 1. Per-minute burst (hard limit).
const burst = await checkBurst(perMinute, identity);
if (!burst.ok) {
if (enforce) {
const retryAfterSec = Math.max(1, Math.ceil((burst.reset - Date.now()) / 1000));
emitRequest(429, 'rl_min_429', null);
return new Response(JSON.stringify({
error: 'Too many requests',
plan: planKey || undefined,
limit: burst.limit,
limit_type: 'per_minute',
reset: new Date(burst.reset).toISOString(),
upgrade_url: upgradeUrl,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...rateLimitHeaders({ limit: burst.limit, remaining: 0, resetMs: burst.reset, retryAfterSec, windowSec: 60 }),
...corsHeaders,
},
});
}
pendingShadowReason = 'rl_min_shadow';
} else if (allowance >= 0) {
// 2. Daily meter β hard-rejects at the sold allowance (#4635).
// Skipped for unlimited (-1); reserveDailyMeter fail-opens on <=0.
const meter = await reserveDailyMeter({
userId: identity,
allowance,
pipeline: (cmds) => runRedisPipeline(cmds),
});
if (meter.overLimit) {
if (enforce) {
await meter.rollback();
emitRequest(429, 'rl_ceiling_429', null);
return new Response(JSON.stringify({
error: 'Daily request limit reached',
plan: planKey || undefined,
limit: allowance,
limit_type: 'daily',
reset: new Date(Date.now() + meter.retryAfterSec * 1000).toISOString(),
upgrade_url: upgradeUrl,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...rateLimitHeaders({
limit: allowance,
remaining: 0,
resetMs: Date.now() + meter.retryAfterSec * 1000,
retryAfterSec: meter.retryAfterSec,
// Daily ceiling window (24 h) for the advertised policy.
windowSec: 86_400,
}),
...corsHeaders,
},
});
}
pendingShadowReason = 'rl_ceiling_shadow';
}
}
// Eligible + enforce + not rejected β the per-account layer governs
// this request and skips the global fallback. In shadow, keep that
// fallback active: validated user keys use their trusted principal,
// while enterprise keys retain IP attribution.
if (enforce) governedByApiKeyLayer = true;
}
}
if (!governedByApiKeyLayer && !hasEndpointRatePolicy(pathname)) {
const rateLimitResponse = rateLimitPrincipalUserId
? await checkRateLimit(request, corsHeaders, {
principalUserId: rateLimitPrincipalUserId,
})
: await checkRateLimit(request, corsHeaders);
if (rateLimitResponse) {
const reason = getRateLimitTelemetryReason(
rateLimitResponse,
'rate_limit_429_global',
);
emitRequest(rateLimitResponse.status, reason, null);
return rateLimitResponse;
}
}
}
if (requiresDirectLlmQuota && !isEnterpriseAuth) {
if (!sessionUserId) {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, 'Pro authentication required', corsHeaders);
}
const reservation = await reserveDirectLlmQuota({
userId: sessionUserId,
pipeline: (cmds) => runRedisPipeline(cmds, true),
});
if (!reservation.ok) {
const response = createDirectLlmQuotaFailureResponse(reservation, corsHeaders);
emitRequest(
response.status,
response.status === 429 ? 'rate_limit_429_direct_llm' : 'rate_limit_degraded',
null,
);
return response;
}
}
// Gate on presence (not truthiness) so a present-but-empty header is
// rejected as malformed rather than silently ignored.
if (hasIdempotencyKey) {
idempotency = await beginIdempotency({
request,
pathname,
// Tag the scope with the auth kind so value spaces (Clerk id vs hashed
// key vs customer ref) can never collide across authentication methods.
scope: idempotencyScope,
idempotencyKey: request.headers.get(IDEMPOTENCY_HEADER) ?? '',
corsHeaders,
});
switch (idempotency.kind) {
case 'invalid':
emitRequest(400, 'idempotency_invalid', null);
return idempotency.response;
case 'replay':
emitRequest(idempotency.response.status, 'idempotent_replay', null);
return idempotency.response;
case 'conflict':
emitRequest(409, 'idempotency_conflict', null);
return idempotency.response;
case 'mismatch':
emitRequest(422, 'idempotency_mismatch', null);
return idempotency.response;
// 'disabled' (fail-open) and 'proceed' fall through to execution.
}
}
// Execute handler with top-level error boundary.
// Wrap in runWithUsageScope so deep fetch helpers (fetchJson,
// cachedFetchJsonWithMeta) can attribute upstream calls to this customer
// without leaf handlers having to thread a usage hook through every call.
let response: Response;
const handlerCall = matchedHandler;
const requestForHandler = request;
try {
response = await runWithUsageScope(
{
ctx: ctx ?? { waitUntil: () => {} },
requestId: deriveRequestId(originalRequest),
customerId: identityForScope.customer_id,
route: pathname,
tier: identityForScope.tier,
},
() => handlerCall(requestForHandler),
);
} catch (err) {
console.error('[gateway] Unhandled handler error:', err);
response = new Response(JSON.stringify({ message: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Merge CORS + handler side-channel headers into response
const mergedHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
mergedHeaders.set(key, value);
}
const extraHeaders = drainResponseHeaders(request);
if (extraHeaders) {
for (const [key, value] of Object.entries(extraHeaders)) {
mergedHeaders.set(key, value);
}
}
const retryableResponse = drainRetryableResponse(request);
attachRequiredBboxDiagnosticHeaders(mergedHeaders, pathname, requiredBboxDiagnostic);
// Handler side-channel status override (setSuccessStatusOverride): applied
// only when the handler actually produced a 200 on a POST β async-enqueue
// endpoints (run-scenario) upgrade their success to 202 Accepted, while
// thrown ApiError statuses always win. GET success flows are excluded:
// the ETag/304 + CDN-cache path below assumes 200. Always drained so a
// set-but-unapplied override can't leak state.
const statusOverride = drainSuccessStatusOverride(request);
const finalStatus =
statusOverride !== undefined && request.method === 'POST' && response.status === 200
? statusOverride
: response.status;
// For GET 200 responses: read body once for cache-header decisions + ETag
let resolvedCacheTier: CacheTier | null = null;
if (response.status === 200 && request.method === 'GET' && response.body) {
const bodyBytes = await response.arrayBuffer();
const bodyStr = new TextDecoder().decode(bodyBytes);
const noStoreReason = getRpcNoStoreReasonFromJson(bodyStr, { pathname });
if (mergedHeaders.get('X-No-Cache') || noStoreReason) {
mergedHeaders.set('Cache-Control', 'no-store');
mergedHeaders.delete('CDN-Cache-Control');
mergedHeaders.delete('Vercel-CDN-Cache-Control');
mergedHeaders.set('X-Cache-Tier', 'no-store');
resolvedCacheTier = 'no-store';
} else {
const rpcName = pathname.split('/').pop() ?? '';
const envOverride = process.env[`CACHE_TIER_OVERRIDE_${rpcName.replace(/-/g, '_').toUpperCase()}`] as CacheTier | undefined;
const isPremium = PREMIUM_RPC_PATHS.has(pathname) || getRequiredTier(pathname) !== null;
const hasCredentialedNonPublicGet = !isPublicNoAuthRpc && hasCredentialBearingHeader(request);
const tier = hasProFreshCacheAccess ? 'live-browser' as CacheTier
: isPremium || hasCredentialedNonPublicGet ? 'slow-browser' as CacheTier
: (envOverride && envOverride in TIER_HEADERS ? envOverride : null) ?? RPC_CACHE_TIER[pathname] ?? 'medium';
resolvedCacheTier = tier;
mergedHeaders.set('Cache-Control', TIER_HEADERS[tier]);
// Only allow Vercel CDN caching for trusted origins (worldmonitor.app, Vercel previews,
// Tauri). No-origin server-side requests (external scrapers) must always reach the edge
// function so the auth check in validateApiKey() can run. Without this guard, a cached
// 200 from a trusted-origin browser request could be served to a no-origin scraper,
// bypassing auth entirely.
const reqOrigin = request.headers.get('origin') || '';
const cdnCache = !hasProFreshCacheAccess && !isPremium && !hasCredentialedNonPublicGet && isAllowedOrigin(reqOrigin)
? TIER_CDN_CACHE[tier]
: null;
mergedHeaders.delete('CDN-Cache-Control');
mergedHeaders.delete('Vercel-CDN-Cache-Control');
if (cdnCache) mergedHeaders.set('CDN-Cache-Control', cdnCache);
mergedHeaders.set('X-Cache-Tier', tier);
// Keep per-origin ACAO (already set from corsHeaders above) and preserve Vary: Origin.
// ACAO: * with no Vary would collapse all origins into one cache entry, bypassing
// isDisallowedOrigin() for cache hits β Vercel CDN serves s-maxage responses without
// re-invoking the function, so a disallowed origin could read a cached ACAO: * response.
}
mergedHeaders.delete('X-No-Cache');
if (!new URL(request.url).searchParams.has('_debug')) {
mergedHeaders.delete('X-Cache-Tier');
}
// Universal optional JMESPath projection (REST parity with the MCP
// server's `jmespath` tool argument). Applied to the JSON body BEFORE the
// ETag hash so the ETag reflects the projected payload; the ?jmespath=
// expression is part of the request URL, so Vercel's CDN keys each
// projection separately. GET-only: mutating POSTs are already fully typed
// via their requestBody and their responses are not cached/ETagged here.
// See server/_shared/response-projection.ts + /docs/mcp-jmespath.
let responseView = new Uint8Array(bodyBytes);
const jmespathExpr = new URL(request.url).searchParams.get('jmespath');
if (jmespathExpr && (mergedHeaders.get('Content-Type') ?? '').includes('application/json')) {
const projection = projectJsonResponse(bodyStr, jmespathExpr);
if (!projection.ok) {
const errorBody = JSON.stringify(projection.envelope);
emitRequest(400, 'malformed_request', null, errorBody.length);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(errorBody, {
status: 400,
headers: {
...corsHeaders,
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
},
});
}
responseView = new TextEncoder().encode(projection.body);
// The projected body has a different length than the handler's β drop any
// stale Content-Length so the runtime recomputes it (a leftover value
// would truncate the response).
mergedHeaders.delete('Content-Length');
}
// FNV-1a inspired fast hash β good enough for cache validation
let hash = 2166136261;
const view = responseView;
for (let i = 0; i < view.length; i++) {
hash ^= view[i]!;
hash = Math.imul(hash, 16777619);
}
const etag = `"${(hash >>> 0).toString(36)}-${view.length.toString(36)}"`;
mergedHeaders.set('ETag', etag);
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === etag) {
emitRequest(304, 'ok', resolvedCacheTier, 0);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(null, { status: 304, headers: mergedHeaders });
}
emitRequest(response.status, 'ok', resolvedCacheTier, view.length);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(responseView, {
status: response.status,
statusText: response.statusText,
headers: mergedHeaders,
});
}
if (response.status === 200 && request.method === 'GET') {
if (mergedHeaders.get('X-No-Cache')) {
mergedHeaders.set('Cache-Control', 'no-store');
}
mergedHeaders.delete('X-No-Cache');
}
// Idempotent POST (opt-in): buffer the body so it can be persisted for
// replay, then echo the key. Only reached when the client sent a valid
// Idempotency-Key on a first request; normal POSTs keep the streaming path
// below untouched.
if (idempotency?.kind === 'proceed') {
const bodyBytes = response.body ? await response.arrayBuffer() : new ArrayBuffer(0);
mergedHeaders.set(IDEMPOTENCY_HEADER, idempotency.key);
mergedHeaders.set(IDEMPOTENT_REPLAYED_HEADER, 'false');
// Awaited (not waitUntil'd) so a sub-second retry sees the completed
// record rather than a lingering 'processing' lock β 409. store() is
// best-effort/fail-open, so a Redis blip degrades to a re-executable
// retry, never a failed response.
// Generated response-envelope RPCs can report a retryable ServiceError
// inside HTTP 200. Feed store() a retryable status only for its
// persist-vs-release decision; the client still receives finalStatus.
await idempotency.store(
retryableResponse ? 503 : finalStatus,
bodyBytes,
response.headers.get('content-type'),
);
emitRequest(finalStatus, 'ok', resolvedCacheTier, bodyBytes.byteLength);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(bodyBytes, {
status: finalStatus,
statusText: response.statusText,
headers: mergedHeaders,
});
}
// Streaming/non-GET-200 responses: res_bytes is best-effort 0 (Content-Length
// is often absent on chunked responses; teeing the stream would add latency).
const finalContentLen = response.headers.get('content-length');
const finalResBytes = finalContentLen ? Number(finalContentLen) || 0 : 0;
emitRequest(finalStatus, 'ok', resolvedCacheTier, finalResBytes);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(response.body, {
status: finalStatus,
statusText: response.statusText,
headers: mergedHeaders,
});
};
}
|