@lensmcp/cluster 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/executors/gateway/gateway.lib.d.ts +15 -167
  2. package/executors/gateway/gateway.lib.d.ts.map +1 -1
  3. package/executors/gateway/gateway.lib.js +24 -1041
  4. package/executors/gateway/main.prod-gateway.js +74 -2
  5. package/executors/gateway/prod-gateway.lib.d.ts +19 -151
  6. package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
  7. package/executors/gateway/prod-gateway.lib.js +16 -863
  8. package/executors/gateway/prod-runtime/access-log.d.ts +9 -0
  9. package/executors/gateway/prod-runtime/access-log.d.ts.map +1 -0
  10. package/executors/gateway/prod-runtime/access-log.js +24 -0
  11. package/executors/gateway/prod-runtime/app.d.ts +23 -0
  12. package/executors/gateway/prod-runtime/app.d.ts.map +1 -0
  13. package/executors/gateway/prod-runtime/app.js +123 -0
  14. package/executors/gateway/prod-runtime/auth.d.ts +30 -0
  15. package/executors/gateway/prod-runtime/auth.d.ts.map +1 -0
  16. package/executors/gateway/prod-runtime/auth.js +51 -0
  17. package/executors/gateway/prod-runtime/cors.d.ts +19 -0
  18. package/executors/gateway/prod-runtime/cors.d.ts.map +1 -0
  19. package/executors/gateway/prod-runtime/cors.js +40 -0
  20. package/executors/gateway/prod-runtime/edge.d.ts +16 -0
  21. package/executors/gateway/prod-runtime/edge.d.ts.map +1 -0
  22. package/executors/gateway/prod-runtime/edge.js +65 -0
  23. package/executors/gateway/prod-runtime/handler.d.ts +32 -0
  24. package/executors/gateway/prod-runtime/handler.d.ts.map +1 -0
  25. package/executors/gateway/prod-runtime/handler.js +226 -0
  26. package/executors/gateway/prod-runtime/hooks.d.ts +20 -0
  27. package/executors/gateway/prod-runtime/hooks.d.ts.map +1 -0
  28. package/executors/gateway/prod-runtime/hooks.js +42 -0
  29. package/executors/gateway/prod-runtime/observability.d.ts +23 -0
  30. package/executors/gateway/prod-runtime/observability.d.ts.map +1 -0
  31. package/executors/gateway/prod-runtime/observability.js +125 -0
  32. package/executors/gateway/prod-runtime/rollout.d.ts +18 -0
  33. package/executors/gateway/prod-runtime/rollout.d.ts.map +1 -0
  34. package/executors/gateway/prod-runtime/rollout.js +103 -0
  35. package/executors/gateway/prod-runtime/routing.d.ts +4 -0
  36. package/executors/gateway/prod-runtime/routing.d.ts.map +1 -0
  37. package/executors/gateway/prod-runtime/routing.js +40 -0
  38. package/executors/gateway/prod-runtime/server.d.ts +3 -0
  39. package/executors/gateway/prod-runtime/server.d.ts.map +1 -0
  40. package/executors/gateway/prod-runtime/server.js +79 -0
  41. package/executors/gateway/prod-runtime/trust.d.ts +12 -0
  42. package/executors/gateway/prod-runtime/trust.d.ts.map +1 -0
  43. package/executors/gateway/prod-runtime/trust.js +32 -0
  44. package/executors/gateway/prod-runtime/types.d.ts +241 -0
  45. package/executors/gateway/prod-runtime/types.d.ts.map +1 -0
  46. package/executors/gateway/prod-runtime/types.js +2 -0
  47. package/executors/gateway/prod-runtime/upgrade.d.ts +21 -0
  48. package/executors/gateway/prod-runtime/upgrade.d.ts.map +1 -0
  49. package/executors/gateway/prod-runtime/upgrade.js +116 -0
  50. package/executors/gateway/prod-runtime/upstream.d.ts +13 -0
  51. package/executors/gateway/prod-runtime/upstream.d.ts.map +1 -0
  52. package/executors/gateway/prod-runtime/upstream.js +21 -0
  53. package/executors/gateway/runtime/auth.d.ts +8 -0
  54. package/executors/gateway/runtime/auth.d.ts.map +1 -0
  55. package/executors/gateway/runtime/auth.js +60 -0
  56. package/executors/gateway/runtime/dev-auth.d.ts +9 -0
  57. package/executors/gateway/runtime/dev-auth.d.ts.map +1 -0
  58. package/executors/gateway/runtime/dev-auth.js +79 -0
  59. package/executors/gateway/runtime/discovery.d.ts +17 -0
  60. package/executors/gateway/runtime/discovery.d.ts.map +1 -0
  61. package/executors/gateway/runtime/discovery.js +111 -0
  62. package/executors/gateway/runtime/edge.d.ts +31 -0
  63. package/executors/gateway/runtime/edge.d.ts.map +1 -0
  64. package/executors/gateway/runtime/edge.js +47 -0
  65. package/executors/gateway/runtime/handler.d.ts +24 -0
  66. package/executors/gateway/runtime/handler.d.ts.map +1 -0
  67. package/executors/gateway/runtime/handler.js +165 -0
  68. package/executors/gateway/runtime/hooks.d.ts +23 -0
  69. package/executors/gateway/runtime/hooks.d.ts.map +1 -0
  70. package/executors/gateway/runtime/hooks.js +55 -0
  71. package/executors/gateway/runtime/lens-children.d.ts +13 -0
  72. package/executors/gateway/runtime/lens-children.d.ts.map +1 -0
  73. package/executors/gateway/runtime/lens-children.js +136 -0
  74. package/executors/gateway/runtime/lifecycle.d.ts +13 -0
  75. package/executors/gateway/runtime/lifecycle.d.ts.map +1 -0
  76. package/executors/gateway/runtime/lifecycle.js +179 -0
  77. package/executors/gateway/runtime/observability.d.ts +17 -0
  78. package/executors/gateway/runtime/observability.d.ts.map +1 -0
  79. package/executors/gateway/runtime/observability.js +107 -0
  80. package/executors/gateway/runtime/proxy.d.ts +20 -0
  81. package/executors/gateway/runtime/proxy.d.ts.map +1 -0
  82. package/executors/gateway/runtime/proxy.js +113 -0
  83. package/executors/gateway/runtime/scope.d.ts +16 -0
  84. package/executors/gateway/runtime/scope.d.ts.map +1 -0
  85. package/executors/gateway/runtime/scope.js +68 -0
  86. package/executors/gateway/runtime/server.d.ts +3 -0
  87. package/executors/gateway/runtime/server.d.ts.map +1 -0
  88. package/executors/gateway/runtime/server.js +168 -0
  89. package/executors/gateway/runtime/service-keys.d.ts +11 -0
  90. package/executors/gateway/runtime/service-keys.d.ts.map +1 -0
  91. package/executors/gateway/runtime/service-keys.js +49 -0
  92. package/executors/gateway/runtime/types.d.ts +234 -0
  93. package/executors/gateway/runtime/types.d.ts.map +1 -0
  94. package/executors/gateway/runtime/types.js +2 -0
  95. package/executors/gateway/runtime/upgrade.d.ts +22 -0
  96. package/executors/gateway/runtime/upgrade.d.ts.map +1 -0
  97. package/executors/gateway/runtime/upgrade.js +63 -0
  98. package/package.json +3 -3
