@lensmcp/cluster 1.21.2 → 1.21.4

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 (66) hide show
  1. package/create-webpack-dev.d.ts +45 -0
  2. package/create-webpack-dev.js +1 -1
  3. package/executors/gateway/body-stall.d.ts +37 -0
  4. package/executors/gateway/body-stall.js +1 -0
  5. package/executors/gateway/canonical-path.d.ts +65 -0
  6. package/executors/gateway/canonical-path.js +1 -0
  7. package/executors/gateway/client-ip.d.ts +44 -0
  8. package/executors/gateway/client-ip.js +1 -0
  9. package/executors/gateway/jwks-verify.js +1 -1
  10. package/executors/gateway/main.prod-gateway.js +2 -2
  11. package/executors/gateway/manifest.d.ts +46 -4
  12. package/executors/gateway/manifest.js +1 -1
  13. package/executors/gateway/prod-config.d.ts +74 -0
  14. package/executors/gateway/prod-config.js +1 -0
  15. package/executors/gateway/prod-runtime/access-log.js +1 -1
  16. package/executors/gateway/prod-runtime/app.d.ts +6 -0
  17. package/executors/gateway/prod-runtime/app.js +1 -1
  18. package/executors/gateway/prod-runtime/auth.d.ts +1 -1
  19. package/executors/gateway/prod-runtime/auth.js +1 -1
  20. package/executors/gateway/prod-runtime/body-stall.d.ts +5 -0
  21. package/executors/gateway/prod-runtime/body-stall.js +1 -0
  22. package/executors/gateway/prod-runtime/cors.js +1 -1
  23. package/executors/gateway/prod-runtime/edge.d.ts +19 -3
  24. package/executors/gateway/prod-runtime/edge.js +1 -1
  25. package/executors/gateway/prod-runtime/handler.js +1 -1
  26. package/executors/gateway/prod-runtime/rollout.d.ts +11 -4
  27. package/executors/gateway/prod-runtime/rollout.js +1 -1
  28. package/executors/gateway/prod-runtime/routing.js +1 -1
  29. package/executors/gateway/prod-runtime/server.js +1 -1
  30. package/executors/gateway/prod-runtime/types.d.ts +30 -3
  31. package/executors/gateway/prod-runtime/upgrade.d.ts +25 -0
  32. package/executors/gateway/prod-runtime/upgrade.js +10 -2
  33. package/executors/gateway/prod-runtime/upstream.d.ts +3 -1
  34. package/executors/gateway/prod-runtime/upstream.js +1 -1
  35. package/executors/gateway/providers-prod.d.ts +13 -4
  36. package/executors/gateway/providers-prod.js +1 -1
  37. package/executors/gateway/registry-source.js +1 -1
  38. package/executors/gateway/rollout-ops.js +2 -2
  39. package/executors/gateway/runtime/auth.d.ts +6 -1
  40. package/executors/gateway/runtime/auth.js +1 -1
  41. package/executors/gateway/runtime/control.js +2 -1
  42. package/executors/gateway/runtime/dev-auth.d.ts +6 -2
  43. package/executors/gateway/runtime/dev-auth.js +1 -1
  44. package/executors/gateway/runtime/discovery.js +1 -1
  45. package/executors/gateway/runtime/edge.d.ts +19 -0
  46. package/executors/gateway/runtime/edge.js +1 -1
  47. package/executors/gateway/runtime/handler.js +1 -1
  48. package/executors/gateway/runtime/lens-children.d.ts +7 -0
  49. package/executors/gateway/runtime/lens-children.js +1 -1
  50. package/executors/gateway/runtime/lifecycle.d.ts +69 -7
  51. package/executors/gateway/runtime/lifecycle.js +3 -3
  52. package/executors/gateway/runtime/pod-probe.d.ts +37 -0
  53. package/executors/gateway/runtime/pod-probe.js +1 -0
  54. package/executors/gateway/runtime/proxy.js +1 -1
  55. package/executors/gateway/runtime/scope.d.ts +15 -0
  56. package/executors/gateway/runtime/scope.js +1 -1
  57. package/executors/gateway/runtime/server.js +2 -2
  58. package/executors/gateway/runtime/service-keys.d.ts +42 -1
  59. package/executors/gateway/runtime/service-keys.js +1 -1
  60. package/executors/gateway/runtime/types.d.ts +193 -1
  61. package/executors/gateway/runtime/types.js +1 -1
  62. package/executors/gateway/runtime/upgrade.js +1 -1
  63. package/executors/gateway/runtime/workspace-log.d.ts +7 -0
  64. package/executors/gateway/runtime/workspace-log.js +1 -0
  65. package/main.devserver.js +7 -7
  66. package/package.json +4 -4
@@ -23,4 +23,49 @@ export interface DevWebpackOptions {
23
23
  /** Parent terminates TLS (gateway.https) — reload notifications go over https. */
24
24
  httpsReload?: boolean;
25
25
  }
26
+ /**
27
+ * Notifies the devserver parent on every successful rebuild (`POST /webpack/reload` → rolling hot-swap of the
28
+ * pod children) AND stamps the pod's WATCHER TESTIMONY (`POST /webpack/watching`): every `watchRun`, `done`,
29
+ * `failed` and `watchClose`, plus any watchpack error. The parent relays the latest testimony to its children,
30
+ * which serve it in `/__lensmcp/alive`; the gateway's pod sweeper reads it before a `stale-source-set` recycle
31
+ * (`lifecycle.ts` `podWatcherVerdict`). Without it the sweeper had NO evidence and scaled every pod to zero on
32
+ * every source-set change in its scope (issues/dev-gateway/07). Both posts are best-effort: a parent that
33
+ * predates `/webpack/watching` answers 404 and the plugin stays quiet.
34
+ */
35
+ export declare class DevServerReloadPlugin {
36
+ private base;
37
+ private secure;
38
+ /** The devserver parent's CONTROL socket — preferred over the TCP port (see `post`). */
39
+ private controlSock;
40
+ /** Bounded, newest-last paths of the last few `watchRun`s — diagnostics for the register, not policy. */
41
+ private static readonly RECENT_PATH_CAP;
42
+ private testimony;
43
+ constructor(port: number, secure?: boolean, controlSock?: string);
44
+ /**
45
+ * Deliver to the parent's CONTROL SOCKET first (`$TMPDIR[/<wsKey>]/<service>-devserver/parent.sock`), then
46
+ * the TCP port. The socket path is bind-STOLEN by whichever parent generation started last, so during a
47
+ * rolling recycle (lensmcp issues/dev-gateway/09) this webpack's rebuilds reach ITS OWN parent even while the
48
+ * retiring generation still holds the TCP port; the port is the fallback for a parent without the socket.
49
+ */
50
+ private post;
51
+ /** The current testimony snapshot (exported for the spec — the wire shape the parent receives). */
52
+ snapshot(): PodWatcherTestimonyWire;
53
+ private stampWatchRun;
54
+ private degrade;
55
+ private tellWatching;
56
+ apply(compiler: any): void;
57
+ }
58
+ /** The devserver parent's control socket for `serviceName` — the SAME derivation as `main.devserver.ts`'s
59
+ * `SOCK_DIR` (`$TMPDIR[/<LENSMCP_WS_KEY>]/<service>-devserver/parent.sock`). */
60
+ export declare function devserverControlSock(serviceName: string, env?: NodeJS.ProcessEnv): string;
61
+ /** Wire shape of the testimony (mirrors `PodWatcherTestimony` in the gateway runtime; kept structural so the
62
+ * webpack side does not import the gateway's types). */
63
+ export interface PodWatcherTestimonyWire {
64
+ schemaVersion: 1;
65
+ lastWatchRunAt: number;
66
+ lastBuildAt: number;
67
+ buildsSinceSpawn: number;
68
+ degraded: false | string;
69
+ recentPaths: string[];
70
+ }
26
71
  export declare function createDevWebpackConfig(options: DevWebpackOptions): Record<string, any>;