@@ -1,869 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.startProdGateway = startProdGateway;
4
- const tslib_1 = require("tslib");
3
+ exports.startProdGateway = void 0;
5
4
  /**
6
- * Production gateway runtime Fastify + @fastify/reply-from (undici) engine.
5
+ * Public facade for the PRODUCTION gateway runtime (Fastify + @fastify/reply-from).
7
6
  *
8
- * The SAME routing brain as dev (shared `matchRoute` / `buildRouteTable` /
9
- * manifest schema) composed over the prod providers (a manifest source +
10
- * endpoint resolution). The HTTP transport differs from dev on purpose: dev
11
- * uses node:http + http-proxy (it manages unix-socket pods + scale-from-zero),
12
- * prod uses Fastify + reply-from's undici pool connection-reusing, HTTP/1.1
13
- * pipelined, materially faster than node-http-proxy for the high-RPS edge.
7
+ * The implementation now lives in `./prod-runtime/*` as composable LAYERS,
8
+ * mirroring the dev gateway's `./runtime/*` see `prod-runtime/server.ts` for the
9
+ * composition root (`startProdGateway`), `prod-runtime/types.ts` for the shared
10
+ * types + the customer **hooks** API, and the sibling modules for each layer
11
+ * (routing, observability, rollout, upstream, auth, cors, trust, handler, upgrade,
12
+ * app, access-log, hooks). This barrel preserves the original module's public
13
+ * surface so the entry (`main.prod-gateway.ts`), the `@lensmcp/cluster` re-export
14
+ * (`gateway.ts`), the e2e suite, and the pentest/perf suites import from one stable
15
+ * path, and additionally exposes the hooks types for customer-authored layers.
14
16
  *
15
- * The exported contract (`startProdGateway` / `ProdGatewayOptions` /
16
- * `ProdGatewayHandle`) is unchanged from the previous engine, so the entry,
17
- * the e2e suite, and the perf suite drive it identically — the swap is proven
18
- * behind a stable seam.
19
- *
20
- * It keeps the dev trust boundary verbatim — strip client-supplied trust
21
- * headers, validate `x-api-key` on internal routes, inject the target's
22
- * `x-internal-token` — and the header-gated `gateway-request` flow trace, so
23
- * the lens (when ENABLED via opts.emit; off by default) sees identical events.
24
- * Per-request upstream selection (rollout cohort + sticky version) rides on
25
- * reply-from's `getUpstream` hook. WebSocket upgrades are proxied raw (a
26
- * net-level pipe) with the same trust model — no http-proxy in the prod path.
27
- *
28
- * No autoscale / idle-kill / process management here — prod pods are
29
- * always-on (k8s keeps them up); that machinery is the dev PodProvider's.
30
- */
31
- const node_crypto_1 = require("node:crypto");
32
- const net = tslib_1.__importStar(require("node:net"));
33
- const fastify_1 = tslib_1.__importDefault(require("fastify"));
34
- const reply_from_1 = tslib_1.__importDefault(require("@fastify/reply-from"));
35
- const manifest_1 = require("./manifest");
36
- const gateway_errors_1 = require("./gateway-errors");
37
- const eid = () => Date.now().toString(36) + (0, node_crypto_1.randomBytes)(6).toString('hex');
38
- const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
39
- /**
40
- * Send a gateway-generated edge error as the JSON envelope `{ key, status, traceId, message? }` (NOT
41
- * `text/plain`), stamping `x-request-id` so the trace id is on the response. The SAME `{ key, status }`
42
- * localized contract a service returns, so a browser app renders `t(key)` and surfaces the trace id.
43
- * `detail` is the dev-facing hint (the old plain-text reason), never shown to end users.
17
+ * The same routing brain as dev (shared `matchRoute` / `buildRouteTable` / manifest
18
+ * schema); the HTTP transport differs on purpose (dev: node:http + http-proxy over
19
+ * unix-socket pods; prod: Fastify + reply-from's undici pool for the high-RPS edge).
44
20
  */
45
- const sendError = (reply, req, status, reason, detail) => {
46
- const traceId = (0, gateway_errors_1.ensureRequestId)(req);
47
- return reply
48
- .code(status)
49
- .header('x-request-id', traceId)
50
- .type(gateway_errors_1.GATEWAY_ERROR_CONTENT_TYPE)
51
- .send((0, gateway_errors_1.gatewayErrorBody)(status, reason, traceId, detail));
52
- };
53
- // Path-canonicalization guard (CWE-288): a reverse proxy and its backend must agree
54
- // on the request path, or auth gets evaluated against a DIFFERENT path than the one
55
- // served (AWS API Gateway trailing-slash bypass, IBM API Connect CVE-2025-13915,
56
- // Kong/Envoy/NGINX normalization mismatch). We REJECT structurally-encoded paths
57
- // (encoded dot/slash/backslash/null) outright, and return the DECODED path for auth
58
- // matching so `/%61dmin` is matched as `/admin` — never skipped because it's encoded.
59
- const ENCODED_STRUCTURE = /%2e|%2f|%5c|%00|\\/i; // encoded . / \ NUL, or a raw backslash
60
- /** Canonical path for auth matching, or undefined if the path is unsafe (→ 400 / drop). */
61
- const canonicalPath = (rawUrl) => {
62
- const p = rawUrl.split('?')[0] ?? '/';
63
- if (ENCODED_STRUCTURE.test(p))
64
- return undefined;
65
- let decoded;
66
- try {
67
- decoded = decodeURIComponent(p);
68
- }
69
- catch {
70
- return undefined;
71
- } // malformed %xx
72
- if (decoded.includes('\0') || /(^|\/)\.\.(?=\/|$)/.test(decoded))
73
- return undefined; // NUL or `..` traversal
74
- // Strip fragment (#…) and per-segment matrix params (;…) so we match the path the
75
- // BACKEND resolves to — defeats /admin;x=1 and /admin#.css permission-skip bypass (CWE-288).
76
- return decoded.split('#')[0].split('/').map((s) => s.split(';')[0]).join('/') || '/';
77
- };
78
- async function startProdGateway(opts) {
79
- const ports = opts.ports?.length ? opts.ports : [opts.tls ? 8443 : 8080];
80
- // Default authenticator FAILS CLOSED: a jwt route with no authenticator wired is
81
- // rejected, never passed through. Provide opts.authenticate (or a verifier in the
82
- // entry) to actually validate tokens.
83
- const authenticate = opts.authenticate ?? ((mode) => {
84
- if (mode === 'jwt')
85
- throw new Error('jwt route but no authenticator configured');
86
- });
87
- const emit = (severity, title, fingerprint, raw, ctx) => {
88
- if (!opts.emit)
89
- return; // lens off by default — no work, no allocation beyond the call
90
- try {
91
- opts.emit({
92
- id: eid(), sessionId: 'pending', timestamp: Date.now(),
93
- source: 'gateway', category: 'cluster', severity,
94
- context: { sessionId: 'pending', ...ctx }, fingerprint, title, raw,
95
- });
96
- }
97
- catch { /* never let the bus break traffic */ }
98
- };
99
- // ---- route table (rebuilt atomically from the manifest source) ----
100
- let manifests = opts.manifests.list();
101
- let routes = (0, manifest_1.buildRouteTable)(manifests);
102
- let manifestByService = new Map(manifests.map((m) => [m.service, m]));
103
- const rebuild = (next) => {
104
- manifests = next;
105
- routes = (0, manifest_1.buildRouteTable)(next);
106
- manifestByService = new Map(next.map((m) => [m.service, m]));
107
- if (opts.healthChecker) { // monitor every reachable upstream: direct manifest URLs + pooled endpoints
108
- const urls = new Set();
109
- for (const r of routes)
110
- if (r.upstream)
111
- urls.add(r.upstream);
112
- for (const u of opts.pods?.endpoints?.() ?? [])
113
- urls.add(u);
114
- opts.healthChecker.track([...urls]);
115
- }
116
- emit('info', `gateway routes: ${routes.length} on ${manifests.length} services`, 'gateway-up', { kind: 'gateway-up', routes: routes.map((r) => ({ host: r.host ?? '(default)', service: r.service, internal: !!r.internal })) });
117
- };
118
- const unwatch = opts.manifests.watch?.((next) => rebuild(next));
119
- const keyToProject = new Map(Object.entries(opts.serviceKeys ?? {}).map(([p, k]) => [k, p]));
120
- const versionHeader = (opts.versionHeader ?? 'x-lensmcp-version').toLowerCase();
121
- const accessLog = !!opts.accessLog;
122
- // Origins allowed to make CREDENTIALED CORS requests (cookies). Exact-matched, lower-cased.
123
- const corsCredentialOrigins = new Set((opts.corsCredentialOrigins ?? []).map((o) => o.toLowerCase()));
124
- const clientIpHeader = opts.clientIpHeader?.toLowerCase();
125
- // `clientIpHeader` is only trusted from a known front proxy. Default: loopback
126
- // only (a co-located cloudflared/sidecar) — an off-proxy attacker hitting the
127
- // origin directly cannot spoof the `ip` ABAC attribute.
128
- const trustedProxies = opts.clientIpTrustedProxies ?? [];
129
- const peerTrusted = (peer) => {
130
- if (!peer)
131
- return false;
132
- const ip = peer.startsWith('::ffff:') ? peer.slice(7) : peer;
133
- if (trustedProxies.length === 0)
134
- return ip === '127.0.0.1' || peer === '::1';
135
- return trustedProxies.some((c) => (0, manifest_1.matchCidr)(ip, c));
136
- };
137
- /** Resolve the client IP: the trusted front-proxy header if the peer is trusted, else the fallback. */
138
- const clientIpOf = (req, fallback) => {
139
- if (clientIpHeader && peerTrusted(req.socket?.remoteAddress ?? undefined)) {
140
- const h = hdr(req.headers[clientIpHeader]);
141
- if (h)
142
- return h;
143
- }
144
- return fallback;
145
- };
146
- // ---- targeted rollout: attributes + sticky device key (cookie → uid header) ----
147
- const attrHeaders = (opts.attributeHeaders ?? []).map((h) => h.toLowerCase());
148
- const uidHeader = opts.uidHeader?.toLowerCase();
149
- const cookieOn = !!opts.cookie;
150
- const cookieName = opts.cookie?.name ?? 'lensmcp_did';
151
- const cookieSecret = opts.cookie?.secret;
152
- const cookieDomain = opts.cookie?.domain;
153
- const cookieMaxAge = opts.cookie?.maxAge ?? 31_536_000; // 1y
154
- if (cookieOn && !cookieSecret)
155
- console.warn('[gateway] device cookie has no secret — values are UNSIGNED (set cookie.secret to prevent bucket-grinding).');
156
- const signId = (id) => (0, node_crypto_1.createHmac)('sha256', cookieSecret).update(id).digest('base64url');
157
- const signedCookie = (id) => (cookieSecret ? `${id}.${signId(id)}` : id);
158
- const verifyCookie = (val) => {
159
- if (!cookieSecret)
160
- return val || undefined; // unsigned mode
161
- const i = val.lastIndexOf('.');
162
- if (i < 0)
163
- return undefined;
164
- const id = val.slice(0, i);
165
- const a = Buffer.from(val.slice(i + 1)), b = Buffer.from(signId(id));
166
- return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b) ? id : undefined;
167
- };
168
- const parseCookie = (header, name) => {
169
- if (!header)
170
- return undefined;
171
- for (const part of header.split(';')) {
172
- const eq = part.indexOf('=');
173
- if (eq > 0 && part.slice(0, eq).trim() === name)
174
- return decodeURIComponent(part.slice(eq + 1).trim());
175
- }
176
- return undefined;
177
- };
178
- const mintCookieHeader = (id) => {
179
- let c = `${cookieName}=${encodeURIComponent(signedCookie(id))}; Path=/; Max-Age=${cookieMaxAge}; HttpOnly; SameSite=Lax; Secure`;
180
- if (cookieDomain)
181
- c += `; Domain=${cookieDomain}`;
182
- return c;
183
- };
184
- /** Resolve the sticky key: valid cookie → UID header → freshly minted (cookie clients). */
185
- const resolveStickyKey = (req) => {
186
- if (cookieOn) {
187
- const c = parseCookie(req.headers.cookie, cookieName);
188
- const id = c ? verifyCookie(c) : undefined;
189
- if (id)
190
- return { key: id, mint: false };
191
- }
192
- if (uidHeader) {
193
- const u = hdr(req.headers[uidHeader]);
194
- if (u)
195
- return { key: u, mint: false };
196
- }
197
- if (cookieOn)
198
- return { key: 'd_' + (0, node_crypto_1.randomBytes)(12).toString('base64url'), mint: true };
199
- return { mint: false };
200
- };
201
- /** Assemble the flat attribute bag a rule evaluates against. Shared by the HTTP
202
- * and WebSocket paths so ABAC sees an identical bag on both (no divergence). */
203
- const buildAttrs = (req, fallbackIp, principal, key) => {
204
- const a = { ...(principal ?? {}) };
205
- a['ip'] = clientIpOf(req, fallbackIp);
206
- a['method'] = (req.method ?? 'GET').toUpperCase();
207
- a['path'] = (req.url ?? '/').split('?')[0];
208
- if (key)
209
- a['device'] = key;
210
- for (const h of attrHeaders) {
211
- const v = hdr(req.headers[h]);
212
- if (v !== undefined)
213
- a['header.' + h] = v;
214
- }
215
- return a;
216
- };
217
- // ---- traffic edges (host→service[@version]), flushed to the lens periodically ----
218
- const edges = new Map();
219
- const recordEdge = (route, ms, status, caller, version) => {
220
- const host = route.host ?? '(default)';
221
- const key = (caller ? caller + '⇒' : '') + host + '→' + route.service + (version ? '@' + version : '');
222
- const e = edges.get(key) ?? { host, service: route.service, caller, version, count: 0, totalMs: 0, errors: 0 };
223
- e.count += 1;
224
- e.totalMs += ms;
225
- if (status >= 500 || status === 0)
226
- e.errors += 1;
227
- edges.set(key, e);
228
- };
229
- // ---- per-service health rollup (feeds /statusz + the Redis status fan-out) ----
230
- // A ring of the last N flush windows, summed at read time → a rolling view that
231
- // doesn't flap to "idle" the instant traffic pauses. Derived purely from the
232
- // edges the gateway already records, so it's effectively free.
233
- const statusWindowCount = Math.max(1, opts.statusWindows ?? 12);
234
- const statusWindows = [];
235
- const lastSeenAt = new Map();
236
- const routeServices = () => [...new Set(routes.map((r) => r.service))];
237
- const pushStatusWindow = () => {
238
- const w = new Map();
239
- for (const e of edges.values()) {
240
- const s = w.get(e.service) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
241
- s.requests += e.count;
242
- s.errors += e.errors;
243
- s.totalMs += e.totalMs;
244
- if (e.version)
245
- s.versions.add(e.version);
246
- w.set(e.service, s);
247
- }
248
- const now = Date.now();
249
- for (const [svc, s] of w)
250
- if (s.requests > 0)
251
- lastSeenAt.set(svc, now);
252
- statusWindows.push(w);
253
- while (statusWindows.length > statusWindowCount)
254
- statusWindows.shift();
255
- };
256
- const buildStatusSnapshot = () => {
257
- const agg = new Map();
258
- for (const w of statusWindows)
259
- for (const [svc, s] of w) {
260
- const a = agg.get(svc) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
261
- a.requests += s.requests;
262
- a.errors += s.errors;
263
- a.totalMs += s.totalMs;
264
- for (const v of s.versions)
265
- a.versions.add(v);
266
- agg.set(svc, a);
267
- }
268
- return routeServices().map((svc) => {
269
- const a = agg.get(svc) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
270
- const hosts = [...new Set(routes.filter((r) => r.service === svc).map((r) => r.host ?? '(default)'))];
271
- const er = a.requests ? a.errors / a.requests : 0;
272
- let status = a.requests === 0 ? 'idle' : er >= 0.5 ? 'down' : er >= 0.05 ? 'degraded' : 'healthy';
273
- // active liveness: if every known upstream for this service is failing probes, it's down (even with no traffic)
274
- if (opts.healthChecker) {
275
- const svcUrls = routes.filter((r) => r.service === svc && r.upstream).map((r) => r.upstream);
276
- if (svcUrls.length && svcUrls.every((u) => !opts.healthChecker.isHealthy(u)))
277
- status = 'down';
278
- }
279
- return { service: svc, hosts, status, requests: a.requests, errorRate: +er.toFixed(4), avgMs: a.requests ? Math.round(a.totalMs / a.requests) : 0, versions: [...a.versions], lastSeen: lastSeenAt.get(svc) ?? null };
280
- });
281
- };
282
- const edgeFlusher = setInterval(() => {
283
- pushStatusWindow(); // always: roll the health window (independent of the lens)
284
- if (opts.onStatus) {
285
- try {
286
- opts.onStatus(buildStatusSnapshot());
287
- }
288
- catch { /* never let a status sink break traffic */ }
289
- }
290
- if (opts.emit) {
291
- for (const e of edges.values()) {
292
- emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.service}${e.version ? '@' + e.version : ''}: ${e.count} req`, `gateway-traffic:${e.host}->${e.service}${e.version ? '@' + e.version : ''}`, { kind: 'gateway-traffic', host: e.host, project: e.service, caller: e.caller, version: e.version, count: e.count, avgMs: Math.round(e.totalMs / e.count), errors: e.errors });
293
- }
294
- }
295
- edges.clear();
296
- }, opts.trafficFlushMs ?? 5000);
297
- edgeFlusher.unref?.();
298
- // ---- per-request flow trace (only for flow-header-carrying requests) ----
299
- const beginTrace = (req) => {
300
- if (!opts.emit)
301
- return undefined; // no lens → no trace overhead
302
- const flowId = hdr(req.headers['x-lensmcp-flow-id']);
303
- let requestId = hdr(req.headers['x-request-id']);
304
- if (!flowId && !requestId && !hdr(req.headers['traceparent']))
305
- return undefined;
306
- if (!requestId) {
307
- requestId = eid();
308
- req.headers['x-request-id'] = requestId;
309
- }
310
- return { startedAt: Date.now(), method: (req.method ?? 'GET').toUpperCase(), host: (req.headers.host || '').split(':')[0], url: req.url ?? '/', steps: [], ...(flowId ? { flowId } : {}), ...(requestId ? { requestId } : {}) };
311
- };
312
- const step = (t, name, info) => { if (t)
313
- t.steps.push({ step: name, atMs: Date.now() - t.startedAt, ...info }); };
314
- const finish = (t, service, status, extra) => {
315
- if (!t || t.done)
316
- return;
317
- t.done = true;
318
- emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${Date.now() - t.startedAt}ms)`, `gateway-request:${service}`, { kind: 'gateway-request', project: service, host: t.host, method: t.method, url: t.url, status, durationMs: Date.now() - t.startedAt, startedAt: t.startedAt, steps: t.steps, ...extra }, { ...(t.flowId ? { flowId: t.flowId } : {}), ...(t.requestId ? { requestId: t.requestId } : {}) });
319
- };
320
- rebuild(manifests); // emit initial gateway-up
321
- // ---- trust boundary (verbatim dev model) ----
322
- // The gateway OWNS these headers — drop any client-supplied copy at ingress so identity
323
- // can only originate from a verified token (closes header spoofing), then re-stamp on forward.
324
- const TRUST_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
325
- // Also drop client-supplied hop-by-hop control headers: a `Connection: x-internal-token`
326
- // would otherwise make undici strip the gateway's OWN injected trust headers before the
327
- // upstream (CVE-2026-33805). The proxy manages its own upstream connection semantics.
328
- const HOP_BY_HOP = ['connection', 'proxy-connection', 'keep-alive'];
329
- const stripTrust = (req) => { for (const h of TRUST_HEADERS)
330
- delete req.headers[h]; for (const h of HOP_BY_HOP)
331
- delete req.headers[h]; };
332
- /** Stamp the verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
333
- const stampIdentity = (req, claims) => {
334
- // Only stamp SCALAR claims — an object/array sub/tid must never coerce to
335
- // "[object Object]" / a CSV tenant header (claim type-confusion).
336
- const set = (k, v) => { if ((typeof v === 'string' && v !== '') || typeof v === 'number')
337
- req.headers[k] = String(v); };
338
- set('x-user-id', claims['sub']);
339
- set('x-tenant-id', claims['tid']);
340
- const roles = claims['roles'];
341
- if (Array.isArray(roles) && roles.length)
342
- req.headers['x-user-roles'] = roles.map(String).join(',');
343
- const perms = claims['perms'];
344
- if (Array.isArray(perms) && perms.length)
345
- req.headers['x-user-permissions'] = perms.map(String).join(',');
346
- };
347
- // ---- per-request upstream resolution (direct upstream OR pod pick + sticky version) ----
348
- const resolveUpstream = async (route, trace, pinned) => {
349
- if (route.upstream)
350
- return { url: route.upstream };
351
- const opt = {
352
- ...(pinned ? { version: pinned } : {}),
353
- ...(opts.healthChecker ? { isHealthy: (u) => opts.healthChecker.isHealthy(u) } : {}),
354
- };
355
- let up = opts.pods?.pick(route.service, opt);
356
- if (!up && opts.pods?.ensureUp) {
357
- step(trace, 'ensure-up', { project: route.service });
358
- if (await opts.pods.ensureUp(route.service))
359
- up = opts.pods.pick(route.service, opt);
360
- }
361
- return up;
362
- };
363
- const reqState = new WeakMap();
364
- const spanByReq = new WeakMap(); // OTel span per in-flight request (ended in onResponse)
365
- // ---- no-route fallbacks: CORS preflight, health, discovery, 404 ----
366
- const setCors = (reply, req) => {
367
- const origin = req.headers.origin;
368
- // Credentialed allowlist: echo the EXACT origin + allow credentials (cookies). NEVER `*` with
369
- // credentials (forbidden by the Fetch spec; a cross-site read hole). Needed for a first-party
370
- // browser app that POSTs to a cookie-setting routed service (e.g. an IdP SSO cookie).
371
- if (origin && corsCredentialOrigins.has(origin.toLowerCase())) {
372
- reply.header('access-control-allow-origin', origin);
373
- reply.header('access-control-allow-credentials', 'true');
374
- reply.header('vary', 'Origin');
375
- return;
376
- }
377
- // Otherwise — UNAUTHENTICATED gateway responses (404 / health / route discovery / preflight):
378
- // reflect the Origin for convenience but DO NOT allow credentials — a reflected origin +
379
- // credentials would let any site read these cross-origin.
380
- reply.header('access-control-allow-origin', String(origin ?? '*'));
381
- reply.header('vary', 'Origin');
382
- };
383
- const handleNoRoute = (request, reply, host, url) => {
384
- const req = request.raw;
385
- setCors(reply, req);
386
- if (req.method === 'OPTIONS') {
387
- reply.code(204)
388
- .header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
389
- .header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
390
- .send();
391
- return;
392
- }
393
- const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
394
- const p = url.split('?')[0];
395
- if (mounted.length > 0 && (p === '/health' || p === '/healthz')) {
396
- reply.code(200).type('application/json').send(JSON.stringify({ status: 'ok', host, services: mounted.map((r) => ({ service: r.service, prefix: r.prefix })) }));
397
- return;
398
- }
399
- if (p === '/.well-known/lensmcp-routes') {
400
- reply.code(200).type('application/json').send(JSON.stringify({ host, routes: routes.filter((r) => !r.host || (0, manifest_1.hostMatches)(r.host, host)).map((r) => ({ host: r.host ?? '(default)', service: r.service, prefix: r.prefix, internal: !!r.internal, auth: r.auth })) }));
401
- return;
402
- }
403
- sendError(reply, req, 404, 'not_found', mounted.length > 0 ? `No service mounted at ${url} on ${host}.` : 'No gateway route for this hostname.');
404
- };
405
- // ---- the request handler (catch-all): route → trust → auth → forward ----
406
- const handler = async (request, reply) => {
407
- const req = request.raw;
408
- const host = (req.headers.host || '').split(':')[0];
409
- const url = req.url ?? '/';
410
- const trace = beginTrace(req);
411
- // Mint a trace id for EVERY request (not just lens-traced ones): it's forwarded to the upstream (the
412
- // pod adopts it as its traceId) and returned on the response — so any request is traceable end-to-end.
413
- (0, gateway_errors_1.ensureRequestId)(req);
414
- step(trace, 'received', { host, method: req.method, url });
415
- // Reject HTTP/0.9 — a header-less simple request has no Host, defeating host-keyed routing/policy.
416
- if (req.httpVersionMajor < 1) {
417
- finish(trace, 'gateway', 400);
418
- return sendError(reply, req, 400, 'bad_request', 'Bad Request');
419
- }
420
- // Reject path-canonicalization attacks BEFORE routing/auth (proxy↔backend mismatch).
421
- const cpath = canonicalPath(url);
422
- if (cpath === undefined) {
423
- step(trace, 'bad-path', {});
424
- finish(trace, 'gateway', 400);
425
- return sendError(reply, req, 400, 'bad_request', 'Bad Request');
426
- }
427
- // Rate limit (opt-in) BEFORE routing/auth, per real client IP — defense-in-depth.
428
- if (opts.rateLimiter) {
429
- // Key on the trusted client IP (header only from a trusted peer) else the SOCKET ip —
430
- // never request.ip, which is the spoofable X-Forwarded-For chain under trustProxy.
431
- const d = await opts.rateLimiter.check(clientIpOf(req, req.socket?.remoteAddress ?? request.ip));
432
- if (!d.allowed) {
433
- reply.header('retry-after', String(Math.ceil(d.retryAfterMs / 1000)));
434
- step(trace, 'rate-limited', {});
435
- finish(trace, 'gateway', 429);
436
- return sendError(reply, req, 429, 'rate_limited', 'Too Many Requests');
437
- }
438
- }
439
- const route = (0, manifest_1.matchRoute)(routes, host, url);
440
- if (!route) {
441
- handleNoRoute(request, reply, host, url);
442
- return reply;
443
- }
444
- step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
445
- // CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries no
446
- // token by spec, so the auth below would 401 it and the browser's CORS check fails. The real request
447
- // still authenticates. (handleNoRoute covers UNROUTED preflights; this covers routed ones.)
448
- if (req.method === 'OPTIONS') {
449
- setCors(reply, req);
450
- reply.code(204)
451
- .header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
452
- .header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
453
- .send();
454
- return reply;
455
- }
456
- // OTel SERVER span (opt-in): continues the incoming traceparent. Stored now so
457
- // onResponse ends it on ANY exit path (auth reject / 503 / forward). Context is
458
- // injected into req.headers just before forwarding (below) so downstream parents to it.
459
- const span = opts.tracing?.startSpan(req, `gateway ${route.service}`, {
460
- 'http.request.method': (req.method ?? 'GET'),
461
- 'url.path': url.split('?')[0],
462
- 'server.address': host,
463
- 'lensmcp.service': route.service,
464
- ...(route.internal ? { 'lensmcp.internal': true } : {}),
465
- });
466
- if (span)
467
- spanByReq.set(request, span);
468
- // Verify the end-user JWT ONCE (when a verifier is configured) — reused for both edge
469
- // authorization and rollout targeting. Internal east-west routes use x-api-key instead.
470
- const claims = route.internal ? undefined : opts.identify?.(req);
471
- let caller;
472
- if (route.internal) {
473
- caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
474
- if (!caller) {
475
- step(trace, 'auth', { mode: 'x-api-key', ok: false });
476
- finish(trace, route.service, 401);
477
- return sendError(reply, req, 401, 'unauthorized', 'Invalid or missing x-api-key for internal route.');
478
- }
479
- req.headers['x-api-key-id'] = caller;
480
- delete req.headers['x-api-key'];
481
- step(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
482
- }
483
- else {
484
- const m = manifestByService.get(route.service);
485
- const resolved = m ? (0, manifest_1.authRuleForPath)(m, cpath, req.method ?? 'GET', false) : { mode: route.auth };
486
- if (resolved.mode === 'jwt') {
487
- if (opts.identify) {
488
- if (!claims) {
489
- step(trace, 'auth', { mode: 'jwt', ok: false });
490
- finish(trace, route.service, 401);
491
- return sendError(reply, req, 401, 'unauthorized', 'Unauthorized: invalid or missing token');
492
- }
493
- }
494
- else {
495
- try {
496
- authenticate('jwt', req);
497
- }
498
- catch (e) {
499
- step(trace, 'auth', { mode: 'jwt', ok: false });
500
- finish(trace, route.service, 401);
501
- return sendError(reply, req, 401, 'unauthorized', `Unauthorized: ${e.message}`);
502
- }
503
- }
504
- // edge ABAC: the required permission/rule must hold against the VERIFIED claims.
505
- // A present rule MUST be satisfied by VERIFIED claims. No claims (no verifier) → cannot satisfy → DENY (fail closed).
506
- if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, request.ip, claims, undefined)))) {
507
- step(trace, 'authz', { ok: false });
508
- finish(trace, route.service, 403);
509
- return sendError(reply, req, 403, 'forbidden', 'Forbidden: insufficient permission');
510
- }
511
- step(trace, 'auth', { mode: 'jwt', ok: true });
512
- if (claims)
513
- stampIdentity(req, claims);
514
- }
515
- else {
516
- step(trace, 'auth', { mode: resolved.mode });
517
- }
518
- }
519
- if (opts.serviceKeys?.[route.service])
520
- req.headers['x-internal-token'] = opts.serviceKeys[route.service];
521
- if (route.prependPrefix) {
522
- const u = req.url ?? '/';
523
- const pp = route.prependPrefix;
524
- if (u !== pp && !u.startsWith(pp + '/'))
525
- req.url = pp + u;
526
- } // segment-boundary, not bare startsWith (/apidoc must not escape /api)
527
- // ── version selection: explicit pin → ABAC rule → sticky-key weighted → SWRR ──
528
- // (targeting/stickiness only for external routes; internal east-west has no "device")
529
- const explicit = hdr(req.headers[versionHeader]);
530
- let pinned = explicit;
531
- let mintKey; // set → append Set-Cookie on the response
532
- if (!route.internal && !explicit) {
533
- const sk = resolveStickyKey(req);
534
- if (sk.mint)
535
- mintKey = sk.key;
536
- const attrs = buildAttrs(req, request.ip, claims, sk.key);
537
- const targeted = opts.pods?.resolveVersion?.(route.service, attrs);
538
- if (targeted) {
539
- pinned = targeted.version;
540
- step(trace, 'targeting', { rule: targeted.rule, version: targeted.version });
541
- }
542
- else if (sk.key) {
543
- const b = opts.pods?.bucket?.(route.service, sk.key);
544
- if (b) {
545
- pinned = b.version;
546
- step(trace, 'targeting', { mode: 'sticky', version: b.version });
547
- }
548
- }
549
- }
550
- const up = await resolveUpstream(route, trace, pinned);
551
- if (!up) {
552
- finish(trace, route.service, 503);
553
- return sendError(reply, req, 503, 'unavailable', `Service ${route.service} has no available upstream.`);
554
- }
555
- if (!('url' in up)) {
556
- finish(trace, route.service, 502);
557
- return sendError(reply, req, 502, 'unavailable', `Service ${route.service}: socket upstreams are not supported by the prod gateway.`);
558
- }
559
- const origin = new URL(up.url).origin;
560
- const upLabel = up.url;
561
- reqState.set(request, { route, caller, startedAt: Date.now(), trace, version: up.version, upLabel });
562
- step(trace, up.version ? 'rollout' : 'upstream', { upstream: upLabel, ...(up.version ? { version: up.version, ...(explicit ? { pinned: true } : {}) } : {}) });
563
- // Propagate the gateway span's context downstream (W3C traceparent on the forwarded request).
564
- if (span)
565
- opts.tracing.inject(span, req.headers);
566
- // No source → reply-from forwards the (prefix-rewritten) req.url to `origin`.
567
- return reply.from(undefined, {
568
- getUpstream: () => origin,
569
- rewriteHeaders: (headers) => {
570
- headers['x-lensmcp-upstream'] = upLabel;
571
- if (!headers['x-request-id'])
572
- headers['x-request-id'] = (0, gateway_errors_1.ensureRequestId)(req); // trace id on success too (if the upstream didn't echo it)
573
- if (up.version)
574
- headers['x-lensmcp-version'] = up.version; // which rollout cohort served
575
- // The gateway owns the edge response: drop the upstream framework leak
576
- // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
577
- delete headers['x-powered-by'];
578
- delete headers['connection'];
579
- delete headers['keep-alive'];
580
- delete headers['proxy-connection']; // strip hop-by-hop from the upstream response
581
- headers['server'] = 'LensMCP';
582
- if (mintKey) { // first contact → stamp the device cookie (appended to any upstream Set-Cookie)
583
- const ex = headers['set-cookie'];
584
- const list = Array.isArray(ex) ? ex : ex ? [ex] : [];
585
- headers['set-cookie'] = [...list, mintCookieHeader(mintKey)];
586
- }
587
- return headers;
588
- },
589
- onError: (rep, { error }) => {
590
- opts.pods?.drop?.(route.service, up);
591
- void error; // don't reflect the raw upstream error (host:port / errno) to the client
592
- sendError(rep, req, 502, 'unavailable', `Upstream ${route.service} unavailable.`);
593
- },
594
- });
595
- };
596
- // ---- raw WebSocket upgrade proxy (same trust model; net-level pipe) ----
597
- const upgrade = (req, socket, head) => {
598
- stripTrust(req);
599
- const host = (req.headers.host || '').split(':')[0];
600
- const cpath = canonicalPath(req.url ?? '/');
601
- if (cpath === undefined) {
602
- socket.destroy();
603
- return;
604
- } // reject path-canonicalization attacks
605
- const route = (0, manifest_1.matchRoute)(routes, host, req.url ?? '/');
606
- if (!route) {
607
- socket.destroy();
608
- return;
609
- }
610
- const claims = route.internal ? undefined : opts.identify?.(req);
611
- let caller;
612
- if (route.internal) {
613
- caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
614
- if (!caller) {
615
- socket.destroy();
616
- return;
617
- }
618
- }
619
- else {
620
- const m = manifestByService.get(route.service);
621
- const resolved = m ? (0, manifest_1.authRuleForPath)(m, cpath, req.method ?? 'GET', false) : { mode: route.auth };
622
- if (resolved.mode === 'jwt') {
623
- if (opts.identify) {
624
- if (!claims) {
625
- socket.destroy();
626
- return;
627
- }
628
- }
629
- else {
630
- try {
631
- authenticate('jwt', req);
632
- }
633
- catch {
634
- socket.destroy();
635
- return;
636
- }
637
- }
638
- // Same fail-closed ABAC + identical attribute bag as the HTTP path (no divergence).
639
- if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, req.socket.remoteAddress ?? '', claims, undefined)))) {
640
- socket.destroy();
641
- return;
642
- }
643
- }
644
- }
645
- if (route.prependPrefix) {
646
- const u = req.url ?? '/';
647
- const pp = route.prependPrefix;
648
- if (u !== pp && !u.startsWith(pp + '/'))
649
- req.url = pp + u;
650
- } // segment-boundary, not bare startsWith (/apidoc must not escape /api)
651
- const pinned = hdr(req.headers[versionHeader]);
652
- void (async () => {
653
- const up = await resolveUpstream(route, undefined, pinned);
654
- if (!up || !('url' in up)) {
655
- socket.destroy();
656
- return;
657
- }
658
- const u = new URL(up.url);
659
- const upstream = net.connect(Number(u.port) || (u.protocol === 'https:' ? 443 : 80), u.hostname, () => {
660
- // rebuild the upgrade request from rawHeaders (verbatim), minus the trust headers
661
- // we own (incl. client identity), plus the validated caller + target token + identity.
662
- // Strip the trust headers AND the client's hop-by-hop control (connection/keep-alive),
663
- // so a `Connection: x-internal-token` can't strip the gateway's injected headers downstream.
664
- const strip = new Set(['x-internal-token', 'x-api-key-id', 'x-api-key', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions', 'connection', 'proxy-connection', 'keep-alive']);
665
- const lines = [`${req.method} ${req.url} HTTP/1.1`, 'Connection: Upgrade']; // gateway-owned, clean upgrade
666
- for (let i = 0; i < req.rawHeaders.length; i += 2) {
667
- const k = req.rawHeaders[i];
668
- if (!strip.has(k.toLowerCase()))
669
- lines.push(`${k}: ${req.rawHeaders[i + 1]}`);
670
- }
671
- if (caller)
672
- lines.push(`x-api-key-id: ${caller}`);
673
- const token = opts.serviceKeys?.[route.service];
674
- if (token)
675
- lines.push(`x-internal-token: ${token}`);
676
- if (claims) { // stamp the verified identity (mirrors the HTTP path's stampIdentity)
677
- if (claims['sub'])
678
- lines.push(`x-user-id: ${String(claims['sub'])}`);
679
- if (claims['tid'])
680
- lines.push(`x-tenant-id: ${String(claims['tid'])}`);
681
- const roles = claims['roles'];
682
- if (Array.isArray(roles) && roles.length)
683
- lines.push(`x-user-roles: ${roles.map(String).join(',')}`);
684
- const perms = claims['perms'];
685
- if (Array.isArray(perms) && perms.length)
686
- lines.push(`x-user-permissions: ${perms.map(String).join(',')}`);
687
- }
688
- upstream.write(lines.join('\r\n') + '\r\n\r\n');
689
- if (head?.length)
690
- upstream.write(head);
691
- socket.pipe(upstream);
692
- upstream.pipe(socket);
693
- });
694
- upstream.on('error', () => socket.destroy());
695
- socket.on('error', () => upstream.destroy());
696
- })();
697
- };
698
- // ---- opt-in access log: one Cloud-Logging-shaped JSON line per response ----
699
- // Covers EVERY response (incl. 401/404/503 that never reached an upstream),
700
- // not just forwarded ones; `reply.elapsedTime` gives latency without our own timer.
701
- const logAccess = (request, reply, st) => {
702
- const req = request.raw;
703
- const status = reply.statusCode ?? 0;
704
- const host = (req.headers.host || '').split(':')[0];
705
- const path = (req.url || '/').split('?')[0];
706
- const ms = Math.round(reply.elapsedTime);
707
- try {
708
- console.log(JSON.stringify({
709
- severity: status >= 500 ? 'ERROR' : status >= 400 ? 'WARNING' : 'INFO',
710
- time: new Date().toISOString(),
711
- message: `${req.method} ${host}${path} ${status} ${ms}ms`,
712
- method: req.method, host, path, status, ms,
713
- ...(st ? { service: st.route.service, upstream: st.upLabel } : {}),
714
- ...(st?.version ? { version: st.version } : {}),
715
- ...(st?.caller ? { caller: st.caller } : {}),
716
- ...(req.headers['x-request-id'] ? { requestId: String(req.headers['x-request-id']) } : {}),
717
- }));
718
- }
719
- catch { /* never let logging break the response */ }
720
- };
721
- // ---- build one Fastify app per port (shared route table + providers) ----
722
- const buildApp = async () => {
723
- const keepAlive = opts.server?.keepAliveTimeout ?? 620_000; // > GCP external ALB 600s backend keep-alive
724
- const app = (0, fastify_1.default)({
725
- logger: false,
726
- keepAliveTimeout: keepAlive,
727
- requestTimeout: opts.server?.requestTimeout ?? 0, // 0 = no inbound request timeout (long-lived streaming)
728
- ...(opts.server?.connectionTimeout !== undefined ? { connectionTimeout: opts.server.connectionTimeout } : {}),
729
- ...(opts.trustProxyIp ? { trustProxy: true } : {}),
730
- ...(opts.tls ? { https: { key: opts.tls.key, cert: opts.tls.cert } } : {}),
731
- });
732
- // headersTimeout must stay above keepAliveTimeout, else Node may close an idle
733
- // keep-alive socket mid-request under load. Default: just above keepAlive.
734
- app.server.headersTimeout = opts.server?.headersTimeout ?? keepAlive + 10_000;
735
- // Stream every body straight through — never parse/buffer (this is a proxy).
736
- app.removeAllContentTypeParsers();
737
- app.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
738
- await app.register(reply_from_1.default, {
739
- disableRequestLogging: true,
740
- undici: {
741
- connections: opts.undici?.connections ?? 256,
742
- pipelining: opts.undici?.pipelining ?? 1,
743
- keepAliveTimeout: opts.undici?.keepAliveTimeout ?? 60_000,
744
- bodyTimeout: opts.undici?.bodyTimeout ?? 0, // 0 = unlimited — never cut long-lived SSE / streaming MCP responses
745
- headersTimeout: opts.undici?.headersTimeout ?? 60_000, // upstream must send response headers within 60s
746
- ...(opts.undici?.allowH2 ? { allowH2: true } : {}), // multiplex many MCP streams over few h2 conns (Cloud Run speaks h2)
747
- connect: {
748
- rejectUnauthorized: opts.undici?.rejectUnauthorized ?? false, // local-CA/self-signed by default; set true for verified east-west on GCP
749
- ...(opts.undici?.cert ? { cert: opts.undici.cert } : {}), // ── mTLS: present a client cert to upstreams ──
750
- ...(opts.undici?.key ? { key: opts.undici.key } : {}),
751
- ...(opts.undici?.ca ? { ca: opts.undici.ca } : {}), // trust a private CA for the server side
752
- },
753
- },
754
- });
755
- // Host-agnostic operational probes (answered before routing).
756
- app.get('/livez', (_req, reply) => { reply.type('text/plain').send('ok'); });
757
- app.get('/readyz', (_req, reply) => {
758
- const ready = routes.length > 0;
759
- reply.code(ready ? 200 : 503).type('text/plain').send(ready ? 'ready' : 'no routes loaded');
760
- });
761
- // Per-service health rollup (opt-in; token-guarded). For a status page that
762
- // prefers HTTP over the Redis `status:*` fan-out. Off by default → 404 (don't
763
- // leak topology on a public edge unless explicitly enabled).
764
- app.get('/statusz', (request, reply) => {
765
- // Fail closed: /statusz is served ONLY when both enabled AND a token is set
766
- // (never expose topology/health unauthenticated). Constant-time token compare.
767
- if (!opts.statusEndpoint || !opts.statusToken) {
768
- reply.code(404).type('text/plain').send('not found');
769
- return;
770
- }
771
- const tok = request.query?.token ?? hdr(request.raw.headers['x-status-token']) ?? '';
772
- const a = Buffer.from(tok), b = Buffer.from(opts.statusToken);
773
- if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b)) {
774
- reply.code(401).type('text/plain').send('unauthorized');
775
- return;
776
- }
777
- reply.type('application/json').send(JSON.stringify({ generatedAt: Date.now(), services: buildStatusSnapshot(), endpoints: opts.healthChecker?.snapshot() ?? [] }));
778
- });
779
- // Process self-metrics (event-loop lag / rss / cpu / req rates) — same token gate as /statusz.
780
- app.get('/metricsz', (request, reply) => {
781
- if (!opts.metrics || !opts.statusToken) {
782
- reply.code(404).type('text/plain').send('not found');
783
- return;
784
- }
785
- const tok = request.query?.token ?? hdr(request.raw.headers['x-status-token']) ?? '';
786
- const a = Buffer.from(tok), b = Buffer.from(opts.statusToken);
787
- if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b)) {
788
- reply.code(401).type('text/plain').send('unauthorized');
789
- return;
790
- }
791
- reply.type('application/json').send(JSON.stringify(opts.metrics.snapshot()));
792
- });
793
- app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
794
- // Edge CORS authority: stamp Access-Control-Allow-Origin on EVERY response that lacks it — including the
795
- // 401/403/5xx the gateway generates ITSELF (edge auth/authz/proxy errors) which never reach an upstream.
796
- // Without it the browser sees the error WITHOUT CORS and reports a CORS failure that MASKS the real
797
- // status (e.g. a 401 the app should handle by re-authing). Add ONLY if absent so a proxied success keeps
798
- // the upstream's header (no duplicate). No credentials (Bearer APIs; matches setCors).
799
- app.addHook('onSend', (request, reply, payload, done) => {
800
- const origin = request.headers.origin;
801
- if (origin && !reply.getHeader('access-control-allow-origin')) {
802
- // A credentialed-allowlist origin gets the EXACT origin + ACAC:true (so a gateway-generated
803
- // 401/403/5xx on a credentialed first-party origin still passes the browser's CORS check);
804
- // everyone else gets the reflected origin WITHOUT credentials (matches setCors).
805
- if (corsCredentialOrigins.has(origin.toLowerCase())) {
806
- reply.header('access-control-allow-origin', origin);
807
- reply.header('access-control-allow-credentials', 'true');
808
- }
809
- else {
810
- reply.header('access-control-allow-origin', String(origin));
811
- }
812
- reply.header('vary', 'Origin');
813
- }
814
- done(null, payload);
815
- });
816
- app.addHook('onResponse', (request, reply, done) => {
817
- const st = reqState.get(request);
818
- if (st) {
819
- recordEdge(st.route, Date.now() - st.startedAt, reply.statusCode ?? 0, st.caller, st.version);
820
- finish(st.trace, st.route.service, reply.statusCode ?? 0, { upstream: st.upLabel, ...(st.version ? { version: st.version } : {}), ...(st.caller ? { caller: st.caller } : {}) });
821
- reqState.delete(request);
822
- }
823
- const sp = spanByReq.get(request);
824
- if (sp) {
825
- opts.tracing.end(sp, reply.statusCode ?? 0, { ...(st?.upLabel ? { 'upstream.url': st.upLabel } : {}), ...(st?.version ? { 'lensmcp.version': st.version } : {}) });
826
- spanByReq.delete(request);
827
- }
828
- opts.metrics?.recordRequest(reply.statusCode ?? 0);
829
- if (accessLog)
830
- logAccess(request, reply, st);
831
- done();
832
- });
833
- app.all('/', handler);
834
- app.all('/*', handler);
835
- app.server.on('upgrade', upgrade);
836
- return app;
837
- };
838
- // ---- listeners ----
839
- const apps = [];
840
- const boundPorts = [];
841
- for (const port of ports) {
842
- const app = await buildApp();
843
- await app.listen({ port, host: '0.0.0.0' });
844
- const addr = app.server.address();
845
- const bound = typeof addr === 'object' && addr ? addr.port : port;
846
- boundPorts.push(bound);
847
- console.log(`[gateway] listening on ${opts.tls ? 'https' : 'http'}://0.0.0.0:${bound}`);
848
- apps.push(app);
849
- }
850
- opts.healthChecker?.start(); // begin probing now that the route table (and tracked URLs) is warm
851
- let stopped = false;
852
- return {
853
- ports: boundPorts,
854
- routes: () => routes,
855
- status: buildStatusSnapshot,
856
- stop: async () => {
857
- if (stopped)
858
- return;
859
- stopped = true;
860
- clearInterval(edgeFlusher);
861
- unwatch?.();
862
- opts.healthChecker?.stop();
863
- opts.rateLimiter?.stop();
864
- opts.metrics?.stop();
865
- emit('info', 'gateway down', 'gateway-down', { kind: 'gateway-down' });
866
- await Promise.all(apps.map((a) => a.close())); // reply-from closes its undici pool on close
867
- },
868
- };
869
- }
21
+ var server_1 = require("./prod-runtime/server");
22
+ Object.defineProperty(exports, "startProdGateway", { enumerable: true, get: function () { return server_1.startProdGateway; } });