@@ -1 +1 @@
1
- "use strict";var x=Object.defineProperty;var u=(o,t)=>x(o,"name",{value:t,configurable:!0});var T=Object.defineProperty,l=u((o,t)=>T(o,"name",{value:t,configurable:!0}),"l");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createDevWebpackConfig=createDevWebpackConfig;const tslib_1=require("tslib"),app_plugin_1=require("@nx/webpack/app-plugin"),fs=tslib_1.__importStar(require("node:fs")),path=tslib_1.__importStar(require("node:path")),webpack_node_externals_1=tslib_1.__importDefault(require("webpack-node-externals")),build_scope_patterns_1=require("./build-scope-patterns"),tsgo_check_plugin_1=require("./tsgo-check-plugin"),webpack=require("webpack");class DevServerReloadPlugin{static{u(this,"DevServerReloadPlugin")}static{l(this,"DevServerReloadPlugin")}constructor(t,s=!1){this.secure=s,this.url=`${s?"https":"http"}://localhost:${t}/webpack/reload`}notify(){return new Promise((t,s)=>{const i=(this.secure?require("node:https"):require("node:http")).request(this.url,{method:"POST",headers:{"content-type":"application/json"},rejectUnauthorized:!1},a=>{a.resume(),a.on("end",t)});i.on("error",s),i.end("{}")})}apply(t){t.hooks.done.tap("DevServerReloadPlugin",async s=>{try{if(s.hasErrors())return;await this.notify(),console.log("[DevServerReloadPlugin] notified devserver to reload")}catch{console.log("[DevServerReloadPlugin] devserver not running yet")}})}}function createDevWebpackConfig(o){const{appRoot:t,outputDir:s,main:i,tsConfig:a,workspaceRoot:r,assets:f=[],port:b,memoryLimit:P=8192,buildLibsFromSource:k=!0,orgScopes:w=[],bundlePackages:S=[],additionalEntryPoints:C=[],nodeExternalsConfig:p,webpackConfigPath:d}=o,{allowlistPatterns:y,scopePrefixes:E,scopePatterns:N}=(0,build_scope_patterns_1.buildScopePatterns)(w),h=process.env.LENSMCP_TYPECHECK,m=h==="tsgo"?(0,tsgo_check_plugin_1.resolveTsgoBin)(r,process.env.LENSMCP_TSGO_BIN):null;if(h==="tsgo"&&!m){const e=`${tsgo_check_plugin_1.TSGO_MISSING_CAUSE}. Fell back to fork-ts-checker, which reports to the console only \u2014 so typecheck:// has no producer for this project until it is fixed`;console.warn(`[serve] ${o.appName}: LENSMCP_TYPECHECK=tsgo but ${e}.`),(0,tsgo_check_plugin_1.reportTsgoUnavailable)({appName:o.appName,tsConfig:a,workspaceRoot:r,detail:e})}const g=!!m,v=S.map(e=>new RegExp(`^${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}(/|$)`)),D=[/webpack\/hot\/poll\?100/,...y,...v,...p?.allowlist||[]],_={output:{path:s,...process.env.NODE_ENV!=="production"&&{devtoolModuleFilenameTemplate:"[absolute-resource-path]"},clean:!0},externals:[(0,webpack_node_externals_1.default)({allowlist:D,additionalModuleDirs:p?.additionalModuleDirs||[],...p?.importType&&{importType:p.importType}}),({request:e},c)=>{if(e&&path.isAbsolute(e)&&e.startsWith(r)&&!e.includes("node_modules")||e&&(e.startsWith(path.join(r,"apps"))||e.startsWith(path.join(r,"libs")))||e&&E.some(n=>e.startsWith(n))||e&&N.some(n=>n.test(e))||e&&v.some(n=>n.test(e)))return c();if(e&&!(e.startsWith("./")||e.startsWith(".."))){const n=e.startsWith("@")?e.split("/").slice(0,2).join("/"):e.split("/")[0];return fs.existsSync(path.join(r,"node_modules",n))?c(null,`commonjs ${e}`):c()}return c()}],mode:"development",devtool:"eval-cheap-module-source-map",plugins:[new webpack.HotModuleReplacementPlugin,new app_plugin_1.NxAppWebpackPlugin({target:"node22",compiler:"tsc",main:i,additionalEntryPoints:C,verbose:!0,sourceMap:"eval-cheap-module-source-map",mergeExternals:!0,externalDependencies:[],memoryLimit:P,tsConfig:a,assets:f,optimization:!1,progress:!1,outputHashing:"none",generatePackageJson:!1,watchDependencies:!0,typeCheckOptions:process.env.LENSMCP_TYPECHECK==="off"||g?!1:{async:!0},buildLibsFromSource:k}),new DevServerReloadPlugin(b,o.httpsReload===!0),...g?[new tsgo_check_plugin_1.TsgoCheckPlugin({appName:o.appName,tsConfig:a,workspaceRoot:r,...process.env.LENSMCP_TSGO_BIN?{binPath:process.env.LENSMCP_TSGO_BIN}:{}})]:[]],snapshot:{managedPaths:[/^(.+?[\\/])?node_modules[\\/]/]},watch:!0,watchOptions:{ignored:["**/*.env.template","**/config.template.json","**/*.md","**/dist/**","**/migrations/**"]},cache:{type:"filesystem"}};if(d){const e=require(path.resolve(t,d));return(e.default||e)(_)}return _}u(createDevWebpackConfig,"createDevWebpackConfig"),l(createDevWebpackConfig,"createDevWebpackConfig");
1
+ "use strict";var R=Object.defineProperty;var d=(n,t)=>R(n,"name",{value:t,configurable:!0});var T=Object.defineProperty,u=d((n,t)=>T(n,"name",{value:t,configurable:!0}),"u");Object.defineProperty(exports,"__esModule",{value:!0}),exports.DevServerReloadPlugin=void 0,exports.devserverControlSock=devserverControlSock,exports.createDevWebpackConfig=createDevWebpackConfig;const tslib_1=require("tslib"),app_plugin_1=require("@nx/webpack/app-plugin"),fs=tslib_1.__importStar(require("node:fs")),os=tslib_1.__importStar(require("node:os")),path=tslib_1.__importStar(require("node:path")),webpack_node_externals_1=tslib_1.__importDefault(require("webpack-node-externals")),build_scope_patterns_1=require("./build-scope-patterns"),tsgo_check_plugin_1=require("./tsgo-check-plugin"),webpack=require("webpack");class DevServerReloadPlugin{static{d(this,"DevServerReloadPlugin")}static{u(this,"DevServerReloadPlugin")}static{this.RECENT_PATH_CAP=32}constructor(t,o=!1,r){this.testimony={schemaVersion:1,lastWatchRunAt:0,lastBuildAt:0,buildsSinceSpawn:0,degraded:!1,recentPaths:[]},this.secure=o,this.base=`${o?"https":"http"}://localhost:${t}`,this.controlSock=r}post(t,o){const r=JSON.stringify(o),s=u(()=>new Promise((a,c)=>{const l=(this.secure?require("node:https"):require("node:http")).request(`${this.base}${t}`,{method:"POST",headers:{"content-type":"application/json"},rejectUnauthorized:!1},p=>{p.resume(),p.on("end",a)});l.on("error",c),l.end(r)}),"viaTcp"),i=this.controlSock;return!i||!fs.existsSync(i)?s():new Promise((a,c)=>{const l=require("node:http").request({socketPath:i,path:t,method:"POST",headers:{"content-type":"application/json"}},p=>{p.resume(),p.on("end",a)});l.on("error",c),l.end(r)}).catch(s)}snapshot(){return{...this.testimony,recentPaths:[...this.testimony.recentPaths]}}stampWatchRun(t){if(this.testimony.lastWatchRunAt=Date.now(),t.length){const o=[...this.testimony.recentPaths,...t];this.testimony.recentPaths=o.slice(-DevServerReloadPlugin.RECENT_PATH_CAP)}}degrade(t){this.testimony.degraded=t}tellWatching(){this.post("/webpack/watching",this.testimony).catch(()=>{})}apply(t){const o="DevServerReloadPlugin";let r=!1;t.hooks.watchRun?.tap(o,s=>{const i=[...s?.modifiedFiles??[],...s?.removedFiles??[]].filter(a=>typeof a=="string");if(this.stampWatchRun(i),!r){const a=s?.watchFileSystem?.watcher;a&&typeof a.on=="function"&&(r=!0,a.on("error",c=>{this.degrade(`watcher error: ${c?.message??String(c)}`),this.tellWatching()}))}this.tellWatching()}),t.hooks.watchClose?.tap(o,()=>{this.degrade("watch closed"),this.tellWatching()}),t.hooks.failed?.tap(o,s=>{this.degrade(`compiler failed: ${s?.message??String(s)}`),this.tellWatching()}),t.hooks.done.tap(o,async s=>{try{if(s.hasErrors()){this.tellWatching();return}this.testimony.lastBuildAt=Date.now(),this.testimony.buildsSinceSpawn+=1,await this.post("/webpack/reload",{watcher:this.testimony}),console.log("[DevServerReloadPlugin] notified devserver to reload")}catch{console.log("[DevServerReloadPlugin] devserver not running yet")}})}}exports.DevServerReloadPlugin=DevServerReloadPlugin;function devserverControlSock(n,t=process.env){const o=t.LENSMCP_WS_KEY||"",r=o?path.join(os.tmpdir(),o,`${n}-devserver`):path.join(os.tmpdir(),`${n}-devserver`);return path.join(r,"parent.sock")}d(devserverControlSock,"devserverControlSock"),u(devserverControlSock,"devserverControlSock");function createDevWebpackConfig(n){const{appRoot:t,outputDir:o,main:r,tsConfig:s,workspaceRoot:i,assets:a=[],port:c,memoryLimit:l=8192,buildLibsFromSource:p=!0,orgScopes:b=[],bundlePackages:y=[],additionalEntryPoints:k=[],nodeExternalsConfig:m,webpackConfigPath:v}=n,{allowlistPatterns:C,scopePrefixes:E,scopePatterns:N}=(0,build_scope_patterns_1.buildScopePatterns)(b),P=process.env.LENSMCP_TYPECHECK,S=P==="tsgo"?(0,tsgo_check_plugin_1.resolveTsgoBin)(i,process.env.LENSMCP_TSGO_BIN):null;if(P==="tsgo"&&!S){const e=`${tsgo_check_plugin_1.TSGO_MISSING_CAUSE}. Fell back to fork-ts-checker, which reports to the console only \u2014 so typecheck:// has no producer for this project until it is fixed`;console.warn(`[serve] ${n.appName}: LENSMCP_TYPECHECK=tsgo but ${e}.`),(0,tsgo_check_plugin_1.reportTsgoUnavailable)({appName:n.appName,tsConfig:s,workspaceRoot:i,detail:e})}const f=!!S,_=y.map(e=>new RegExp(`^${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}(/|$)`)),W=[/webpack\/hot\/poll\?100/,...C,..._,...m?.allowlist||[]],w={output:{path:o,...process.env.NODE_ENV!=="production"&&{devtoolModuleFilenameTemplate:"[absolute-resource-path]"},clean:!0},externals:[(0,webpack_node_externals_1.default)({allowlist:W,additionalModuleDirs:m?.additionalModuleDirs||[],...m?.importType&&{importType:m.importType}}),({request:e},g)=>{if(e&&path.isAbsolute(e)&&e.startsWith(i)&&!e.includes("node_modules")||e&&(e.startsWith(path.join(i,"apps"))||e.startsWith(path.join(i,"libs")))||e&&E.some(h=>e.startsWith(h))||e&&N.some(h=>h.test(e))||e&&_.some(h=>h.test(e)))return g();if(e&&!(e.startsWith("./")||e.startsWith(".."))){const h=e.startsWith("@")?e.split("/").slice(0,2).join("/"):e.split("/")[0];return fs.existsSync(path.join(i,"node_modules",h))?g(null,`commonjs ${e}`):g()}return g()}],mode:"development",devtool:"eval-cheap-module-source-map",plugins:[new webpack.HotModuleReplacementPlugin,new app_plugin_1.NxAppWebpackPlugin({target:"node22",compiler:"tsc",main:r,additionalEntryPoints:k,verbose:!0,sourceMap:"eval-cheap-module-source-map",mergeExternals:!0,externalDependencies:[],memoryLimit:l,tsConfig:s,assets:a,optimization:!1,progress:!1,outputHashing:"none",generatePackageJson:!1,watchDependencies:!0,typeCheckOptions:process.env.LENSMCP_TYPECHECK==="off"||f?!1:{async:!0},buildLibsFromSource:p}),new DevServerReloadPlugin(c,n.httpsReload===!0,devserverControlSock(n.serviceName)),...f?[new tsgo_check_plugin_1.TsgoCheckPlugin({appName:n.appName,tsConfig:s,workspaceRoot:i,...process.env.LENSMCP_TSGO_BIN?{binPath:process.env.LENSMCP_TSGO_BIN}:{}})]:[]],snapshot:{managedPaths:[/^(.+?[\\/])?node_modules[\\/]/]},watch:!0,watchOptions:{ignored:["**/*.env.template","**/config.template.json","**/*.md","**/dist/**","**/migrations/**"]},cache:{type:"filesystem"}};if(v){const e=require(path.resolve(t,v));return(e.default||e)(w)}return w}d(createDevWebpackConfig,"createDevWebpackConfig"),u(createDevWebpackConfig,"createDevWebpackConfig");
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The bidirectional progress bound — SHARED by both gateways (lifted from `prod-runtime/body-stall.ts`;
3
+ * the dev proxy arms the same guard on its h1 and h2 paths, issues/dev-gateway/12).
4
+ *
5
+ * `requestTimeout` is deliberately 0 on this proxy — a hard request deadline would cut the
6
+ * long-lived streaming (SSE, streamable-HTTP MCP) it exists to carry. But `headersTimeout`
7
+ * only ever bounded HEADERS, and undici's `bodyTimeout` is 0 too, so nothing bounded a
8
+ * request BODY: a client could send headers, have the gateway dispatch upstream, and then
9
+ * dribble one byte a minute — holding one of the 256 per-origin pool connections for as long
10
+ * as it liked. 256 of those and a service is unreachable, from one unauthenticated client.
11
+ *
12
+ * The bound here is on STALL, not duration: a request is killed only when it has transferred
13
+ * NOTHING for the whole window. A slow-but-progressing upload is untouched, and a request
14
+ * whose body has already been fully received is never watched at all — so an indefinitely
15
+ * open SSE *response* is unaffected.
16
+ *
17
+ * Progress is read from `socket.bytesRead` rather than a `data` listener, because attaching
18
+ * one would put the request stream into flowing mode and steal the body from reply-from.
19
+ *
20
+ * The RESPONSE direction needs the same bound for the same reason. A client that requests a
21
+ * large body and then stops reading applies backpressure all the way up the pipe, and the
22
+ * undici pool connection serving it stays busy for as long as the client cares to wait — 256
23
+ * of those (the default per-origin pool) make a service unreachable for everyone else, from
24
+ * one unauthenticated client. `armResponse` watches for that, and it is a STALL bound too:
25
+ * a response is killed only while bytes are queued in the socket AND none have moved for the
26
+ * whole window, so a client that is draining slowly is untouched and an idle SSE stream (with
27
+ * nothing queued) is never even a candidate.
28
+ */
29
+ import type * as http from 'node:http';
30
+ export interface BodyStallGuard {
31
+ /** Watch a request that is still receiving a body. No-op for a body-less request. */
32
+ arm(req: http.IncomingMessage): void;
33
+ /** Watch a response for a client that has stopped draining it. */
34
+ armResponse(res: http.ServerResponse): void;
35
+ stop(): void;
36
+ }
37
+ export declare function createBodyStallGuard(stallMs: number): BodyStallGuard;
@@ -0,0 +1 @@
1
+ "use strict";var u=Object.defineProperty;var a=(r,s)=>u(r,"name",{value:s,configurable:!0});var y=Object.defineProperty,c=a((r,s)=>y(r,"name",{value:s,configurable:!0}),"c");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createBodyStallGuard=createBodyStallGuard;const OFF={arm:c(()=>{},"arm"),armResponse:c(()=>{},"armResponse"),stop:c(()=>{},"stop")};function createBodyStallGuard(r){if(!Number.isFinite(r)||r<=0)return OFF;const s=new Map,i=new Map,l=Math.max(500,Math.min(r,5e3)),d=setInterval(c(()=>{if(s.size===0&&i.size===0)return;const e=Date.now();for(const[t,n]of s){if(t.complete||t.destroyed||!t.socket||t.socket.destroyed){s.delete(t);continue}const o=t.socket.bytesRead;if(o!==n.bytes){n.bytes=o,n.since=e;continue}e-n.since>=r&&(s.delete(t),t.socket.destroy())}for(const[t,n]of i){const o=t.socket;if(t.writableEnded||t.destroyed||!o||o.destroyed){i.delete(t);continue}if(o.writableLength===0){n.bytes=o.bytesWritten,n.since=e;continue}if(o.bytesWritten!==n.bytes){n.bytes=o.bytesWritten,n.since=e;continue}e-n.since>=r&&(i.delete(t),o.destroy())}},"tick"),l);return d.unref?.(),{arm(e){const t=e.headers["content-length"];if(!(String(e.headers["transfer-encoding"]??"").toLowerCase().includes("chunked")||typeof t=="string"&&Number(t)>0)||e.complete||!e.socket)return;s.set(e,{bytes:e.socket.bytesRead,since:Date.now()});const n=c(()=>{s.delete(e)},"done");e.on("end",n),e.on("close",n),e.on("aborted",n)},armResponse(e){const t=e.socket;if(!t||e.writableEnded||e.destroyed)return;i.set(e,{bytes:t.bytesWritten,since:Date.now()});const n=c(()=>{i.delete(e)},"done");e.on("finish",n),e.on("close",n)},stop(){clearInterval(d),s.clear(),i.clear()}}}a(createBodyStallGuard,"createBodyStallGuard"),c(createBodyStallGuard,"createBodyStallGuard");
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The ONE request-path canonicalizer — shared by BOTH gateways.
3
+ *
4
+ * Lifted out of `prod-runtime/edge.ts` (prod-gateway-2 #01/#03/#04/#12) so the dev gateway can stop
5
+ * routing, authorising and forwarding the RAW `req.url` (issues/dev-gateway/11: `/./admin`, an
6
+ * absolute-form target and `/..;/admin` all skipped dev's path policy while prod had been fixed).
7
+ * `prod-runtime/edge.ts` and `runtime/edge.ts` re-export everything here, so each pipeline keeps its
8
+ * import surface and neither can drift from the other.
9
+ */
10
+ import type * as http from 'node:http';
11
+ export declare const ENCODED_STRUCTURE: RegExp;
12
+ /**
13
+ * The ONE canonical form of a request target. Everything downstream — mount matching, the auth
14
+ * override, the ABAC `path` attribute, the forwarded URL and the WebSocket request line — is
15
+ * derived from this, so the gateway can no longer authorise one string and forward another.
16
+ *
17
+ * Two representations, because they answer different questions:
18
+ * - `path` keeps the ORIGINAL percent-encoding, so what reaches the upstream is byte-equivalent
19
+ * to what the client asked for (decoding and re-encoding would turn `%3F` into a real `?`).
20
+ * - `policy` is the same path DECODED, so `/%61dmin` is judged as `/admin`.
21
+ */
22
+ export interface CanonicalTarget {
23
+ /** Canonical path, original encoding preserved — routed and forwarded. */
24
+ path: string;
25
+ /** Canonical path, decoded — matched against auth overrides and ABAC rules. */
26
+ policy: string;
27
+ /** The query string including `?`, exactly as received (never re-encoded), or ''. */
28
+ query: string;
29
+ }
30
+ /**
31
+ * Canonicalize a request target, or `undefined` if it is unsafe (→ 400 / drop).
32
+ *
33
+ * Three rejections and two collapses, each one a fixed bug:
34
+ * - an ABSOLUTE-form target (`GET http://host/admin`) is refused. Node leaves the scheme and
35
+ * authority in `req.url`, `find-my-way` strips them only for its own lookup, and
36
+ * `@fastify/reply-from` accepts an absolute source whose origin matches the route — so the
37
+ * segments the override was matched against were `['http:', 'host', 'admin']` and the
38
+ * upstream still got `/admin`.
39
+ * - matrix parameters and the fragment are stripped BEFORE any segment is judged, so
40
+ * `/..;/admin` cannot hide a traversal behind a parameter (it used to survive the `..`
41
+ * test and then have the `;x` trimmed off, yielding `/../admin`).
42
+ * - a `..` segment is rejected, never resolved.
43
+ * - empty (`//`) and single-dot (`/./`) segments collapse, because the upstream's own URL
44
+ * parsing collapses them and the policy matcher already ignored them — the gateway used to
45
+ * authorise `/./admin` and forward `/admin`.
46
+ * - a trailing slash is PRESERVED (RFC 3986 §5.2.4: `/a/.` resolves to `/a/`), since
47
+ * `/a` and `/a/` are different resources to many backends.
48
+ */
49
+ export declare const canonicalize: (rawUrl: string) => CanonicalTarget | undefined;
50
+ /**
51
+ * Where the request target AS RECEIVED is kept, when canonicalization changed it.
52
+ *
53
+ * The handler replaces `req.url` with the canonical form so routing, policy and the wire cannot
54
+ * diverge — but that form is also what the access log reads, and an operator investigating a
55
+ * probe needs to see what the client actually sent: `/./admin` and `/admin` are the same line in
56
+ * the log otherwise, and the attempt disappears. Set ONLY when the two differ, so ordinary
57
+ * traffic carries no extra work and no extra field.
58
+ */
59
+ export declare const RAW_TARGET: unique symbol;
60
+ /** The target as received, if canonicalization rewrote it. */
61
+ export declare const rawTargetOf: (req: http.IncomingMessage) => string | undefined;
62
+ /** Remember the target as received, for the access log. */
63
+ export declare const rememberRawTarget: (req: http.IncomingMessage, raw: string) => void;
64
+ /** The decoded canonical path for auth matching, or undefined if the path is unsafe. */
65
+ export declare const canonicalPath: (rawUrl: string) => string | undefined;
@@ -0,0 +1 @@
1
+ "use strict";var x=Object.defineProperty;var f=(e,t)=>x(e,"name",{value:t,configurable:!0});var u=Object.defineProperty,r=f((e,t)=>u(e,"name",{value:t,configurable:!0}),"r");Object.defineProperty(exports,"__esModule",{value:!0}),exports.canonicalPath=exports.rememberRawTarget=exports.rawTargetOf=exports.RAW_TARGET=exports.canonicalize=exports.ENCODED_STRUCTURE=void 0,exports.ENCODED_STRUCTURE=/%2e|%2f|%5c|%00|\\/i;const NEEDS_NORMALIZE=/[%;#\\]|\/\/|(?:^|\/)\.{1,2}(?:\/|$)/,canonicalize=r(e=>{if(e.charCodeAt(0)!==47)return;const t=e.indexOf("?"),a=t===-1?e:e.slice(0,t),s=t===-1?"":e.slice(t);if(a.indexOf("\0")!==-1)return;if(!NEEDS_NORMALIZE.test(a))return{path:a,policy:a,query:s};if(exports.ENCODED_STRUCTURE.test(a))return;const c=a.split("#")[0].split("/").map(n=>n.split(";")[0]),i=[],p=[];for(const n of c){let o;try{o=decodeURIComponent(n)}catch{return}if(o.indexOf("\0")!==-1)return;if(!(o===""||o===".")){if(o==="..")return;i.push(n),p.push(o)}}const l=c[c.length-1],T=i.length>0&&(l===""||l===".")?"/":"";return{path:"/"+i.join("/")+T,policy:"/"+p.join("/")+T,query:s}},"canonicalize");exports.canonicalize=canonicalize,exports.RAW_TARGET=Symbol.for("lensmcp.gateway.rawTarget");const rawTargetOf=r(e=>e[exports.RAW_TARGET],"rawTargetOf");exports.rawTargetOf=rawTargetOf;const rememberRawTarget=r((e,t)=>{e[exports.RAW_TARGET]=t},"rememberRawTarget");exports.rememberRawTarget=rememberRawTarget;const canonicalPath=r(e=>(0,exports.canonicalize)(e)?.policy,"canonicalPath");exports.canonicalPath=canonicalPath;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The ONE client-address resolver — shared by BOTH gateways.
3
+ *
4
+ * Lifted out of `prod-runtime/rollout.ts` (prod-gateway-2 #05 + #11) so the dev gateway resolves the
5
+ * caller the same way: the socket peer by default; a `clientIpHeader` only when the peer is a trusted
6
+ * front proxy; `X-Forwarded-For` counted from the RIGHT by a trusted hop count, never the leftmost entry
7
+ * (the one a client can always write itself). `forwardedFor` is its outbound twin: append to a trusted
8
+ * chain, REPLACE an untrusted one — so an upstream is never told a story about who is calling it.
9
+ *
10
+ * Dev used the raw socket peer for the ABAC `ip` attribute and passed the client's own `X-Forwarded-For`
11
+ * through (h2 path appended to it; h1 path forwarded it verbatim), so behind a front proxy (the Caddy the
12
+ * workspace once ran on :443) an `ip` rule saw the proxy and a client could prepend any chain it liked
13
+ * (issues/dev-gateway/11, the resolver half).
14
+ */
15
+ import type * as http from 'node:http';
16
+ export interface ClientIpOptions {
17
+ /** A front proxy's client-address header (e.g. `cf-connecting-ip`), trusted only from a trusted peer. */
18
+ clientIpHeader?: string;
19
+ /** CIDRs of peers allowed to assert `clientIpHeader`. Empty ⇒ loopback only (a co-located sidecar). */
20
+ clientIpTrustedProxies?: readonly string[];
21
+ /** How many proxy hops in front of us are trusted to have appended to `X-Forwarded-For`. 0 = none. */
22
+ trustProxyHops?: number;
23
+ }
24
+ export interface ClientIpResolver {
25
+ /** The authoritative client address for `req`, or `fallback` (normally the socket peer). */
26
+ clientIpOf(req: http.IncomingMessage, fallback: string): string;
27
+ /** The `X-Forwarded-For` to send upstream: trusted chain + peer, or just the peer. */
28
+ forwardedFor(req: http.IncomingMessage, peer: string): string;
29
+ /** Is this peer address a trusted front proxy? */
30
+ peerTrusted(peer?: string): boolean;
31
+ readonly trustProxyHops: number;
32
+ }
33
+ /** An IPv4-mapped IPv6 peer (`::ffff:127.0.0.1`, what a dual-stack listener reports) → its IPv4 form. */
34
+ export declare const normalizePeer: (addr: string) => string;
35
+ /** `1.2.3.4:51234` (an X-Forwarded-For port suffix) → `1.2.3.4`. IPv6 keeps its colons unless bracketed. */
36
+ export declare const stripPort: (v: string) => string;
37
+ export declare function createClientIpResolver(opts?: ClientIpOptions): ClientIpResolver;
38
+ /**
39
+ * The dev gateway's knobs (the prod gateway reads its `LENSMCP_GW_*` twins in `prod-config.ts`):
40
+ * - `LENSMCP_TRUST_PROXY` — trusted `X-Forwarded-For` hop count (default 0: the socket peer is the client).
41
+ * - `LENSMCP_CLIENT_IP_HEADER` — a front proxy's client-address header, honoured from trusted peers only.
42
+ * - `LENSMCP_CLIENT_IP_TRUSTED_PROXIES` — comma-separated CIDRs that may assert it (default: loopback).
43
+ */
44
+ export declare function clientIpOptionsFromEnv(env?: NodeJS.ProcessEnv): ClientIpOptions;
@@ -0,0 +1 @@
1
+ "use strict";var u=Object.defineProperty;var f=(e,r)=>u(e,"name",{value:r,configurable:!0});var a=Object.defineProperty,o=f((e,r)=>a(e,"name",{value:r,configurable:!0}),"o");Object.defineProperty(exports,"__esModule",{value:!0}),exports.stripPort=exports.normalizePeer=void 0,exports.createClientIpResolver=createClientIpResolver,exports.clientIpOptionsFromEnv=clientIpOptionsFromEnv;const manifest_1=require("./manifest"),first=o(e=>{const r=Array.isArray(e)?e[0]:e;return typeof r=="string"&&r.length>0?r:void 0},"first"),normalizePeer=o(e=>e.startsWith("::ffff:")&&e.indexOf(":",7)===-1?e.slice(7):e,"normalizePeer");exports.normalizePeer=normalizePeer;const stripPort=o(e=>{if(e.startsWith("[")){const p=e.indexOf("]");return p===-1?e:e.slice(1,p)}const r=e.indexOf(":");return r!==-1&&e.indexOf(":",r+1)===-1?e.slice(0,r):e},"stripPort");exports.stripPort=stripPort;function createClientIpResolver(e={}){const r=e.clientIpHeader?.toLowerCase(),p=e.clientIpTrustedProxies??[],l=Math.max(0,Math.floor(e.trustProxyHops??0)),c=o(t=>{if(!t)return!1;const s=t.startsWith("::ffff:")?t.slice(7):t;return p.length===0?s==="127.0.0.1"||t==="::1":p.some(i=>(0,manifest_1.matchCidr)(s,i))},"peerTrusted"),d=o(t=>String(t.headers["x-forwarded-for"]??"").split(",").map(s=>s.trim()).filter(Boolean),"forwardedChain");return{clientIpOf:o((t,s)=>{if(r&&c(t.socket?.remoteAddress??void 0)){const i=first(t.headers[r]);if(i){const n=(0,exports.stripPort)(i.split(",")[0].trim());if(n)return n}}if(l>0){const i=d(t),n=i.length-l;if(n>=0&&i[n])return(0,exports.stripPort)(i[n])}return(0,exports.normalizePeer)(s)},"clientIpOf"),forwardedFor:o((t,s)=>{const i=(0,exports.normalizePeer)(s);if(l>0||r&&c(s)){const n=d(t);return n.length?`${n.join(", ")}, ${i}`:i}return i},"forwardedFor"),peerTrusted:c,trustProxyHops:l}}f(createClientIpResolver,"createClientIpResolver"),o(createClientIpResolver,"createClientIpResolver");function clientIpOptionsFromEnv(e=process.env){const r=Number(e.LENSMCP_TRUST_PROXY),p=String(e.LENSMCP_CLIENT_IP_TRUSTED_PROXIES??"").split(",").map(l=>l.trim()).filter(Boolean);return{...Number.isFinite(r)&&r>0?{trustProxyHops:Math.floor(r)}:{},...e.LENSMCP_CLIENT_IP_HEADER?{clientIpHeader:e.LENSMCP_CLIENT_IP_HEADER}:{},...p.length?{clientIpTrustedProxies:p}:{}}}f(clientIpOptionsFromEnv,"clientIpOptionsFromEnv"),o(clientIpOptionsFromEnv,"clientIpOptionsFromEnv");
@@ -1 +1 @@
1
- "use strict";var J=Object.defineProperty;var h=(e,n)=>J(e,"name",{value:n,configurable:!0});var v=Object.defineProperty,c=h((e,n)=>v(e,"name",{value:n,configurable:!0}),"c");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createJwksVerifier=createJwksVerifier;const node_crypto_1=require("node:crypto"),ALG_TO_HASH={RS256:"sha256",RS384:"sha384",RS512:"sha512"},b64urlJsonSafe=c(e=>JSON.parse(Buffer.from(e,"base64url").toString("utf8")),"b64urlJsonSafe");function createJwksVerifier(e){const n=(e.algorithms??["RS256"]).filter(r=>r in ALG_TO_HASH),g=e.cacheMaxAgeMs??6e5,k=e.missRefreshMs??3e4,d=e.clockToleranceSec??60,S=e.fetchImpl??globalThis.fetch,p=e.audience===void 0?void 0:Array.isArray(e.audience)?e.audience:[e.audience];let o=new Map,f,w=0;const m=c(async()=>{const r=await S(e.jwksUrl,{headers:{accept:"application/json"}});if(!r.ok)throw new Error(`JWKS ${e.jwksUrl} \u2192 HTTP ${r.status}`);const a=await r.json(),s=new Map;for(const t of a.keys??[])if(!(t.kty!=="RSA"||!t.n||!t.e)&&!(t.use&&t.use!=="sig"))try{const u=(0,node_crypto_1.createPublicKey)({key:t,format:"jwk"});s.set(t.kid??"__single__",u)}catch{}s.size>0&&(o=s)},"load"),l=c(()=>(f||(f=m().finally(()=>{f=void 0})),f),"refresh"),A=c(r=>{r-w<k||(w=r,l().catch(()=>{}))},"refreshOnMiss"),_=setInterval(()=>{l().catch(()=>{})},g);return _.unref?.(),{verify:c(r=>{const a=r.split(".");if(a.length!==3)return;let s;try{s=b64urlJsonSafe(a[0])}catch{return}if(!s.alg||!n.includes(s.alg))return;const t=o.get(s.kid??"__single__")??(o.size===1?[...o.values()][0]:void 0);if(!t){A(Date.now());return}let u;try{u=(0,node_crypto_1.verify)(ALG_TO_HASH[s.alg],Buffer.from(`${a[0]}.${a[1]}`),t,Buffer.from(a[2],"base64url"))}catch{return}if(!u)return;let i;try{i=b64urlJsonSafe(a[1])}catch{return}const b=Date.now()/1e3;if(!(typeof i.exp=="number"&&b>i.exp+d)&&!(typeof i.nbf=="number"&&b<i.nbf-d)&&!(e.issuer&&i.iss!==e.issuer)){if(p){const y=i.aud;if(!(Array.isArray(y)?y:[y]).some(j=>p.includes(j)))return}return i}},"verify"),refresh:l,stop:c(()=>clearInterval(_),"stop")}}h(createJwksVerifier,"createJwksVerifier"),c(createJwksVerifier,"createJwksVerifier");
1
+ "use strict";var J=Object.defineProperty;var d=(e,n)=>J(e,"name",{value:n,configurable:!0});var v=Object.defineProperty,c=d((e,n)=>v(e,"name",{value:n,configurable:!0}),"c");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createJwksVerifier=createJwksVerifier;const node_crypto_1=require("node:crypto"),ALG_TO_HASH={RS256:"sha256",RS384:"sha384",RS512:"sha512"},b64urlJsonSafe=c(e=>JSON.parse(Buffer.from(e,"base64url").toString("utf8")),"b64urlJsonSafe");function createJwksVerifier(e){const n=(e.algorithms??["RS256"]).filter(r=>r in ALG_TO_HASH),_=e.cacheMaxAgeMs??6e5,b=e.missRefreshMs??3e4,h=e.clockToleranceSec??60,S=e.fetchImpl??globalThis.fetch,p=e.audience===void 0?void 0:Array.isArray(e.audience)?e.audience:[e.audience];let o=new Map,f,g=0;const m=c(async()=>{const r=await S(e.jwksUrl,{headers:{accept:"application/json"}});if(!r.ok)throw new Error(`JWKS ${e.jwksUrl} \u2192 HTTP ${r.status}`);const a=await r.json(),t=new Map;for(const s of a.keys??[])if(!(s.kty!=="RSA"||!s.n||!s.e)&&!(s.use&&s.use!=="sig"))try{const u=(0,node_crypto_1.createPublicKey)({key:s,format:"jwk"});t.set(s.kid??"__single__",u)}catch{}t.size>0&&(o=t)},"load"),l=c(()=>(f||(f=m().finally(()=>{f=void 0})),f),"refresh"),A=c(r=>{r-g<b||(g=r,l().catch(()=>{}))},"refreshOnMiss"),k=setInterval(()=>{l().catch(()=>{})},_);return k.unref?.(),{verify:c(r=>{const a=r.split(".");if(a.length!==3)return;let t;try{t=b64urlJsonSafe(a[0])}catch{return}if(!t.alg||!n.includes(t.alg))return;const s=t.kid!==void 0?o.get(t.kid):o.get("__single__")??(o.size===1?[...o.values()][0]:void 0);if(!s){A(Date.now());return}let u;try{u=(0,node_crypto_1.verify)(ALG_TO_HASH[t.alg],Buffer.from(`${a[0]}.${a[1]}`),s,Buffer.from(a[2],"base64url"))}catch{return}if(!u)return;let i;try{i=b64urlJsonSafe(a[1])}catch{return}const w=Date.now()/1e3;if(!(typeof i.exp=="number"&&w>i.exp+h)&&!(typeof i.nbf=="number"&&w<i.nbf-h)&&!(e.issuer&&i.iss!==e.issuer)){if(p){const y=i.aud;if(!(Array.isArray(y)?y:[y]).some(j=>p.includes(j)))return}return i}},"verify"),refresh:l,stop:c(()=>clearInterval(k),"stop")}}d(createJwksVerifier,"createJwksVerifier"),c(createJwksVerifier,"createJwksVerifier");
@@ -1,2 +1,2 @@
1
- "use strict";var Q=Object.defineProperty;var L=(e,o)=>Q(e,"name",{value:o,configurable:!0});var X=Object.defineProperty,a=L((e,o)=>X(e,"name",{value:o,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0});const tslib_1=require("tslib"),node_fs_1=require("node:fs"),node_crypto_1=require("node:crypto"),node_path_1=require("node:path"),node_os_1=require("node:os"),node_cluster_1=tslib_1.__importDefault(require("node:cluster")),prod_gateway_lib_1=require("./prod-gateway.lib"),jwks_verify_1=require("./jwks-verify"),otel_tracing_1=require("./otel-tracing"),health_check_1=require("./health-check"),rate_limit_1=require("./rate-limit"),metrics_1=require("./metrics"),registry_source_1=require("./registry-source"),providers_prod_1=require("./providers-prod");function readJson(e,o){if(!e)return o;try{return JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(n){return console.error(`[gateway] could not read ${e}:`,n.message),o}}L(readJson,"readJson"),a(readJson,"readJson");function isCohortShape(e){const o=Object.values(e)[0];return Array.isArray(o)&&o.length>0&&typeof o[0]=="object"&&o[0]!==null&&"urls"in o[0]}L(isCohortShape,"isCohortShape"),a(isCohortShape,"isCohortShape");function watchJsonFile(e,o,n=1e3){let E=-1;const c=setInterval(a(()=>{if(!(0,node_fs_1.existsSync)(e))return;let N;try{N=(0,node_fs_1.statSync)(e).mtimeMs}catch{return}if(N===E)return;let u;try{u=JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(l){console.error("[gateway] endpoints reload:",l.message);return}E=N,o(u)},"tick"),n);return c.unref?.(),()=>clearInterval(c)}L(watchJsonFile,"watchJsonFile"),a(watchJsonFile,"watchJsonFile");async function main(){const e=process.env,o=(e.LENSMCP_GW_PORTS??e.PORT??"").split(",").map(t=>Number(t.trim())).filter(Boolean);process.on("uncaughtException",t=>console.error("[gateway] uncaughtException:",t)),process.on("unhandledRejection",t=>console.error("[gateway] unhandledRejection:",t));const n=e.LENSMCP_GW_REDIS_URL;let E,c,N,u,l;if(n){const t=require("ioredis"),r=new t(n);l=r;const _=(0,registry_source_1.createRegistryProviders)((0,registry_source_1.redisRegistrySource)(r,e.LENSMCP_GW_REDIS_PREFIX?{prefix:e.LENSMCP_GW_REDIS_PREFIX}:{}),{onError:a(s=>console.error("[gateway] registry:",s instanceof Error?s.message:s),"onError")});await _.start(),E=_.manifests,c=_.pods,u=_,console.log(`[gateway] registry: Redis-backed, live (${E.list().length} service(s))`)}else{const t=e.LENSMCP_GW_MANIFESTS;t?E=(0,providers_prod_1.fileManifestProvider)(t,{onError:a(s=>console.error("[gateway] manifest:",s instanceof Error?s.message:s),"onError")}):(console.warn("[gateway] no LENSMCP_GW_MANIFESTS / LENSMCP_GW_REDIS_URL \u2014 starting with an EMPTY route table (all requests 404 until manifests arrive)."),E=(0,providers_prod_1.staticManifestProvider)([]));const r=e.LENSMCP_GW_ENDPOINTS,_=readJson(r,{});if(Object.keys(_).length){const s=isCohortShape(_);c=s?(0,providers_prod_1.rolloutPodProvider)(_):(0,providers_prod_1.endpointsPodProvider)(_),console.log(`[gateway] endpoints: ${s?"ROLLOUT (weighted cohorts)":"flat"} \u2014 ${Object.keys(_).length} service(s)`),r&&(N=watchJsonFile(r,S=>{try{c?.update?.(S),console.log("[gateway] endpoints reloaded")}catch(P){console.error("[gateway] endpoints reload failed:",P.message)}}))}}const J=readJson(e.LENSMCP_GW_KEYS,{}),w=e.LENSMCP_GW_TLS_KEY&&e.LENSMCP_GW_TLS_CERT?{key:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_KEY),cert:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_CERT)}:void 0,j=e.LENSMCP_GW_JWT_PRESENCE_ONLY==="1",O=e.LENSMCP_GW_JWKS_URL,I=e.LENSMCP_GW_JWT_SECRET,M=O?(0,jwks_verify_1.createJwksVerifier)({jwksUrl:O,...e.LENSMCP_GW_JWT_ISS?{issuer:e.LENSMCP_GW_JWT_ISS}:{},...e.LENSMCP_GW_JWT_AUD?{audience:e.LENSMCP_GW_JWT_AUD.split(",").map(t=>t.trim())}:{}}):void 0,x=a(t=>{if(!I)return;const r=t.split(".");if(r.length===3)try{const _=(0,node_crypto_1.createHmac)("sha256",I).update(r[0]+"."+r[1]).digest("base64url"),s=Buffer.from(r[2]),S=Buffer.from(_);if(s.length!==S.length||!(0,node_crypto_1.timingSafeEqual)(s,S))return;const P=JSON.parse(Buffer.from(r[1],"base64url").toString());return typeof P.exp=="number"&&Date.now()/1e3>P.exp?void 0:P}catch{return}},"hs256"),A=a(t=>{const r=String(t.headers.authorization??"");if(!r.startsWith("Bearer "))return;const _=r.slice(7);return M?M.verify(_):x(_)},"bearerClaims"),b=M||I?A:void 0,B=a((t,r)=>{if(t==="jwt"){if(M||I){if(!A(r))throw new Error("invalid or missing token");return}if(!j)throw new Error("jwt route but no verifier configured (set LENSMCP_GW_JWKS_URL or LENSMCP_GW_JWT_SECRET)");if(!String(r.headers.authorization??"").startsWith("Bearer "))throw new Error("missing bearer token")}},"authenticate"),v=(e.LENSMCP_GW_ATTR_HEADERS??"").split(",").map(t=>t.trim()).filter(Boolean),D=e.LENSMCP_GW_TRUST_PROXY==="1",U=e.LENSMCP_GW_UID_HEADER,k=e.LENSMCP_GW_COOKIE_SECRET||e.LENSMCP_GW_COOKIE_NAME||e.LENSMCP_GW_COOKIE_DOMAIN?{...e.LENSMCP_GW_COOKIE_NAME?{name:e.LENSMCP_GW_COOKIE_NAME}:{},...e.LENSMCP_GW_COOKIE_SECRET?{secret:e.LENSMCP_GW_COOKIE_SECRET}:{},...e.LENSMCP_GW_COOKIE_DOMAIN?{domain:e.LENSMCP_GW_COOKIE_DOMAIN}:{},...e.LENSMCP_GW_COOKIE_MAXAGE?{maxAge:Number(e.LENSMCP_GW_COOKIE_MAXAGE)}:{}}:void 0,g=e.LENSMCP_EVENT_FILE;let H=!1;const m=g?t=>{try{H||((0,node_fs_1.mkdirSync)((0,node_path_1.dirname)(g),{recursive:!0}),H=!0),(0,node_fs_1.appendFileSync)(g,JSON.stringify(t)+`
2
- `)}catch{}}:void 0;if(M)try{await M.refresh(),console.log("[gateway] JWKS loaded")}catch(t){console.error("[gateway] initial JWKS load failed (will retry in background):",t.message)}const V=e.LENSMCP_GW_ACCESS_LOG==="1",$=e.LENSMCP_GW_CLIENT_IP_HEADER,F=(e.LENSMCP_GW_CLIENT_IP_TRUSTED_PROXIES??"").split(",").map(t=>t.trim()).filter(Boolean),i={};if(e.LENSMCP_GW_UPSTREAM_TLS_VERIFY==="1"&&(i.rejectUnauthorized=!0),e.LENSMCP_GW_UPSTREAM_H2==="1"&&(i.allowH2=!0),e.LENSMCP_GW_UNDICI_CONNECTIONS&&(i.connections=Number(e.LENSMCP_GW_UNDICI_CONNECTIONS)),e.LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS&&(i.bodyTimeout=Number(e.LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS)),e.LENSMCP_GW_UNDICI_PIPELINING&&(i.pipelining=Number(e.LENSMCP_GW_UNDICI_PIPELINING)),e.LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS&&(i.headersTimeout=Number(e.LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS)),e.LENSMCP_GW_UNDICI_KEEPALIVE_MS&&(i.keepAliveTimeout=Number(e.LENSMCP_GW_UNDICI_KEEPALIVE_MS)),e.LENSMCP_GW_MTLS_FROM_REDIS==="1"&&l){const t=e.LENSMCP_GW_REDIS_PREFIX??"lensmcp:gw",[r,_,s]=await l.mget(`${t}:mtls:cert`,`${t}:mtls:key`,`${t}:mtls:ca`);r&&(i.cert=r),_&&(i.key=_),s&&(i.ca=s),(r||_)&&console.log("[gateway] mTLS materials loaded from Redis")}else e.LENSMCP_GW_MTLS_CERT&&(i.cert=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CERT)),e.LENSMCP_GW_MTLS_KEY&&(i.key=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_KEY)),e.LENSMCP_GW_MTLS_CA&&(i.ca=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CA));i.cert&&i.key&&(e.LENSMCP_GW_UPSTREAM_H2!=="0"&&(i.allowH2=!0),console.log(`[gateway] mTLS to upstreams ON over ${i.allowH2?"h2":"h1.1"} (server validation ${i.rejectUnauthorized?"enabled":"OFF"})`));const d={};e.LENSMCP_GW_KEEPALIVE_MS&&(d.keepAliveTimeout=Number(e.LENSMCP_GW_KEEPALIVE_MS)),e.LENSMCP_GW_REQUEST_TIMEOUT_MS&&(d.requestTimeout=Number(e.LENSMCP_GW_REQUEST_TIMEOUT_MS));const W=(0,otel_tracing_1.setupTracing)({...e.OTEL_SERVICE_NAME?{serviceName:e.OTEL_SERVICE_NAME}:{},...e.LENSMCP_GW_OTEL_SAMPLE?{sampleRatio:Number(e.LENSMCP_GW_OTEL_SAMPLE)}:{}});W&&console.log(`[gateway] OTLP tracing on \u2192 ${e.OTEL_EXPORTER_OTLP_ENDPOINT}`);const G=e.LENSMCP_GW_STATUS==="1",C=e.LENSMCP_GW_STATUS_TOKEN,K=(0,node_os_1.hostname)(),Y=Math.max(3,Math.ceil(Number(e.LENSMCP_GW_TRAFFIC_FLUSH_MS??5e3)/1e3*3)),q=l,R=G&&q?t=>{for(const r of t)q.set(`status:${r.service}:${K}`,JSON.stringify({...r,instance:K,at:Date.now()}),"EX",Y).catch(()=>{})}:void 0;G&&!C&&console.warn("[gateway] LENSMCP_GW_STATUS=1 but no LENSMCP_GW_STATUS_TOKEN \u2014 /statusz stays DISABLED (404) until a token is set (fail-closed). The Redis status:* fan-out still runs."),G&&console.log(`[gateway] status on \u2014 /statusz${C?"":" (endpoint disabled: no token)"}${R?" + Redis status:* fan-out":""}`);const f=e.LENSMCP_GW_HEALTH_CHECK==="1"?(0,health_check_1.createHealthChecker)({...e.LENSMCP_GW_HEALTH_PATH?{path:e.LENSMCP_GW_HEALTH_PATH}:{},...e.LENSMCP_GW_HEALTH_INTERVAL_MS?{intervalMs:Number(e.LENSMCP_GW_HEALTH_INTERVAL_MS)}:{},...e.LENSMCP_GW_HEALTH_TIMEOUT_MS?{timeoutMs:Number(e.LENSMCP_GW_HEALTH_TIMEOUT_MS)}:{}}):void 0;f&&console.log(`[gateway] active health-checking on (path ${e.LENSMCP_GW_HEALTH_PATH??"/healthz"})`);let T,y;if(e.LENSMCP_GW_RATE_LIMIT){const t=Number(e.LENSMCP_GW_RATE_LIMIT),r=Number(e.LENSMCP_GW_RATE_WINDOW_MS??6e4);if(e.LENSMCP_GW_RATE_LIMIT_REDIS==="1"||e.LENSMCP_GW_RATE_LIMIT_REDIS_URL){const _=e.LENSMCP_GW_RATE_LIMIT_REDIS_URL;let s;if(_){const S=require("ioredis");y=new S(_),s=y}else l&&(s=l);if(s){const S=e.LENSMCP_GW_RATE_LIMIT_FAIL_CLOSED==="1";T=(0,rate_limit_1.createRedisRateLimiter)(s,{limit:t,windowMs:r,...e.LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS?{timeoutMs:Number(e.LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS)}:{},...S?{failOpen:!1}:{}}),console.log(`[gateway] rate limit on \u2014 ${t} req / ${r}ms GLOBAL (Redis: ${_?"dedicated":"shared registry"}, fail-${S?"closed":"open"}) per client IP`)}else console.warn("[gateway] LENSMCP_GW_RATE_LIMIT_REDIS set but no Redis configured (set LENSMCP_GW_REDIS_URL or LENSMCP_GW_RATE_LIMIT_REDIS_URL) \u2014 falling back to PER-INSTANCE in-memory limiting.")}T||(T=(0,rate_limit_1.createRateLimiter)({limit:t,windowMs:r}),console.log(`[gateway] rate limit on \u2014 ${t} req / ${r}ms per client IP (in-memory, per-instance)`))}const p=e.LENSMCP_GW_METRICS==="1"?(0,metrics_1.createProcessMetrics)():void 0;p&&console.log(`[gateway] process metrics on \u2014 /metricsz${C?" (token-gated)":" (DISABLED: needs LENSMCP_GW_STATUS_TOKEN)"}`);const h=await(0,prod_gateway_lib_1.startProdGateway)({...o.length?{ports:o}:{},manifests:E,...c?{pods:c}:{},serviceKeys:J,authenticate:B,...w?{tls:w}:{},...m?{emit:m}:{},...b?{identify:b}:{},...v.length?{attributeHeaders:v}:{},...D?{trustProxyIp:D}:{},...k?{cookie:k}:{},...U?{uidHeader:U}:{},...V?{accessLog:!0}:{},...$?{clientIpHeader:$}:{},...F.length?{clientIpTrustedProxies:F}:{},...W?{tracing:W}:{},...G?{statusEndpoint:!0}:{},...C?{statusToken:C}:{},...R?{onStatus:R}:{},...f?{healthChecker:f}:{},...T?{rateLimiter:T}:{},...p?{metrics:p}:{},...e.LENSMCP_GW_TRAFFIC_FLUSH_MS?{trafficFlushMs:Number(e.LENSMCP_GW_TRAFFIC_FLUSH_MS)}:{},...Object.keys(i).length?{undici:i}:{},...Object.keys(d).length?{server:d}:{}});console.log(`[gateway] production gateway up on ${h.ports.join(", ")} (${h.routes().length} routes${m?", lens ON":""})`);const z=a(async t=>{console.log(`[gateway] ${t} \u2014 draining\u2026`);try{E.stop?.()}catch{}try{N?.()}catch{}try{u?.stop()}catch{}try{await y?.quit()}catch{}try{M?.stop()}catch{}await h.stop();try{await W?.shutdown()}catch{}process.exit(0)},"shutdown");for(const t of["SIGTERM","SIGINT"])process.on(t,()=>{z(t)})}L(main,"main"),a(main,"main");function workerCount(){const e=(process.env.LENSMCP_GW_WORKERS??"1").trim().toLowerCase();if(e==="auto"||e==="max"||e==="0")return Math.max(1,(0,node_os_1.cpus)().length);const o=Math.floor(Number(e));return Number.isFinite(o)&&o>0?o:1}L(workerCount,"workerCount"),a(workerCount,"workerCount");const WORKERS=workerCount();if(WORKERS>1&&node_cluster_1.default.isPrimary){console.log(`[gateway] primary ${process.pid}: forking ${WORKERS} workers (one per core)\u2026`),process.env.LENSMCP_GW_RATE_LIMIT&&!(process.env.LENSMCP_GW_RATE_LIMIT_REDIS==="1"||process.env.LENSMCP_GW_RATE_LIMIT_REDIS_URL)&&console.warn(`[gateway] WARNING: in-memory rate limiter + ${WORKERS} workers \u2192 the limit is enforced PER WORKER (effective \u2248 ${WORKERS}\xD7 ${process.env.LENSMCP_GW_RATE_LIMIT}/window). Set LENSMCP_GW_RATE_LIMIT_REDIS=1 for one shared global budget, or divide LENSMCP_GW_RATE_LIMIT by ${WORKERS}.`);let e=!1;for(let n=0;n<WORKERS;n++)node_cluster_1.default.fork();node_cluster_1.default.on("exit",(n,E,c)=>{e||(console.warn(`[gateway] worker ${n.process.pid} exited (code=${E} sig=${c??""}) \u2014 respawning.`),node_cluster_1.default.fork())});const o=a(n=>{if(!e){e=!0,console.log(`[gateway] primary: ${n} \u2014 stopping ${Object.keys(node_cluster_1.default.workers??{}).length} workers\u2026`);for(const E of Object.values(node_cluster_1.default.workers??{}))try{E?.kill(n)}catch{}setTimeout(()=>process.exit(0),12e3).unref()}},"stopAll");process.on("SIGTERM",()=>o("SIGTERM")),process.on("SIGINT",()=>o("SIGINT"))}else main();
1
+ "use strict";var ce=Object.defineProperty;var T=(e,r)=>ce(e,"name",{value:r,configurable:!0});var ie=Object.defineProperty,a=T((e,r)=>ie(e,"name",{value:r,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0});const tslib_1=require("tslib"),node_fs_1=require("node:fs"),node_crypto_1=require("node:crypto"),node_path_1=require("node:path"),node_os_1=require("node:os"),node_cluster_1=tslib_1.__importDefault(require("node:cluster")),prod_gateway_lib_1=require("./prod-gateway.lib"),jwks_verify_1=require("./jwks-verify"),otel_tracing_1=require("./otel-tracing"),health_check_1=require("./health-check"),rate_limit_1=require("./rate-limit"),metrics_1=require("./metrics"),edge_1=require("./prod-runtime/edge"),registry_source_1=require("./registry-source"),providers_prod_1=require("./providers-prod"),prod_config_1=require("./prod-config");function readJson(e,r){if(!e)return r;try{return JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(n){return console.error(`[gateway] could not read ${e}:`,n.message),r}}T(readJson,"readJson"),a(readJson,"readJson");function watchJsonFile(e,r,n=1e3){let u=-1;const E=setInterval(a(()=>{if(!(0,node_fs_1.existsSync)(e))return;let p;try{p=(0,node_fs_1.statSync)(e).mtimeMs}catch{return}if(p===u)return;let y;try{y=JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(_){console.error("[gateway] endpoints reload:",_.message);return}u=p,r(y)},"tick"),n);return E.unref?.(),()=>clearInterval(E)}T(watchJsonFile,"watchJsonFile"),a(watchJsonFile,"watchJsonFile");async function main(){const e=process.env,r=(0,prod_config_1.parseGatewayEnv)(e),n=r.ports;process.on("uncaughtException",o=>console.error("[gateway] uncaughtException:",o)),process.on("unhandledRejection",o=>console.error("[gateway] unhandledRejection:",o));const u=[];let E=!1;const p=a(async o=>{if(!E){E=!0,console.log(`[gateway] ${o} \u2014 draining\u2026`);for(const t of u)try{await t()}catch{}process.exit(0)}},"shutdown");for(const o of["SIGTERM","SIGINT"])process.on(o,()=>{p(o)});const y=e.LENSMCP_GW_REDIS_URL;let _,w,b,$;const O={};let f;if(y){const o=require("ioredis"),t=new o(y);f=t;const s=(0,registry_source_1.createRegistryProviders)((0,registry_source_1.redisRegistrySource)(t,e.LENSMCP_GW_REDIS_PREFIX?{prefix:e.LENSMCP_GW_REDIS_PREFIX}:{}),{onError:a(i=>console.error("[gateway] registry:",i instanceof Error?i.message:i),"onError")});await s.start(),_=s.manifests,w=s.pods,$=s,console.log(`[gateway] registry: Redis-backed, live (${_.list().length} service(s))`)}else{const o=e.LENSMCP_GW_MANIFESTS;o?_=(0,providers_prod_1.fileManifestProvider)(o,{onError:a(s=>console.error("[gateway] manifest:",s instanceof Error?s.message:s),"onError")}):(console.warn("[gateway] no LENSMCP_GW_MANIFESTS / LENSMCP_GW_REDIS_URL \u2014 starting with an EMPTY route table (all requests 404 until manifests arrive)."),_=(0,providers_prod_1.staticManifestProvider)([]));const t=e.LENSMCP_GW_ENDPOINTS;if(t){const s=a(S=>console.error("[gateway] endpoints:",S instanceof Error?S.message:S),"onEpError"),i=readJson(t,{}),d=Object.keys(i).length,l=(0,providers_prod_1.isRolloutShape)(i);w=l?(0,providers_prod_1.rolloutPodProvider)(i,{onError:s}):(0,providers_prod_1.endpointsPodProvider)(i,{onError:s}),console.log(d?`[gateway] endpoints: ${l?"ROLLOUT (weighted cohorts)":"flat"} \u2014 ${d} service(s)`:`[gateway] endpoints: ${(0,node_fs_1.existsSync)(t)?"present but empty/unreadable":"file not there yet"} \u2014 watching ${t}`),b=watchJsonFile(t,S=>{try{w?.update?.(S),O.current?.refreshUpstreams(),console.log("[gateway] endpoints reloaded")}catch(ae){console.error("[gateway] endpoints reload failed:",ae.message)}})}}const Z=readJson(e.LENSMCP_GW_KEYS,{}),A=e.LENSMCP_GW_TLS_KEY&&e.LENSMCP_GW_TLS_CERT?{key:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_KEY),cert:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_CERT)}:void 0,ee=r.jwt.presenceOnly,F=r.jwt.jwksUrl,M=r.jwt.secret,g=F?(0,jwks_verify_1.createJwksVerifier)({jwksUrl:F,...r.jwt.issuer?{issuer:r.jwt.issuer}:{},...r.jwt.audience?{audience:r.jwt.audience}:{}}):void 0,te=a(o=>{if(!M)return;const t=o.split(".");if(t.length===3)try{const s=(0,node_crypto_1.createHmac)("sha256",M).update(t[0]+"."+t[1]).digest("base64url"),i=Buffer.from(t[2]),d=Buffer.from(s);if(i.length!==d.length||!(0,node_crypto_1.timingSafeEqual)(i,d))return;const l=JSON.parse(Buffer.from(t[1],"base64url").toString());return typeof l.exp=="number"&&Date.now()/1e3>l.exp?void 0:l}catch{return}},"hs256"),j=a(o=>{const t=String(o.headers.authorization??"");if(!t.startsWith("Bearer "))return;const s=t.slice(7);return g?g.verify(s):te(s)},"bearerClaims"),q=g||M?j:void 0,re=a((o,t)=>{if(o==="jwt"){if(g||M){if(!j(t))throw new Error("invalid or missing token");return}if(!ee)throw new Error("jwt route but no verifier configured (set LENSMCP_GW_JWKS_URL or LENSMCP_GW_JWT_SECRET)");if(!String(t.headers.authorization??"").startsWith("Bearer "))throw new Error("missing bearer token")}},"authenticate"),{attributeHeaders:D,trustProxyHops:oe,trustProxyIp:x,uidHeader:U,cookie:J}=r,m=e.LENSMCP_EVENT_FILE,K=m?(()=>{try{return require("@lensmcp/node-instrumentation")}catch{return}})():void 0,H=K?.createEventFloodGuard({burst:30,perSec:5});let z=!1;const I=m?o=>{try{const t=H?H.admit(String(o.fingerprint??"")):{ok:!0,suppressed:0};if(!t.ok)return;z||((0,node_fs_1.mkdirSync)((0,node_path_1.dirname)(m),{recursive:!0}),z=!0);const s=o.raw,i=JSON.stringify(t.suppressed>0?{...o,raw:{...s,floodSuppressed:t.suppressed}}:o)+`
2
+ `;K?.maybeRotateEventFile(m,i.length),(0,node_fs_1.appendFileSync)(m,i)}catch{}}:void 0;if(g)try{await g.refresh(),console.log("[gateway] JWKS loaded")}catch(o){console.error("[gateway] initial JWKS load failed (will retry in background):",o.message)}const{accessLog:se,clientIpHeader:B,clientIpTrustedProxies:Y,undici:c}=r;if(r.mtlsFromRedis&&f){const o=e.LENSMCP_GW_REDIS_PREFIX??"lensmcp:gw",[t,s,i]=await f.mget(`${o}:mtls:cert`,`${o}:mtls:key`,`${o}:mtls:ca`);t&&(c.cert=t),s&&(c.key=s),i&&(c.ca=i),(t||s)&&console.log("[gateway] mTLS materials loaded from Redis")}else e.LENSMCP_GW_MTLS_CERT&&(c.cert=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CERT)),e.LENSMCP_GW_MTLS_KEY&&(c.key=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_KEY)),e.LENSMCP_GW_MTLS_CA&&(c.ca=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CA));(0,prod_config_1.finalizeUpstreamTls)(c).autoEnabled&&console.log("[gateway] upstream TLS verification ENABLED (a CA is configured; set LENSMCP_GW_UPSTREAM_TLS_VERIFY=0 to opt out)"),(0,prod_config_1.finalizeUpstreamH2)(c,e)&&console.log(`[gateway] mTLS to upstreams ON over ${c.allowH2?"h2":"h1.1"} (server validation ${c.rejectUnauthorized?"enabled":"OFF"})`);const{upstreamRetries:v,server:V}=r,P=(0,otel_tracing_1.setupTracing)({...e.OTEL_SERVICE_NAME?{serviceName:e.OTEL_SERVICE_NAME}:{},...e.LENSMCP_GW_OTEL_SAMPLE?{sampleRatio:Number(e.LENSMCP_GW_OTEL_SAMPLE)}:{}});P&&console.log(`[gateway] OTLP tracing on \u2192 ${(0,edge_1.safeUrl)(e.OTEL_EXPORTER_OTLP_ENDPOINT)}`);const{statusEnabled:R,statusToken:h}=r,X=(0,node_os_1.hostname)(),ne=r.statusTtlSec,Q=f,C=R&&Q?o=>{for(const t of o)Q.set(`status:${t.service}:${X}`,JSON.stringify({...t,instance:X,at:Date.now()}),"EX",ne).catch(()=>{})}:void 0;R&&!h&&console.warn("[gateway] LENSMCP_GW_STATUS=1 but no LENSMCP_GW_STATUS_TOKEN \u2014 /statusz stays DISABLED (404) until a token is set (fail-closed). The Redis status:* fan-out still runs."),R&&console.log(`[gateway] status on \u2014 /statusz${h?"":" (endpoint disabled: no token)"}${C?" + Redis status:* fan-out":""}`);const G=r.health?(0,health_check_1.createHealthChecker)(r.health):void 0;G&&console.log(`[gateway] active health-checking on (path ${r.health?.path??"/healthz"})`);let L,k;if(r.rateLimit){const{limit:o,windowMs:t,wantRedis:s,failClosed:i}=r.rateLimit;if(s){const d=r.rateLimit.dedicatedUrl;let l;if(d){const S=require("ioredis");k=new S(d),l=k}else f&&(l=f);l?(L=(0,rate_limit_1.createRedisRateLimiter)(l,{limit:o,windowMs:t,...r.rateLimit.timeoutMs!==void 0?{timeoutMs:r.rateLimit.timeoutMs}:{},...i?{failOpen:!1}:{}}),console.log(`[gateway] rate limit on \u2014 ${o} req / ${t}ms GLOBAL (Redis: ${d?"dedicated":"shared registry"}, fail-${i?"closed":"open"}) per client IP`)):console.warn("[gateway] LENSMCP_GW_RATE_LIMIT_REDIS set but no Redis configured (set LENSMCP_GW_REDIS_URL or LENSMCP_GW_RATE_LIMIT_REDIS_URL) \u2014 falling back to PER-INSTANCE in-memory limiting.")}L||(L=(0,rate_limit_1.createRateLimiter)({limit:o,windowMs:t}),console.log(`[gateway] rate limit on \u2014 ${o} req / ${t}ms per client IP (in-memory, per-instance)`))}const W=r.metricsEnabled?(0,metrics_1.createProcessMetrics)():void 0;W&&console.log(`[gateway] process metrics on \u2014 /metricsz${h?" (token-gated)":" (DISABLED: needs LENSMCP_GW_STATUS_TOKEN)"}`);const N=await(0,prod_gateway_lib_1.startProdGateway)({...n.length?{ports:n}:{},manifests:_,...w?{pods:w}:{},serviceKeys:Z,authenticate:re,...A?{tls:A}:{},...I?{emit:I}:{},...q?{identify:q}:{},...D.length?{attributeHeaders:D}:{},...x?{trustProxyIp:x,trustProxyHops:oe}:{},...J?{cookie:J}:{},...U?{uidHeader:U}:{},...se?{accessLog:!0}:{},...B?{clientIpHeader:B}:{},...Y.length?{clientIpTrustedProxies:Y}:{},...P?{tracing:P}:{},...R?{statusEndpoint:!0}:{},...h?{statusToken:h}:{},...C?{onStatus:C}:{},...G?{healthChecker:G}:{},...L?{rateLimiter:L}:{},...W?{metrics:W}:{},...r.trafficFlushMs!==void 0?{trafficFlushMs:r.trafficFlushMs}:{},...v!==void 0&&Number.isFinite(v)?{upstreamRetries:v}:{},...Object.keys(c).length?{undici:c}:{},...Object.keys(V).length?{server:V}:{}});O.current=N,console.log(`[gateway] production gateway up on ${N.ports.join(", ")} (${N.routes().length} routes${I?", lens ON":""})`),u.push(()=>_.stop?.(),()=>b?.(),()=>$?.stop(),()=>k?.quit(),()=>g?.stop(),()=>N.stop(),()=>P?.shutdown())}T(main,"main"),a(main,"main");const WORKERS=(0,prod_config_1.workerCount)(process.env);if(WORKERS>1&&node_cluster_1.default.isPrimary){console.log(`[gateway] primary ${process.pid}: forking ${WORKERS} workers (one per core)\u2026`),process.env.LENSMCP_GW_RATE_LIMIT&&!(process.env.LENSMCP_GW_RATE_LIMIT_REDIS==="1"||process.env.LENSMCP_GW_RATE_LIMIT_REDIS_URL)&&console.warn(`[gateway] WARNING: in-memory rate limiter + ${WORKERS} workers \u2192 the limit is enforced PER WORKER (effective \u2248 ${WORKERS}\xD7 ${process.env.LENSMCP_GW_RATE_LIMIT}/window). Set LENSMCP_GW_RATE_LIMIT_REDIS=1 for one shared global budget, or divide LENSMCP_GW_RATE_LIMIT by ${WORKERS}.`);let e=!1;for(let n=0;n<WORKERS;n++)node_cluster_1.default.fork();node_cluster_1.default.on("exit",(n,u,E)=>{e||(console.warn(`[gateway] worker ${n.process.pid} exited (code=${u} sig=${E??""}) \u2014 respawning.`),node_cluster_1.default.fork())});const r=a(n=>{if(!e){e=!0,console.log(`[gateway] primary: ${n} \u2014 stopping ${Object.keys(node_cluster_1.default.workers??{}).length} workers\u2026`);for(const u of Object.values(node_cluster_1.default.workers??{}))try{u?.kill(n)}catch{}setTimeout(()=>process.exit(0),12e3).unref()}},"stopAll");process.on("SIGTERM",()=>r("SIGTERM")),process.on("SIGINT",()=>r("SIGINT"))}else main().catch(e=>{console.error("[gateway] FATAL: could not start \u2014",e instanceof Error?e.stack??e.message:e),process.exit(1)});
@@ -116,10 +116,15 @@ export interface PodProvider {
116
116
  /** Pick an upstream for this service. `opts.version` pins a rollout cohort
117
117
  * (sticky canary / debugging); otherwise traffic is split by cohort weight.
118
118
  * `opts.isHealthy` (when given) steers a pooled pick toward healthy endpoints
119
- * — fail-open: if a whole pool looks down it still returns one. */
119
+ * — fail-open: if a whole pool looks down it still returns one.
120
+ * `opts.allowDrained` admits a `weight: 0` cohort as a pin target. The GATEWAY sets it when
121
+ * the version came from its own targeting (an ABAC rule aimed at a canary that takes no
122
+ * organic traffic — the point of a targeted rollout); a version named by the CLIENT never
123
+ * gets it, so a rolled-back build cannot be reached by sending its version string. */
120
124
  pick(service: string, opts?: {
121
125
  version?: string;
122
126
  isHealthy?: (url: string) => boolean;
127
+ allowDrained?: boolean;
123
128
  }): Upstream | undefined;
124
129
  /** All upstream URLs this provider could route to — for active health-checking. */
125
130
  endpoints?(): string[];
@@ -165,7 +170,15 @@ export interface Route {
165
170
  /** Resolved auth for the route root (overrides refine per-path at match). */
166
171
  auth: AuthMode;
167
172
  }
168
- /** Exact hostname or `*.suffix` wildcard (the bare suffix also matches). */
173
+ /**
174
+ * Exact hostname or `*.suffix` wildcard (the bare suffix also matches).
175
+ *
176
+ * Case-INSENSITIVE, because DNS is: `APP.X` and `app.x` name the same host to every other layer,
177
+ * and a byte-exact compare here meant an upper-cased `Host` missed its own route and fell through
178
+ * to whatever claimed the rest of the request — usually the host-less catch-all, under ITS auth
179
+ * policy. Where that catch-all and the named host share an upstream, that was a way to reach a
180
+ * guarded service unauthenticated. The path matcher folds for the same reason.
181
+ */
169
182
  export declare function hostMatches(pattern: string, host: string): boolean;
170
183
  /**
171
184
  * THE matcher — shared by dev and prod. Generic over the route shape so the
@@ -187,7 +200,36 @@ export declare function matchRoute<R extends {
187
200
  prefix?: string;
188
201
  apex?: string;
189
202
  }>(routes: readonly R[], host: string, url: string): R | undefined;
190
- /** Specificity sort: host+path beats host-only beats catch-all. Mutates+returns. */
203
+ /**
204
+ * Is `url` at or under the mount `prefix`? Segment-anchored and case-insensitive.
205
+ *
206
+ * Both properties are fixed bugs. A bare `startsWith` let `/adminx` fall into the `/admin`
207
+ * mount (`handler.ts` already enforces a segment boundary for `prependPrefix`, and the two
208
+ * must agree), and a case-SENSITIVE compare let `/Admin/x` miss the mount entirely and fall
209
+ * through to a sibling — most often the host-less catch-all, under ITS auth policy. Folding
210
+ * is safe in both directions: an upstream that routes case-sensitively simply 404s a path
211
+ * whose case it does not serve, whereas NOT folding hands the request to the wrong policy.
212
+ */
213
+ export declare function pathUnderPrefix(url: string, prefix: string): boolean;
214
+ /**
215
+ * Specificity sort: host+path beats host-only beats catch-all, and among equals the LONGER path
216
+ * wins. Mutates+returns.
217
+ *
218
+ * The length tie-break is what makes `buildRouteTable`'s promise ("sorted by specificity so
219
+ * `matchRoute` picks the most specific") true for two path mounts on ONE host. The rank alone
220
+ * scores the PRESENCE of host/prefix, never its length, so `/api` and `/api/tasks` tie at 3 and
221
+ * `Array.sort` — stable — leaves them in manifest-arrival order. `matchRoute` then takes the FIRST
222
+ * `startsWith` hit, so whichever arrived first swallows the other's whole surface: `/api` catches
223
+ * `/api/tasks/v1`, or `/api/tasks` sorts first and `/api` never matches anything under it.
224
+ *
225
+ * Measured before this tie-break existed (foodguard, 2026-08-31): a second service declaring `/api`
226
+ * beside the incumbent took the ENTIRE mount, and a `@Public` health route on the incumbent began
227
+ * answering 401 because the edge was applying the OTHER service's auth policy to its path. Two
228
+ * services could not share a host prefix at all, in dev or prod — both run this function.
229
+ *
230
+ * Longest-prefix-match is the standard routing semantic and the one the caller already documents;
231
+ * the rank ordering above is unchanged, so only the previously-arbitrary tie moves.
232
+ */
191
233
  export declare function sortBySpecificity<R extends {
192
234
  host?: string;
193
235
  prefix?: string;
@@ -245,7 +287,7 @@ export declare function manifestFromClusterDecl(project: string, decl: {
245
287
  /** A flat, read-only attribute bag assembled per request: verified JWT claims,
246
288
  * `ip`, `header.<name>`, `device` (sticky key), `path`, `method`. */
247
289
  export type Attrs = Record<string, unknown>;
248
- export type RuleOp = 'eq' | 'ne' | 'in' | 'nin' | 'prefix' | 'suffix' | 'contains' | 'hastoken' | 'regex' | 'exists' | 'gt' | 'lt' | 'cidr';
290
+ export type RuleOp = 'eq' | 'ne' | 'in' | 'nin' | 'prefix' | 'suffix' | 'contains' | 'domain' | 'hastoken' | 'regex' | 'exists' | 'gt' | 'lt' | 'cidr';
249
291
  export interface Condition {
250
292
  attr: string;
251
293
  op: RuleOp;
@@ -1 +1 @@
1
- "use strict";var h=Object.defineProperty;var u=(t,e)=>h(t,"name",{value:e,configurable:!0});var f=Object.defineProperty,a=u((t,e)=>f(t,"name",{value:e,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0}),exports.smoothWeightedSelector=smoothWeightedSelector,exports.hostMatches=hostMatches,exports.matchRoute=matchRoute,exports.sortBySpecificity=sortBySpecificity,exports.authForPath=authForPath,exports.authPathMatches=authPathMatches,exports.authRuleFor=authRuleFor,exports.authRuleForPath=authRuleForPath,exports.buildRouteTable=buildRouteTable,exports.manifestFromClusterDecl=manifestFromClusterDecl,exports.matchCidr=matchCidr,exports.evalRule=evalRule,exports.pickTargetedVersion=pickTargetedVersion,exports.fnv1a=fnv1a,exports.bucketByWeight=bucketByWeight;function smoothWeightedSelector(t){const e=t.map(()=>0),r=t.reduce((o,n)=>o+Math.max(0,n),0);return()=>{if(r<=0||t.length===0)return 0;let o=0;for(let n=0;n<t.length;n++)e[n]+=Math.max(0,t[n]),e[n]>e[o]&&(o=n);return e[o]-=r,o}}u(smoothWeightedSelector,"smoothWeightedSelector"),a(smoothWeightedSelector,"smoothWeightedSelector");function hostMatches(t,e){return t.startsWith("*.")?e.endsWith(t.slice(1))||e===t.slice(2):e===t}u(hostMatches,"hostMatches"),a(hostMatches,"hostMatches");function matchRoute(t,e,r){const o=t.some(s=>s.host&&hostMatches(s.host,e));let n;if(o)n=t.filter(s=>s.host&&hostMatches(s.host,e)),n.some(s=>s.host===e)&&(n=n.filter(s=>s.host===e));else{const s=t.filter(i=>!i.host);if(s.length<=1)n=s;else{const i=s.filter(l=>l.apex&&(e===l.apex||e.endsWith("."+l.apex))).sort((l,c)=>c.apex.length-l.apex.length);n=i.length>0?i:s.filter(l=>!l.apex)}}return n.find(s=>!s.prefix||r.startsWith(s.prefix))}u(matchRoute,"matchRoute"),a(matchRoute,"matchRoute");function sortBySpecificity(t){return t.sort((e,r)=>(r.host?2:0)+(r.prefix?1:0)-((e.host?2:0)+(e.prefix?1:0))),t}u(sortBySpecificity,"sortBySpecificity"),a(sortBySpecificity,"sortBySpecificity");function authForPath(t,e,r){if(r)return"internal";const o=t.auth?.overrides??[];for(const n of o)if(e.startsWith(n.path))return n.mode;return t.auth?.default??"jwt"}u(authForPath,"authForPath"),a(authForPath,"authForPath");function authPathMatches(t,e){const r=t.split("/").filter(Boolean),o=(e.split("?")[0]??"").split("/").filter(Boolean);if(o.length<r.length)return!1;for(let n=0;n<r.length;n++){const s=r[n];if(!(s==="*"||s.startsWith(":"))&&s!==o[n])return!1}return!0}u(authPathMatches,"authPathMatches"),a(authPathMatches,"authPathMatches");function authRuleFor(t,e,r){const o=r.toUpperCase();for(const n of t?.overrides??[]){if(!authPathMatches(n.path,e)||n.methods&&!n.methods.some(i=>i.toUpperCase()===o))continue;const s=n.rule??(n.permission?{attr:"perms",op:"hastoken",value:n.permission}:void 0);return{mode:n.mode??t?.default??"jwt",...s?{rule:s}:{}}}return{mode:t?.default??"jwt"}}u(authRuleFor,"authRuleFor"),a(authRuleFor,"authRuleFor");function authRuleForPath(t,e,r,o){return o?{mode:"internal"}:authRuleFor(t.auth,e,r)}u(authRuleForPath,"authRuleForPath"),a(authRuleForPath,"authRuleForPath");function buildRouteTable(t){const e=[];for(const r of t){const o=r.auth?.default??"jwt";for(const n of r.mounts){const s={service:r.service,...n.path?{prefix:n.path}:{},...r.prependPrefix?{prependPrefix:r.prependPrefix}:{},...r.upstream?{upstream:r.upstream}:{}};e.push({...s,host:n.host,auth:o}),r.internal&&r.internalHost!==!1&&e.push({...s,host:typeof r.internalHost=="string"?r.internalHost:`internal.${n.host}`,internal:!0,auth:"internal"})}r.default&&e.push({service:r.service,...r.prependPrefix?{prependPrefix:r.prependPrefix}:{},...r.upstream?{upstream:r.upstream}:{},default:!0,auth:o})}return sortBySpecificity(e)}u(buildRouteTable,"buildRouteTable"),a(buildRouteTable,"buildRouteTable");function manifestFromClusterDecl(t,e){const r=e.upstream??(e.port?`http://localhost:${e.port}`:void 0);if(!(!e.service&&!r))return{service:e.service??t,mounts:e.host?[{host:e.host,...e.path?{path:e.path}:{}}]:[],internal:!!e.service,...e.internalHost!==void 0?{internalHost:e.internalHost}:{},...e.default?{default:!0}:{},...e.prependPrefix?{prependPrefix:e.prependPrefix}:{},...e.auth?{auth:e.auth}:{},...r?{upstream:r}:{}}}u(manifestFromClusterDecl,"manifestFromClusterDecl"),a(manifestFromClusterDecl,"manifestFromClusterDecl");const isGroup=a(t=>"all"in t||"any"in t||"not"in t,"isGroup"),reCache=new Map,compileRe=a(t=>{if(t.length>512)return null;if(reCache.has(t))return reCache.get(t);let e;try{e=new RegExp(t)}catch{e=null}return reCache.set(t,e),e},"compileRe");function ipToInt(t){const e=(t.startsWith("::ffff:")?t.slice(7):t).split(".");if(e.length!==4)return null;let r=0;for(const o of e){const n=Number(o);if(!Number.isInteger(n)||n<0||n>255||o.length>1&&o[0]==="0")return null;r=r*256+n}return r>>>0}u(ipToInt,"ipToInt"),a(ipToInt,"ipToInt");function matchCidr(t,e){const r=e.indexOf("/");if(r<0)return!1;const o=Number(e.slice(r+1));if(!Number.isInteger(o)||o<0||o>32)return!1;const n=ipToInt(t),s=ipToInt(e.slice(0,r));if(n===null||s===null)return!1;if(o===0)return!0;const i=(o===32?4294967295:~((1<<32-o)-1))>>>0;return(n&i)>>>0===(s&i)>>>0}u(matchCidr,"matchCidr"),a(matchCidr,"matchCidr");const str=a(t=>t==null?"":String(t),"str"),eq=a((t,e)=>str(t)===str(e),"eq");function evalCondition(t,e){const r=e[t.attr],o=r!=null;switch(t.op){case"exists":return o;case"ne":return!o||!eq(r,t.value)}if(!o)return!1;switch(t.op){case"eq":return eq(r,t.value);case"in":return Array.isArray(t.value)&&t.value.some(n=>Array.isArray(r)?r.some(s=>eq(s,n)):eq(r,n));case"nin":return!(Array.isArray(t.value)&&t.value.some(n=>Array.isArray(r)?r.some(s=>eq(s,n)):eq(r,n)));case"prefix":return str(r).startsWith(str(t.value));case"suffix":return str(r).endsWith(str(t.value));case"contains":return Array.isArray(r)?r.some(n=>eq(n,t.value)):str(r).includes(str(t.value));case"hastoken":{const n=str(t.value);return Array.isArray(r)?r.some(s=>eq(s,t.value)):str(r).split(/[\s,]+/).filter(Boolean).includes(n)}case"regex":{const n=compileRe(str(t.value));return n?n.test(str(r)):!1}case"gt":return Number(r)>Number(t.value);case"lt":return Number(r)<Number(t.value);case"cidr":return matchCidr(str(r),str(t.value));default:return!1}}u(evalCondition,"evalCondition"),a(evalCondition,"evalCondition");function evalRule(t,e){if(!isGroup(t))return evalCondition(t,e);const r=Array.isArray(t.all)&&t.all.length?t.all:void 0,o=Array.isArray(t.any)&&t.any.length?t.any:void 0,n=t.not&&Object.keys(t.not).length?t.not:void 0;return n&&evalRule(n,e)||r&&!r.every(s=>evalRule(s,e))||o&&!o.some(s=>evalRule(s,e))?!1:!!(r||o||n)}u(evalRule,"evalRule"),a(evalRule,"evalRule");function pickTargetedVersion(t,e){for(const r of t)if(evalRule(r.when,e))return{version:r.version,...r.name?{rule:r.name}:{}}}u(pickTargetedVersion,"pickTargetedVersion"),a(pickTargetedVersion,"pickTargetedVersion");function fnv1a(t){let e=2166136261;for(let r=0;r<t.length;r++)e^=t.charCodeAt(r),e=Math.imul(e,16777619);return e>>>0}u(fnv1a,"fnv1a"),a(fnv1a,"fnv1a");function bucketByWeight(t,e){const r=e.reduce((s,i)=>s+Math.max(0,i),0);if(r<=0||e.length===0)return 0;const o=fnv1a(t)%1e4;let n=0;for(let s=0;s<e.length;s++)if(n+=Math.max(0,e[s])/r*1e4,o<n)return s;return e.length-1}u(bucketByWeight,"bucketByWeight"),a(bucketByWeight,"bucketByWeight");
1
+ "use strict";var h=Object.defineProperty;var i=(t,r)=>h(t,"name",{value:r,configurable:!0});var f=Object.defineProperty,o=i((t,r)=>f(t,"name",{value:r,configurable:!0}),"o");Object.defineProperty(exports,"__esModule",{value:!0}),exports.smoothWeightedSelector=smoothWeightedSelector,exports.hostMatches=hostMatches,exports.matchRoute=matchRoute,exports.pathUnderPrefix=pathUnderPrefix,exports.sortBySpecificity=sortBySpecificity,exports.authForPath=authForPath,exports.authPathMatches=authPathMatches,exports.authRuleFor=authRuleFor,exports.authRuleForPath=authRuleForPath,exports.buildRouteTable=buildRouteTable,exports.manifestFromClusterDecl=manifestFromClusterDecl,exports.matchCidr=matchCidr,exports.evalRule=evalRule,exports.pickTargetedVersion=pickTargetedVersion,exports.fnv1a=fnv1a,exports.bucketByWeight=bucketByWeight;function smoothWeightedSelector(t){const r=t.map(()=>0),e=t.reduce((s,n)=>s+Math.max(0,n),0);return()=>{if(e<=0||t.length===0)return 0;let s=0;for(let n=0;n<t.length;n++)r[n]+=Math.max(0,t[n]),r[n]>r[s]&&(s=n);return r[s]-=e,s}}i(smoothWeightedSelector,"smoothWeightedSelector"),o(smoothWeightedSelector,"smoothWeightedSelector");function hostMatches(t,r){const e=t.toLowerCase(),s=r.toLowerCase();return e.startsWith("*.")?s.endsWith(e.slice(1))||s===e.slice(2):s===e}i(hostMatches,"hostMatches"),o(hostMatches,"hostMatches");function matchRoute(t,r,e){const s=t.some(a=>a.host&&hostMatches(a.host,r));let n;if(s)n=t.filter(a=>a.host&&hostMatches(a.host,r)),n.some(a=>a.host===r)&&(n=n.filter(a=>a.host===r));else{const a=t.filter(u=>!u.host);if(a.length<=1)n=a;else{const u=a.filter(l=>l.apex&&(r===l.apex||r.endsWith("."+l.apex))).sort((l,c)=>c.apex.length-l.apex.length);n=u.length>0?u:a.filter(l=>!l.apex)}}return n.find(a=>!a.prefix||pathUnderPrefix(e,a.prefix))}i(matchRoute,"matchRoute"),o(matchRoute,"matchRoute");function pathUnderPrefix(t,r){let e=r.length;for(;e>0&&r.charCodeAt(e-1)===47;)e-=1;if(e===0)return!0;if(t.length<e||t.slice(0,e).toLowerCase()!==r.slice(0,e).toLowerCase())return!1;const s=t.charCodeAt(e);return Number.isNaN(s)||s===47||s===63}i(pathUnderPrefix,"pathUnderPrefix"),o(pathUnderPrefix,"pathUnderPrefix");function sortBySpecificity(t){const r=o(e=>(e.host?2:0)+(e.prefix?1:0),"rank");return t.sort((e,s)=>r(s)-r(e)||(s.prefix?.length??0)-(e.prefix?.length??0)),t}i(sortBySpecificity,"sortBySpecificity"),o(sortBySpecificity,"sortBySpecificity");function authForPath(t,r,e){if(e)return"internal";const s=t.auth?.overrides??[];for(const n of s)if(r.startsWith(n.path))return n.mode;return t.auth?.default??"jwt"}i(authForPath,"authForPath"),o(authForPath,"authForPath");function authPathMatches(t,r){const e=t.split("/").filter(Boolean),s=(r.split("?")[0]??"").split("/").filter(Boolean);if(s.length<e.length)return!1;for(let n=0;n<e.length;n++){const a=e[n];if(!(a==="*"||a.startsWith(":"))&&a.toLowerCase()!==s[n].toLowerCase())return!1}return!0}i(authPathMatches,"authPathMatches"),o(authPathMatches,"authPathMatches");function authRuleFor(t,r,e){const s=e.toUpperCase();for(const n of t?.overrides??[]){if(!authPathMatches(n.path,r)||n.methods&&!n.methods.some(u=>u.toUpperCase()===s))continue;const a=n.rule??(n.permission?{attr:"perms",op:"hastoken",value:n.permission}:void 0);return{mode:n.mode??t?.default??"jwt",...a?{rule:a}:{}}}return{mode:t?.default??"jwt"}}i(authRuleFor,"authRuleFor"),o(authRuleFor,"authRuleFor");function authRuleForPath(t,r,e,s){return s?{mode:"internal"}:authRuleFor(t.auth,r,e)}i(authRuleForPath,"authRuleForPath"),o(authRuleForPath,"authRuleForPath");function buildRouteTable(t){const r=[];for(const e of t){const s=e.auth?.default??"jwt";for(const n of e.mounts){const a={service:e.service,...n.path?{prefix:n.path}:{},...e.prependPrefix?{prependPrefix:e.prependPrefix}:{},...e.upstream?{upstream:e.upstream}:{}};r.push({...a,host:n.host,auth:s}),e.internal&&e.internalHost!==!1&&r.push({...a,host:typeof e.internalHost=="string"?e.internalHost:`internal.${n.host}`,internal:!0,auth:"internal"})}e.default&&r.push({service:e.service,...e.prependPrefix?{prependPrefix:e.prependPrefix}:{},...e.upstream?{upstream:e.upstream}:{},default:!0,auth:s})}return sortBySpecificity(r)}i(buildRouteTable,"buildRouteTable"),o(buildRouteTable,"buildRouteTable");function manifestFromClusterDecl(t,r){const e=r.upstream??(r.port?`http://localhost:${r.port}`:void 0);if(!(!r.service&&!e))return{service:r.service??t,mounts:r.host?[{host:r.host,...r.path?{path:r.path}:{}}]:[],internal:!!r.service,...r.internalHost!==void 0?{internalHost:r.internalHost}:{},...r.default?{default:!0}:{},...r.prependPrefix?{prependPrefix:r.prependPrefix}:{},...r.auth?{auth:r.auth}:{},...e?{upstream:e}:{}}}i(manifestFromClusterDecl,"manifestFromClusterDecl"),o(manifestFromClusterDecl,"manifestFromClusterDecl");const isGroup=o(t=>"all"in t||"any"in t||"not"in t,"isGroup"),reCache=new Map,compileRe=o(t=>{if(t.length>512)return null;if(reCache.has(t))return reCache.get(t);let r;try{r=new RegExp(t)}catch{r=null}return reCache.set(t,r),r},"compileRe");function ipToInt(t){const r=(t.startsWith("::ffff:")?t.slice(7):t).split(".");if(r.length!==4)return null;let e=0;for(const s of r){const n=Number(s);if(!Number.isInteger(n)||n<0||n>255||s.length>1&&s[0]==="0")return null;e=e*256+n}return e>>>0}i(ipToInt,"ipToInt"),o(ipToInt,"ipToInt");function matchCidr(t,r){const e=r.indexOf("/");if(e<0)return!1;const s=Number(r.slice(e+1));if(!Number.isInteger(s)||s<0||s>32)return!1;const n=ipToInt(t),a=ipToInt(r.slice(0,e));if(n===null||a===null)return!1;if(s===0)return!0;const u=(s===32?4294967295:~((1<<32-s)-1))>>>0;return(n&u)>>>0===(a&u)>>>0}i(matchCidr,"matchCidr"),o(matchCidr,"matchCidr");const str=o(t=>t==null?"":String(t),"str"),eq=o((t,r)=>str(t)===str(r),"eq");function evalCondition(t,r){const e=r[t.attr],s=e!=null;switch(t.op){case"exists":return s;case"ne":return!s||!eq(e,t.value)}if(!s)return!1;switch(t.op){case"eq":return eq(e,t.value);case"in":return Array.isArray(t.value)&&t.value.some(n=>Array.isArray(e)?e.some(a=>eq(a,n)):eq(e,n));case"nin":return Array.isArray(t.value)&&!t.value.some(n=>Array.isArray(e)?e.some(a=>eq(a,n)):eq(e,n));case"prefix":return str(e).startsWith(str(t.value));case"suffix":return str(e).endsWith(str(t.value));case"domain":{const n=str(e).lastIndexOf("@"),a=(n===-1?str(e):str(e).slice(n+1)).toLowerCase().replace(/\.$/,""),u=str(t.value).toLowerCase().replace(/^\./,"").replace(/\.$/,"");return!!u&&(a===u||a.endsWith("."+u))}case"contains":return Array.isArray(e)?e.some(n=>eq(n,t.value)):str(e).includes(str(t.value));case"hastoken":{const n=str(t.value);return Array.isArray(e)?e.some(a=>eq(a,t.value)):str(e).split(/[\s,]+/).filter(Boolean).includes(n)}case"regex":{const n=compileRe(str(t.value));return n?n.test(str(e)):!1}case"gt":return Number(e)>Number(t.value);case"lt":return Number(e)<Number(t.value);case"cidr":return matchCidr(str(e),str(t.value));default:return!1}}i(evalCondition,"evalCondition"),o(evalCondition,"evalCondition");function evalRule(t,r){if(!isGroup(t))return evalCondition(t,r);const e=Array.isArray(t.all)&&t.all.length?t.all:void 0,s=Array.isArray(t.any)&&t.any.length?t.any:void 0,n=t.not&&Object.keys(t.not).length?t.not:void 0;return n&&evalRule(n,r)||e&&!e.every(a=>evalRule(a,r))||s&&!s.some(a=>evalRule(a,r))?!1:!!(e||s||n)}i(evalRule,"evalRule"),o(evalRule,"evalRule");function pickTargetedVersion(t,r){for(const e of t)if(evalRule(e.when,r))return{version:e.version,...e.name?{rule:e.name}:{}}}i(pickTargetedVersion,"pickTargetedVersion"),o(pickTargetedVersion,"pickTargetedVersion");function fnv1a(t){let r=2166136261;for(let e=0;e<t.length;e++)r^=t.charCodeAt(e),r=Math.imul(r,16777619);return r>>>0}i(fnv1a,"fnv1a"),o(fnv1a,"fnv1a");function bucketByWeight(t,r){const e=r.reduce((a,u)=>a+Math.max(0,u),0);if(e<=0||r.length===0)return 0;const s=fnv1a(t)%1e4;let n=0;for(let a=0;a<r.length;a++)if(n+=Math.max(0,r[a])/e*1e4,s<n)return a;return r.length-1}i(bucketByWeight,"bucketByWeight"),o(bucketByWeight,"bucketByWeight");
@@ -0,0 +1,74 @@
1
+ import type { ProdGatewayOptions } from './prod-runtime/types';
2
+ export interface RateLimitConfig {
3
+ limit: number;
4
+ windowMs: number;
5
+ /** Use the shared/dedicated Redis budget rather than per-instance memory. */
6
+ wantRedis: boolean;
7
+ /** A Redis URL just for the limiter; otherwise the registry client is reused. */
8
+ dedicatedUrl?: string;
9
+ timeoutMs?: number;
10
+ /** Deny on a Redis error/timeout. Default false — the limiter is defence in depth, and a
11
+ * Redis blip must not 429 every request. */
12
+ failClosed: boolean;
13
+ }
14
+ export interface JwtConfig {
15
+ jwksUrl?: string;
16
+ secret?: string;
17
+ issuer?: string;
18
+ audience?: string[];
19
+ /** Accept any `Bearer <anything>` on a `jwt` route when no verifier is wired. Opt-in, weak. */
20
+ presenceOnly: boolean;
21
+ }
22
+ export interface GatewayEnvConfig {
23
+ /** Bind ports: explicit `LENSMCP_GW_PORTS`, else the platform `PORT` contract, else []. */
24
+ ports: number[];
25
+ /** Cluster worker processes; `auto`/`max`/`0` ⇒ one per core. Always ≥ 1. */
26
+ workers: number;
27
+ undici: NonNullable<ProdGatewayOptions['undici']>;
28
+ server: NonNullable<ProdGatewayOptions['server']>;
29
+ upstreamRetries?: number;
30
+ trustProxyIp: boolean;
31
+ trustProxyHops: number;
32
+ cookie?: NonNullable<ProdGatewayOptions['cookie']>;
33
+ attributeHeaders: string[];
34
+ clientIpHeader?: string;
35
+ clientIpTrustedProxies: string[];
36
+ uidHeader?: string;
37
+ accessLog: boolean;
38
+ statusEnabled: boolean;
39
+ statusToken?: string;
40
+ metricsEnabled: boolean;
41
+ /** TTL for the Redis `status:*` fan-out — three flush windows, never under 3s. */
42
+ statusTtlSec: number;
43
+ trafficFlushMs?: number;
44
+ rateLimit?: RateLimitConfig;
45
+ health?: {
46
+ path?: string;
47
+ intervalMs?: number;
48
+ timeoutMs?: number;
49
+ };
50
+ healthCheckEnabled: boolean;
51
+ jwt: JwtConfig;
52
+ mtlsFromRedis: boolean;
53
+ }
54
+ /** Parse everything the gateway can decide from the environment alone. Pure. */
55
+ export declare function parseGatewayEnv(env: NodeJS.ProcessEnv): GatewayEnvConfig;
56
+ /**
57
+ * Worker processes for multi-core. `auto`/`max`/`0` ⇒ one per core; anything unparseable or
58
+ * non-positive ⇒ 1, so a typo runs a single healthy gateway rather than none.
59
+ */
60
+ export declare function workerCount(env: NodeJS.ProcessEnv): number;
61
+ /**
62
+ * The last word on upstream TLS, applied AFTER the mTLS materials are loaded (which is
63
+ * effectful, so it cannot live in `parseGatewayEnv`).
64
+ *
65
+ * A configured CA that is never checked against is inert: `rejectUnauthorized:false` skips
66
+ * verification entirely, so an operator who mounted their private CA to pin east-west TLS got no
67
+ * pinning at all and no warning. A CA is an explicit statement of who to trust — honour it
68
+ * unless verification was explicitly turned off. Returns what changed, for the boot log.
69
+ */
70
+ export declare function finalizeUpstreamTls(undici: NonNullable<ProdGatewayOptions['undici']>): {
71
+ autoEnabled: boolean;
72
+ };
73
+ /** mTLS rides on HTTP/2 unless explicitly disabled. Returns whether a client cert is in play. */
74
+ export declare function finalizeUpstreamH2(undici: NonNullable<ProdGatewayOptions['undici']>, env: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1 @@
1
+ "use strict";var w=Object.defineProperty;var o=(e,E)=>w(e,"name",{value:E,configurable:!0});var k=Object.defineProperty,i=o((e,E)=>k(e,"name",{value:E,configurable:!0}),"i");Object.defineProperty(exports,"__esModule",{value:!0}),exports.parseGatewayEnv=parseGatewayEnv,exports.workerCount=workerCount,exports.finalizeUpstreamTls=finalizeUpstreamTls,exports.finalizeUpstreamH2=finalizeUpstreamH2;const node_os_1=require("node:os"),num=i(e=>{if(e===void 0||e.trim()==="")return;const E=Number(e);return Number.isFinite(E)?E:void 0},"num"),csv=i(e=>(e??"").split(",").map(E=>E.trim()).filter(Boolean),"csv"),flag=i(e=>e==="1","flag");function parseGatewayEnv(e){const E=csv(e.LENSMCP_GW_PORTS??e.PORT).map(Number).filter(a=>Number.isFinite(a)&&a>=0&&a<=65535),S=Math.max(0,Math.floor(num(e.LENSMCP_GW_TRUST_PROXY)??0)),t={},N=e.LENSMCP_GW_UPSTREAM_TLS_VERIFY;N==="1"?t.rejectUnauthorized=!0:N==="0"&&(t.rejectUnauthorized=!1),flag(e.LENSMCP_GW_UPSTREAM_H2)&&(t.allowH2=!0);const L=num(e.LENSMCP_GW_UNDICI_CONNECTIONS);L!==void 0&&(t.connections=L);const T=num(e.LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS);T!==void 0&&(t.bodyTimeout=T);const P=num(e.LENSMCP_GW_UNDICI_PIPELINING);P!==void 0&&(t.pipelining=P);const W=num(e.LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS);W!==void 0&&(t.headersTimeout=W);const I=num(e.LENSMCP_GW_UNDICI_KEEPALIVE_MS);I!==void 0&&(t.keepAliveTimeout=I);const _={},G=num(e.LENSMCP_GW_KEEPALIVE_MS);G!==void 0&&(_.keepAliveTimeout=G);const d=num(e.LENSMCP_GW_REQUEST_TIMEOUT_MS);d!==void 0&&(_.requestTimeout=d);const u=num(e.LENSMCP_GW_HEADERS_TIMEOUT_MS);u!==void 0&&(_.headersTimeout=u);const c=num(e.LENSMCP_GW_MAX_HEADERS);c!==void 0&&(_.maxHeadersCount=c);const l=num(e.LENSMCP_GW_CONN_CHECK_MS);l!==void 0&&(_.connectionsCheckingInterval=l);const R=num(e.LENSMCP_GW_MAX_CONNECTIONS);R!==void 0&&(_.maxConnections=R);const A=num(e.LENSMCP_GW_BODY_STALL_MS);A!==void 0&&(_.bodyStallMs=A);const M=e.LENSMCP_GW_COOKIE_SECRET,r=e.LENSMCP_GW_COOKIE_NAME,n=e.LENSMCP_GW_COOKIE_DOMAIN,m=num(e.LENSMCP_GW_COOKIE_MAXAGE),U=M||r||n?{...r?{name:r}:{},...M?{secret:M}:{},...n?{domain:n}:{},...m!==void 0?{maxAge:m}:{}}:void 0,v=num(e.LENSMCP_GW_RATE_LIMIT),s=e.LENSMCP_GW_RATE_LIMIT_REDIS_URL,O=num(e.LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS),p=v===void 0?void 0:{limit:v,windowMs:num(e.LENSMCP_GW_RATE_WINDOW_MS)??6e4,wantRedis:flag(e.LENSMCP_GW_RATE_LIMIT_REDIS)||!!s,...s?{dedicatedUrl:s}:{},...O!==void 0?{timeoutMs:O}:{},failClosed:flag(e.LENSMCP_GW_RATE_LIMIT_FAIL_CLOSED)},H=e.LENSMCP_GW_HEALTH_PATH,D=num(e.LENSMCP_GW_HEALTH_INTERVAL_MS),f=num(e.LENSMCP_GW_HEALTH_TIMEOUT_MS),h=flag(e.LENSMCP_GW_HEALTH_CHECK),b=h?{...H?{path:H}:{},...D!==void 0?{intervalMs:D}:{},...f!==void 0?{timeoutMs:f}:{}}:void 0,C=num(e.LENSMCP_GW_TRAFFIC_FLUSH_MS),x=csv(e.LENSMCP_GW_JWT_AUD);return{ports:E,workers:workerCount(e),undici:t,server:_,...num(e.LENSMCP_GW_UPSTREAM_RETRIES)!==void 0?{upstreamRetries:num(e.LENSMCP_GW_UPSTREAM_RETRIES)}:{},trustProxyIp:S>0,trustProxyHops:S,...U?{cookie:U}:{},attributeHeaders:csv(e.LENSMCP_GW_ATTR_HEADERS),...e.LENSMCP_GW_CLIENT_IP_HEADER?{clientIpHeader:e.LENSMCP_GW_CLIENT_IP_HEADER}:{},clientIpTrustedProxies:csv(e.LENSMCP_GW_CLIENT_IP_TRUSTED_PROXIES),...e.LENSMCP_GW_UID_HEADER?{uidHeader:e.LENSMCP_GW_UID_HEADER}:{},accessLog:flag(e.LENSMCP_GW_ACCESS_LOG),statusEnabled:flag(e.LENSMCP_GW_STATUS),...e.LENSMCP_GW_STATUS_TOKEN?{statusToken:e.LENSMCP_GW_STATUS_TOKEN}:{},metricsEnabled:flag(e.LENSMCP_GW_METRICS),statusTtlSec:Math.max(3,Math.ceil((C??5e3)/1e3*3)),...C!==void 0?{trafficFlushMs:C}:{},...p?{rateLimit:p}:{},...b?{health:b}:{},healthCheckEnabled:h,jwt:{...e.LENSMCP_GW_JWKS_URL?{jwksUrl:e.LENSMCP_GW_JWKS_URL}:{},...e.LENSMCP_GW_JWT_SECRET?{secret:e.LENSMCP_GW_JWT_SECRET}:{},...e.LENSMCP_GW_JWT_ISS?{issuer:e.LENSMCP_GW_JWT_ISS}:{},...x.length?{audience:x}:{},presenceOnly:flag(e.LENSMCP_GW_JWT_PRESENCE_ONLY)},mtlsFromRedis:flag(e.LENSMCP_GW_MTLS_FROM_REDIS)}}o(parseGatewayEnv,"parseGatewayEnv"),i(parseGatewayEnv,"parseGatewayEnv");function workerCount(e){const E=(e.LENSMCP_GW_WORKERS??"1").trim().toLowerCase();if(E==="auto"||E==="max"||E==="0")return Math.max(1,(0,node_os_1.cpus)().length);const S=Math.floor(Number(E));return Number.isFinite(S)&&S>0?S:1}o(workerCount,"workerCount"),i(workerCount,"workerCount");function finalizeUpstreamTls(e){return e.ca&&e.rejectUnauthorized===void 0?(e.rejectUnauthorized=!0,{autoEnabled:!0}):{autoEnabled:!1}}o(finalizeUpstreamTls,"finalizeUpstreamTls"),i(finalizeUpstreamTls,"finalizeUpstreamTls");function finalizeUpstreamH2(e,E){return e.cert&&e.key?(E.LENSMCP_GW_UPSTREAM_H2!=="0"&&(e.allowH2=!0),!0):!1}o(finalizeUpstreamH2,"finalizeUpstreamH2"),i(finalizeUpstreamH2,"finalizeUpstreamH2");
@@ -1 +1 @@
1
- "use strict";var u=Object.defineProperty;var d=(r,s)=>u(r,"name",{value:s,configurable:!0});var n=Object.defineProperty,l=d((r,s)=>n(r,"name",{value:s,configurable:!0}),"l");Object.defineProperty(exports,"__esModule",{value:!0}),exports.logAccess=void 0;const logAccess=l((r,s,e)=>{const t=r.raw,o=s.statusCode??0,a=(t.headers.host||"").split(":")[0],c=(t.url||"/").split("?")[0],i=Math.round(s.elapsedTime);try{console.log(JSON.stringify({severity:o>=500?"ERROR":o>=400?"WARNING":"INFO",time:new Date().toISOString(),message:`${t.method} ${a}${c} ${o} ${i}ms`,method:t.method,host:a,path:c,status:o,ms:i,...e?{service:e.route.service,upstream:e.upLabel}:{},...e?.version?{version:e.version}:{},...e?.caller?{caller:e.caller}:{},...t.headers["x-request-id"]?{requestId:String(t.headers["x-request-id"])}:{}}))}catch{}},"logAccess");exports.logAccess=logAccess;
1
+ "use strict";var g=Object.defineProperty;var u=(t,r)=>g(t,"name",{value:r,configurable:!0});var d=Object.defineProperty,n=u((t,r)=>d(t,"name",{value:r,configurable:!0}),"n");Object.defineProperty(exports,"__esModule",{value:!0}),exports.logAccess=void 0;const edge_1=require("./edge"),logAccess=n((t,r,e)=>{const s=t.raw,o=r.statusCode??0,a=(s.headers.host||"").split(":")[0],i=(s.url||"/").split("?")[0],c=(0,edge_1.rawTargetOf)(s),l=Math.round(r.elapsedTime);try{console.log(JSON.stringify({severity:o>=500?"ERROR":o>=400?"WARNING":"INFO",time:new Date().toISOString(),message:`${s.method} ${a}${i} ${o} ${l}ms`,method:s.method,host:a,path:i,status:o,ms:l,...c?{rawPath:c.split("?")[0]}:{},...e?{service:e.route.service,upstream:e.upLabel}:{},...e?.version?{version:e.version}:{},...e?.caller?{caller:e.caller}:{},...s.headers["x-request-id"]?{requestId:String(s.headers["x-request-id"])}:{}}))}catch{}},"logAccess");exports.logAccess=logAccess;
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Builds one Fastify app per port (shared route table + providers). Streams
3
+ * every body straight through (never parses/buffers — this is a proxy), wires
4
+ * reply-from's undici pool, the operational probes (/livez /readyz /statusz
5
+ * /metricsz), and the trust / CORS / edge-finish hooks.
6
+ */
1
7
  import type * as http from 'node:http';
2
8
  import type * as net from 'node:net';
3
9
  import { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';