@lensmcp/cluster 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ import type * as http from 'node:http';
2
+ /**
3
+ * The gateway's EDGE error contract — shared by the dev (`gateway.lib.ts`) and prod
4
+ * (`prod-gateway.lib.ts`) gateways so an error the gateway generates ITSELF (before/instead of an
5
+ * upstream) has the SAME shape as an error a service returns.
6
+ *
7
+ * Two properties the gateway guarantees for every request:
8
+ * 1. **JSON, not `text/plain`.** A gateway-generated 4xx/5xx is the JSON envelope
9
+ * `{ key, status, traceId, message? }` — the same `{ key, status }` localized contract the house
10
+ * `ApiError` uses, so a browser app renders `t(key)` (a localized message) AND can read the trace id.
11
+ * `key` is a framework-NEUTRAL `gateway.*` slug; the consumer ships the `gateway.*` translations.
12
+ * `message` is a developer-facing hint (never shown to users), preserving the old plain-text detail.
13
+ * 2. **A trace id on EVERY request.** {@link ensureRequestId} mints `x-request-id` at the edge if the
14
+ * client didn't send one, so the SAME id is forwarded to the upstream (the pod adopts it as its
15
+ * trace id) AND returned to the client — making any request traceable end-to-end, not just lensmcp
16
+ * flow-traced ones.
17
+ *
18
+ * This keeps the gateway generic (no consumer-specific keys baked in beyond the neutral `gateway.*`
19
+ * namespace) while integrating cleanly with a localized-error system.
20
+ */
21
+ export type GatewayErrorReason = 'bad_request' | 'unauthorized' | 'forbidden' | 'not_found' | 'rate_limited' | 'unavailable' | 'internal';
22
+ /** Reason → stable, neutral localized key (the consumer translates the `gateway.*` namespace). */
23
+ export declare const GATEWAY_ERROR_KEY: Record<GatewayErrorReason, string>;
24
+ /**
25
+ * Ensure the request carries an `x-request-id` (mint one at the edge if absent) and return it. Because
26
+ * the gateway forwards `req.headers` to the upstream, the pod's request-context adopts THIS id as its
27
+ * `traceId`, and the gateway returns it on the response — one id, end-to-end, for ANY request.
28
+ */
29
+ export declare function ensureRequestId(req: http.IncomingMessage): string;
30
+ /**
31
+ * Build the JSON edge-error body `{ key, status, traceId, message? }`. `detail` becomes the dev-facing
32
+ * `message` (e.g. the old plain-text reason), never surfaced to end users.
33
+ */
34
+ export declare function gatewayErrorBody(status: number, reason: GatewayErrorReason, traceId: string, detail?: string): string;
35
+ export declare const GATEWAY_ERROR_CONTENT_TYPE = "application/json; charset=utf-8";
36
+ //# sourceMappingURL=gateway-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway-errors.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AAEvC;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,kBAAkB,GAC1B,aAAa,GACb,cAAc,GACd,WAAW,GACX,WAAW,GACX,cAAc,GACd,aAAa,GACb,UAAU,CAAC;AAEf,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,kBAAkB,EAAE,MAAM,CAQhE,CAAC;AAOF;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,MAAM,CAMjE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,kBAAkB,EAC1B,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CAOR;AAED,eAAO,MAAM,0BAA0B,oCAAoC,CAAC"}
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GATEWAY_ERROR_CONTENT_TYPE = exports.GATEWAY_ERROR_KEY = void 0;
4
+ exports.ensureRequestId = ensureRequestId;
5
+ exports.gatewayErrorBody = gatewayErrorBody;
6
+ /** Reason → stable, neutral localized key (the consumer translates the `gateway.*` namespace). */
7
+ exports.GATEWAY_ERROR_KEY = {
8
+ bad_request: 'gateway.badRequest',
9
+ unauthorized: 'gateway.unauthorized',
10
+ forbidden: 'gateway.forbidden',
11
+ not_found: 'gateway.notFound',
12
+ rate_limited: 'gateway.rateLimited',
13
+ unavailable: 'gateway.unavailable',
14
+ internal: 'gateway.internal',
15
+ };
16
+ const headerValue = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
17
+ const mintId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
18
+ /**
19
+ * Ensure the request carries an `x-request-id` (mint one at the edge if absent) and return it. Because
20
+ * the gateway forwards `req.headers` to the upstream, the pod's request-context adopts THIS id as its
21
+ * `traceId`, and the gateway returns it on the response — one id, end-to-end, for ANY request.
22
+ */
23
+ function ensureRequestId(req) {
24
+ const existing = headerValue(req.headers['x-request-id']);
25
+ if (existing)
26
+ return existing;
27
+ const rid = mintId();
28
+ req.headers['x-request-id'] = rid;
29
+ return rid;
30
+ }
31
+ /**
32
+ * Build the JSON edge-error body `{ key, status, traceId, message? }`. `detail` becomes the dev-facing
33
+ * `message` (e.g. the old plain-text reason), never surfaced to end users.
34
+ */
35
+ function gatewayErrorBody(status, reason, traceId, detail) {
36
+ return JSON.stringify({
37
+ key: exports.GATEWAY_ERROR_KEY[reason],
38
+ status,
39
+ traceId,
40
+ ...(detail ? { message: detail } : {}),
41
+ });
42
+ }
43
+ exports.GATEWAY_ERROR_CONTENT_TYPE = 'application/json; charset=utf-8';
@@ -1 +1 @@
1
- {"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
@@ -21,9 +21,21 @@ const http = tslib_1.__importStar(require("node:http"));
21
21
  const os = tslib_1.__importStar(require("node:os"));
22
22
  const path = tslib_1.__importStar(require("node:path"));
23
23
  const manifest_1 = require("./manifest");
24
+ const gateway_errors_1 = require("./gateway-errors");
24
25
  const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
25
26
  var manifest_2 = require("./manifest"); // back-compat re-export
26
27
  Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_2.hostMatches; } });
28
+ /**
29
+ * Send a gateway-generated edge error as the JSON envelope `{ key, status, traceId, message? }` (NOT
30
+ * `text/plain`) + an `x-request-id` header — the SAME localized contract a service returns, so a browser
31
+ * app renders `t(key)` and surfaces the trace id. Mirrors the prod gateway's `sendError`.
32
+ */
33
+ const sendError = (res, req, status, reason, detail) => {
34
+ const traceId = (0, gateway_errors_1.ensureRequestId)(req);
35
+ if (!res.headersSent)
36
+ res.writeHead(status, { 'content-type': gateway_errors_1.GATEWAY_ERROR_CONTENT_TYPE, 'x-request-id': traceId });
37
+ res.end((0, gateway_errors_1.gatewayErrorBody)(status, reason, traceId, detail));
38
+ };
27
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
40
  const httpProxy = require('http-proxy');
29
41
  /**
@@ -39,6 +51,29 @@ function verifyDevJwt(req) {
39
51
  const [h, p, s] = auth.slice(7).split('.');
40
52
  if (!h || !p || !s)
41
53
  return undefined;
54
+ // An IdP that signs ASYMMETRIC (RS256 + JWKS — e.g. tetros's auth host) does NOT use the HS256 dev
55
+ // shared-secret this dev edge assumed. The PROD gateway verifies RS256 via jwks-verify; in dev we
56
+ // DECODE + exp-check the claims (no signature check) — the gateway is the only local ingress, the token
57
+ // came from the trusted local IdP, and a dev IdP's ephemeral per-pod keys make in-cluster JWKS
58
+ // verification unreliable. (The HS256 path below stays for services using the shared-secret convention.)
59
+ let alg;
60
+ try {
61
+ alg = JSON.parse(Buffer.from(h, 'base64url').toString())['alg'];
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ if (typeof alg === 'string' && alg !== 'HS256') {
67
+ try {
68
+ const rsClaims = JSON.parse(Buffer.from(p, 'base64url').toString());
69
+ if (typeof rsClaims['exp'] === 'number' && Date.now() / 1000 > rsClaims['exp'])
70
+ return undefined;
71
+ return rsClaims;
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
42
77
  const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
43
78
  const a = Buffer.from(s);
44
79
  const b = Buffer.from(expected);
@@ -687,14 +722,18 @@ async function startGateway(options, context) {
687
722
  if (svc)
688
723
  scaleUp(svc);
689
724
  if (!sock) {
690
- res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
691
- const errs = svc?.lastErrors?.length ? '\n\nService errors (from build/runtime):\n ' + svc.lastErrors.join('\n ') : '';
692
- res.end(`Service ${route.project} is not reachable (no pods in ${route.pool.dir}).${errs}\nFix the error — the watcher recovers and the next request retries automatically.`);
725
+ const errs = svc?.lastErrors?.length ? ` Service errors: ${svc.lastErrors.join('; ')}` : '';
726
+ sendError(res, req, 503, 'unavailable', `Service ${route.project} is not reachable (no pods in ${route.pool.dir}).${errs} The watcher recovers and the next request retries automatically.`);
693
727
  return;
694
728
  }
695
729
  req.__lensmcpPod = path.basename(sock, '.sock');
696
730
  traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
697
- proxy.web(req, res, { target: { socketPath: sock }, autoRewrite: true, agent: httpAgent }, (err) => {
731
+ // NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's setRedirectHostRewrite
732
+ // does url.parse(options.target) on ANY 3xx response — which throws ("url must be a string, received
733
+ // Object") and CRASHES the gateway the instant a pod returns a redirect (e.g. the auth IdP's
734
+ // /authorize → login). A socket target has no host to rewrite anyway, and pods emit ABSOLUTE
735
+ // external redirect URLs, so no rewrite is needed.
736
+ proxy.web(req, res, { target: { socketPath: sock }, agent: httpAgent }, (err) => {
698
737
  route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
699
738
  route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
700
739
  try {
@@ -702,15 +741,15 @@ async function startGateway(options, context) {
702
741
  }
703
742
  catch { /* already gone */ } // a refused sock is a GHOST file — remove it so rescans stay truthful
704
743
  console.error(`[gateway] pod ${path.basename(sock)} of ${route.project}:`, err.message);
705
- res.statusCode = 502;
706
- res.end(`Pod ${path.basename(sock)} of ${route.project} unavailable — retry.`);
744
+ sendError(res, req, 502, 'unavailable', `Pod ${path.basename(sock)} of ${route.project} unavailable — retry.`);
707
745
  });
708
746
  return;
709
747
  }
710
- proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
748
+ // autoRewrite ONLY for a string (external URL) target a non-string target crashes
749
+ // setRedirectHostRewrite's url.parse(options.target) on a 3xx (see the socketPath case above).
750
+ proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
711
751
  console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
712
- res.statusCode = 502;
713
- res.end(`Upstream ${route.project} unavailable.`);
752
+ sendError(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
714
753
  });
715
754
  };
716
755
  // The gateway OWNS these headers — strip any client-supplied copy at ingress so identity
@@ -731,6 +770,9 @@ async function startGateway(options, context) {
731
770
  // any public route would forward a spoofed caller id to downstream audit.
732
771
  for (const h of GATEWAY_OWNED_HEADERS)
733
772
  delete req.headers[h];
773
+ // Mint a trace id for EVERY request (not just lens-traced ones): it's forwarded to the pod (adopted as
774
+ // its traceId) and returned on the response — so any request is traceable end-to-end.
775
+ (0, gateway_errors_1.ensureRequestId)(req);
734
776
  const trace = beginTrace(req);
735
777
  if (trace) {
736
778
  traceStep(trace, 'received', { host: trace.host, method: trace.method, url: trace.url });
@@ -773,8 +815,7 @@ async function startGateway(options, context) {
773
815
  res.end(JSON.stringify({ host, services: mounted.map((r) => ({ prefix: r.prefix, project: r.project, internal: !!r.internal })) }, null, 2));
774
816
  return;
775
817
  }
776
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
777
- res.end(mounted.length > 0
818
+ sendError(res, req, 404, 'not_found', mounted.length > 0
778
819
  ? `No service mounted at ${url}. Mounted on ${host}: ${[...new Set(mounted.map((r) => r.prefix))].join(', ')} (+ /health, /.well-known/services)`
779
820
  : 'No gateway route for this hostname.');
780
821
  return;
@@ -785,6 +826,26 @@ async function startGateway(options, context) {
785
826
  ...(route.host ? { routeHost: route.host } : {}),
786
827
  ...(route.internal ? { internal: true } : {}),
787
828
  });
829
+ // CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries
830
+ // no token/credentials by spec, so the JWT (or x-api-key) enforcement below would 401 it and the
831
+ // browser's CORS check fails with "No Access-Control-Allow-Origin". The matching real request
832
+ // (GET/POST with the token) still goes through auth normally; the service supplies CORS on it.
833
+ if (req.method === 'OPTIONS') {
834
+ cors(req, res);
835
+ res.writeHead(204, {
836
+ 'access-control-allow-methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
837
+ 'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
838
+ });
839
+ res.end();
840
+ return;
841
+ }
842
+ // CORS on EVERY routed response — including the 401/403/5xx the gateway generates ITSELF below (edge
843
+ // auth, authz, proxy errors). Without this, a gateway-generated error short-circuits before the upstream
844
+ // service (which carries CORS on success), so the browser sees the error WITHOUT an
845
+ // Access-Control-Allow-Origin and reports a CORS failure that MASKS the real status (e.g. a 401 the app
846
+ // should handle by re-authing). On the success path the upstream's CORS header overwrites this one
847
+ // (http-proxy setHeader), so there's no duplicate. The gateway is the edge CORS authority.
848
+ cors(req, res);
788
849
  if (route.internal) {
789
850
  // service-to-service: validate the CALLER's api key at the boundary
790
851
  const presented = String(req.headers['x-api-key'] ?? '');
@@ -792,8 +853,7 @@ async function startGateway(options, context) {
792
853
  if (!caller) {
793
854
  traceStep(trace, 'auth', { mode: 'x-api-key', ok: false });
794
855
  finishTrace(trace, route.project, 401);
795
- res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
796
- res.end('Invalid or missing x-api-key for internal route.');
856
+ sendError(res, req, 401, 'unauthorized', 'Invalid or missing x-api-key for internal route.');
797
857
  return;
798
858
  }
799
859
  req.headers['x-api-key-id'] = caller; // audit + lens attribution
@@ -815,15 +875,13 @@ async function startGateway(options, context) {
815
875
  if (!claims) {
816
876
  traceStep(trace, 'auth', { mode: 'jwt', ok: false });
817
877
  finishTrace(trace, route.project, 401);
818
- res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
819
- res.end('Unauthorized: invalid or missing token');
878
+ sendError(res, req, 401, 'unauthorized', 'Unauthorized: invalid or missing token');
820
879
  return;
821
880
  }
822
881
  if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
823
882
  traceStep(trace, 'authz', { ok: false });
824
883
  finishTrace(trace, route.project, 403);
825
- res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
826
- res.end('Forbidden: insufficient permission');
884
+ sendError(res, req, 403, 'forbidden', 'Forbidden: insufficient permission');
827
885
  return;
828
886
  }
829
887
  stampIdentity(req, claims);
@@ -844,8 +902,7 @@ async function startGateway(options, context) {
844
902
  catch (e) {
845
903
  traceStep(trace, 'auth', { mode: 'middleware', ok: false, reason: e.message });
846
904
  finishTrace(trace, route.project, 401);
847
- res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
848
- res.end(`Gateway middleware rejected the request: ${e.message}`);
905
+ sendError(res, req, 401, 'unauthorized', `Gateway middleware rejected the request: ${e.message}`);
849
906
  return;
850
907
  }
851
908
  }
@@ -114,6 +114,14 @@ export interface ProdGatewayOptions {
114
114
  /** Opt-in one-JSON-line-per-request access log to stdout, shaped for Cloud
115
115
  * Logging (severity + time + message). Default off (zero overhead). */
116
116
  accessLog?: boolean;
117
+ /** Browser origins allowed to make CREDENTIALED cross-origin requests (cookies /
118
+ * `credentials: 'include'`). For an origin in this allowlist the gateway answers preflights — and
119
+ * stamps gateway-generated responses — with `Access-Control-Allow-Origin: <exact origin>` +
120
+ * `Access-Control-Allow-Credentials: true` (NEVER `*`: a credentialed `*` is forbidden by the Fetch
121
+ * spec and a cross-site read hole). Needed when a first-party browser app POSTs to a routed service
122
+ * that sets a cookie (e.g. an IdP SSO cookie set across `login.example.com → auth.example.com`).
123
+ * Exact-matched, case-insensitive. Omit (default) → no credentialed CORS (the Bearer-only edge). */
124
+ corsCredentialOrigins?: string[];
117
125
  /** Return verified subject attributes (e.g. JWT claims) for ABAC routing.
118
126
  * MUST verify (signature/exp) — forged claims must not reach the rules. */
119
127
  identify?: (req: http.IncomingMessage) => Record<string, unknown> | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAIlC,OAAO,EAEL,KAAK,QAAQ,EAAc,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAqB,KAAK,KAAK,EAClG,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGhD;;+EAE+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC;oEACgE;IAChE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,gBAAgB,CAAC;IAC5B,2EAA2E;IAC3E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC;qFACiF;IACjF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,CAAC;IACnE,GAAG,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACtD,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAChD,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;qFACiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;2EAQuE;IACvE,MAAM,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvP;;;;oFAIgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;sDAIkD;IAClD,sBAAsB,CAAC,EAAE,MAAM,EAAE,CAAC;IAClC;;oFAEgF;IAChF,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB;6FACyF;IACzF,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;0FACsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+FAA+F;IAC/F,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;gFAE4E;IAC5E,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;IAC/C;;iFAE6E;IAC7E,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;+FAG2F;IAC3F,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B;gGAC4F;IAC5F,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB;;;uFAGmF;IACnF,MAAM,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACrH;4EACwE;IACxE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;yGAMqG;IACrG,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IAGjC;gFAC4E;IAC5E,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9E,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B;2DACuD;IACvD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;4EACwE;IACxE,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9E;sEACkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,KAAK,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,aAAa,EAAE,CAAC;IAC9B,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AA6DD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAynB3F"}
@@ -33,8 +33,23 @@ const net = tslib_1.__importStar(require("node:net"));
33
33
  const fastify_1 = tslib_1.__importDefault(require("fastify"));
34
34
  const reply_from_1 = tslib_1.__importDefault(require("@fastify/reply-from"));
35
35
  const manifest_1 = require("./manifest");
36
+ const gateway_errors_1 = require("./gateway-errors");
36
37
  const eid = () => Date.now().toString(36) + (0, node_crypto_1.randomBytes)(6).toString('hex');
37
38
  const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
39
+ /**
40
+ * Send a gateway-generated edge error as the JSON envelope `{ key, status, traceId, message? }` (NOT
41
+ * `text/plain`), stamping `x-request-id` so the trace id is on the response. The SAME `{ key, status }`
42
+ * localized contract a service returns, so a browser app renders `t(key)` and surfaces the trace id.
43
+ * `detail` is the dev-facing hint (the old plain-text reason), never shown to end users.
44
+ */
45
+ const sendError = (reply, req, status, reason, detail) => {
46
+ const traceId = (0, gateway_errors_1.ensureRequestId)(req);
47
+ return reply
48
+ .code(status)
49
+ .header('x-request-id', traceId)
50
+ .type(gateway_errors_1.GATEWAY_ERROR_CONTENT_TYPE)
51
+ .send((0, gateway_errors_1.gatewayErrorBody)(status, reason, traceId, detail));
52
+ };
38
53
  // Path-canonicalization guard (CWE-288): a reverse proxy and its backend must agree
39
54
  // on the request path, or auth gets evaluated against a DIFFERENT path than the one
40
55
  // served (AWS API Gateway trailing-slash bypass, IBM API Connect CVE-2025-13915,
@@ -104,6 +119,8 @@ async function startProdGateway(opts) {
104
119
  const keyToProject = new Map(Object.entries(opts.serviceKeys ?? {}).map(([p, k]) => [k, p]));
105
120
  const versionHeader = (opts.versionHeader ?? 'x-lensmcp-version').toLowerCase();
106
121
  const accessLog = !!opts.accessLog;
122
+ // Origins allowed to make CREDENTIALED CORS requests (cookies). Exact-matched, lower-cased.
123
+ const corsCredentialOrigins = new Set((opts.corsCredentialOrigins ?? []).map((o) => o.toLowerCase()));
107
124
  const clientIpHeader = opts.clientIpHeader?.toLowerCase();
108
125
  // `clientIpHeader` is only trusted from a known front proxy. Default: loopback
109
126
  // only (a co-located cloudflared/sidecar) — an off-proxy attacker hitting the
@@ -347,10 +364,20 @@ async function startProdGateway(opts) {
347
364
  const spanByReq = new WeakMap(); // OTel span per in-flight request (ended in onResponse)
348
365
  // ---- no-route fallbacks: CORS preflight, health, discovery, 404 ----
349
366
  const setCors = (reply, req) => {
350
- // These are UNAUTHENTICATED gateway responses (404 / health / route discovery /
351
- // preflight). Reflect the Origin for convenience but DO NOT allow credentials
352
- // a reflected origin + credentials would let any site read these cross-origin.
353
- reply.header('access-control-allow-origin', String(req.headers.origin ?? '*'));
367
+ const origin = req.headers.origin;
368
+ // Credentialed allowlist: echo the EXACT origin + allow credentials (cookies). NEVER `*` with
369
+ // credentials (forbidden by the Fetch spec; a cross-site read hole). Needed for a first-party
370
+ // browser app that POSTs to a cookie-setting routed service (e.g. an IdP SSO cookie).
371
+ if (origin && corsCredentialOrigins.has(origin.toLowerCase())) {
372
+ reply.header('access-control-allow-origin', origin);
373
+ reply.header('access-control-allow-credentials', 'true');
374
+ reply.header('vary', 'Origin');
375
+ return;
376
+ }
377
+ // Otherwise — UNAUTHENTICATED gateway responses (404 / health / route discovery / preflight):
378
+ // reflect the Origin for convenience but DO NOT allow credentials — a reflected origin +
379
+ // credentials would let any site read these cross-origin.
380
+ reply.header('access-control-allow-origin', String(origin ?? '*'));
354
381
  reply.header('vary', 'Origin');
355
382
  };
356
383
  const handleNoRoute = (request, reply, host, url) => {
@@ -373,7 +400,7 @@ async function startProdGateway(opts) {
373
400
  reply.code(200).type('application/json').send(JSON.stringify({ host, routes: routes.filter((r) => !r.host || (0, manifest_1.hostMatches)(r.host, host)).map((r) => ({ host: r.host ?? '(default)', service: r.service, prefix: r.prefix, internal: !!r.internal, auth: r.auth })) }));
374
401
  return;
375
402
  }
376
- reply.code(404).type('text/plain; charset=utf-8').send(mounted.length > 0 ? `No service mounted at ${url} on ${host}.` : 'No gateway route for this hostname.');
403
+ sendError(reply, req, 404, 'not_found', mounted.length > 0 ? `No service mounted at ${url} on ${host}.` : 'No gateway route for this hostname.');
377
404
  };
378
405
  // ---- the request handler (catch-all): route → trust → auth → forward ----
379
406
  const handler = async (request, reply) => {
@@ -381,20 +408,21 @@ async function startProdGateway(opts) {
381
408
  const host = (req.headers.host || '').split(':')[0];
382
409
  const url = req.url ?? '/';
383
410
  const trace = beginTrace(req);
411
+ // Mint a trace id for EVERY request (not just lens-traced ones): it's forwarded to the upstream (the
412
+ // pod adopts it as its traceId) and returned on the response — so any request is traceable end-to-end.
413
+ (0, gateway_errors_1.ensureRequestId)(req);
384
414
  step(trace, 'received', { host, method: req.method, url });
385
415
  // Reject HTTP/0.9 — a header-less simple request has no Host, defeating host-keyed routing/policy.
386
416
  if (req.httpVersionMajor < 1) {
387
417
  finish(trace, 'gateway', 400);
388
- reply.code(400).type('text/plain').send('Bad Request');
389
- return reply;
418
+ return sendError(reply, req, 400, 'bad_request', 'Bad Request');
390
419
  }
391
420
  // Reject path-canonicalization attacks BEFORE routing/auth (proxy↔backend mismatch).
392
421
  const cpath = canonicalPath(url);
393
422
  if (cpath === undefined) {
394
423
  step(trace, 'bad-path', {});
395
424
  finish(trace, 'gateway', 400);
396
- reply.code(400).type('text/plain').send('Bad Request');
397
- return reply;
425
+ return sendError(reply, req, 400, 'bad_request', 'Bad Request');
398
426
  }
399
427
  // Rate limit (opt-in) BEFORE routing/auth, per real client IP — defense-in-depth.
400
428
  if (opts.rateLimiter) {
@@ -405,8 +433,7 @@ async function startProdGateway(opts) {
405
433
  reply.header('retry-after', String(Math.ceil(d.retryAfterMs / 1000)));
406
434
  step(trace, 'rate-limited', {});
407
435
  finish(trace, 'gateway', 429);
408
- reply.code(429).type('text/plain').send('Too Many Requests');
409
- return reply;
436
+ return sendError(reply, req, 429, 'rate_limited', 'Too Many Requests');
410
437
  }
411
438
  }
412
439
  const route = (0, manifest_1.matchRoute)(routes, host, url);
@@ -415,6 +442,17 @@ async function startProdGateway(opts) {
415
442
  return reply;
416
443
  }
417
444
  step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
445
+ // CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries no
446
+ // token by spec, so the auth below would 401 it and the browser's CORS check fails. The real request
447
+ // still authenticates. (handleNoRoute covers UNROUTED preflights; this covers routed ones.)
448
+ if (req.method === 'OPTIONS') {
449
+ setCors(reply, req);
450
+ reply.code(204)
451
+ .header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
452
+ .header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
453
+ .send();
454
+ return reply;
455
+ }
418
456
  // OTel SERVER span (opt-in): continues the incoming traceparent. Stored now so
419
457
  // onResponse ends it on ANY exit path (auth reject / 503 / forward). Context is
420
458
  // injected into req.headers just before forwarding (below) so downstream parents to it.
@@ -436,8 +474,7 @@ async function startProdGateway(opts) {
436
474
  if (!caller) {
437
475
  step(trace, 'auth', { mode: 'x-api-key', ok: false });
438
476
  finish(trace, route.service, 401);
439
- reply.code(401).type('text/plain').send('Invalid or missing x-api-key for internal route.');
440
- return reply;
477
+ return sendError(reply, req, 401, 'unauthorized', 'Invalid or missing x-api-key for internal route.');
441
478
  }
442
479
  req.headers['x-api-key-id'] = caller;
443
480
  delete req.headers['x-api-key'];
@@ -451,8 +488,7 @@ async function startProdGateway(opts) {
451
488
  if (!claims) {
452
489
  step(trace, 'auth', { mode: 'jwt', ok: false });
453
490
  finish(trace, route.service, 401);
454
- reply.code(401).type('text/plain').send('Unauthorized: invalid or missing token');
455
- return reply;
491
+ return sendError(reply, req, 401, 'unauthorized', 'Unauthorized: invalid or missing token');
456
492
  }
457
493
  }
458
494
  else {
@@ -462,8 +498,7 @@ async function startProdGateway(opts) {
462
498
  catch (e) {
463
499
  step(trace, 'auth', { mode: 'jwt', ok: false });
464
500
  finish(trace, route.service, 401);
465
- reply.code(401).type('text/plain').send(`Unauthorized: ${e.message}`);
466
- return reply;
501
+ return sendError(reply, req, 401, 'unauthorized', `Unauthorized: ${e.message}`);
467
502
  }
468
503
  }
469
504
  // edge ABAC: the required permission/rule must hold against the VERIFIED claims.
@@ -471,8 +506,7 @@ async function startProdGateway(opts) {
471
506
  if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, request.ip, claims, undefined)))) {
472
507
  step(trace, 'authz', { ok: false });
473
508
  finish(trace, route.service, 403);
474
- reply.code(403).type('text/plain').send('Forbidden: insufficient permission');
475
- return reply;
509
+ return sendError(reply, req, 403, 'forbidden', 'Forbidden: insufficient permission');
476
510
  }
477
511
  step(trace, 'auth', { mode: 'jwt', ok: true });
478
512
  if (claims)
@@ -516,13 +550,11 @@ async function startProdGateway(opts) {
516
550
  const up = await resolveUpstream(route, trace, pinned);
517
551
  if (!up) {
518
552
  finish(trace, route.service, 503);
519
- reply.code(503).type('text/plain').send(`Service ${route.service} has no available upstream.`);
520
- return reply;
553
+ return sendError(reply, req, 503, 'unavailable', `Service ${route.service} has no available upstream.`);
521
554
  }
522
555
  if (!('url' in up)) {
523
556
  finish(trace, route.service, 502);
524
- reply.code(502).type('text/plain').send(`Service ${route.service}: socket upstreams are not supported by the prod gateway.`);
525
- return reply;
557
+ return sendError(reply, req, 502, 'unavailable', `Service ${route.service}: socket upstreams are not supported by the prod gateway.`);
526
558
  }
527
559
  const origin = new URL(up.url).origin;
528
560
  const upLabel = up.url;
@@ -536,6 +568,8 @@ async function startProdGateway(opts) {
536
568
  getUpstream: () => origin,
537
569
  rewriteHeaders: (headers) => {
538
570
  headers['x-lensmcp-upstream'] = upLabel;
571
+ if (!headers['x-request-id'])
572
+ headers['x-request-id'] = (0, gateway_errors_1.ensureRequestId)(req); // trace id on success too (if the upstream didn't echo it)
539
573
  if (up.version)
540
574
  headers['x-lensmcp-version'] = up.version; // which rollout cohort served
541
575
  // The gateway owns the edge response: drop the upstream framework leak
@@ -555,7 +589,7 @@ async function startProdGateway(opts) {
555
589
  onError: (rep, { error }) => {
556
590
  opts.pods?.drop?.(route.service, up);
557
591
  void error; // don't reflect the raw upstream error (host:port / errno) to the client
558
- rep.code(502).type('text/plain').send(`Upstream ${route.service} unavailable.`);
592
+ sendError(rep, req, 502, 'unavailable', `Upstream ${route.service} unavailable.`);
559
593
  },
560
594
  });
561
595
  };
@@ -757,6 +791,28 @@ async function startProdGateway(opts) {
757
791
  reply.type('application/json').send(JSON.stringify(opts.metrics.snapshot()));
758
792
  });
759
793
  app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
794
+ // Edge CORS authority: stamp Access-Control-Allow-Origin on EVERY response that lacks it — including the
795
+ // 401/403/5xx the gateway generates ITSELF (edge auth/authz/proxy errors) which never reach an upstream.
796
+ // Without it the browser sees the error WITHOUT CORS and reports a CORS failure that MASKS the real
797
+ // status (e.g. a 401 the app should handle by re-authing). Add ONLY if absent so a proxied success keeps
798
+ // the upstream's header (no duplicate). No credentials (Bearer APIs; matches setCors).
799
+ app.addHook('onSend', (request, reply, payload, done) => {
800
+ const origin = request.headers.origin;
801
+ if (origin && !reply.getHeader('access-control-allow-origin')) {
802
+ // A credentialed-allowlist origin gets the EXACT origin + ACAC:true (so a gateway-generated
803
+ // 401/403/5xx on a credentialed first-party origin still passes the browser's CORS check);
804
+ // everyone else gets the reflected origin WITHOUT credentials (matches setCors).
805
+ if (corsCredentialOrigins.has(origin.toLowerCase())) {
806
+ reply.header('access-control-allow-origin', origin);
807
+ reply.header('access-control-allow-credentials', 'true');
808
+ }
809
+ else {
810
+ reply.header('access-control-allow-origin', String(origin));
811
+ }
812
+ reply.header('vary', 'Origin');
813
+ }
814
+ done(null, payload);
815
+ });
760
816
  app.addHook('onResponse', (request, reply, done) => {
761
817
  const st = reqState.get(request);
762
818
  if (st) {
package/main.devserver.js CHANGED
@@ -600,7 +600,10 @@ else {
600
600
  }
601
601
  if (route?.target) {
602
602
  applyGatewayMiddleware(req);
603
- proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true }, (err) => {
603
+ // autoRewrite ONLY for a string (external URL) target: a route may target a child socketPath
604
+ // OBJECT, and http-proxy's setRedirectHostRewrite does url.parse(options.target) on ANY 3xx —
605
+ // which throws on an object and crashes the gateway (e.g. proxying the auth IdP's /authorize → login).
606
+ proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true }, (err) => {
604
607
  console.error(`[parent] proxy error to ${route.target}:`, err);
605
608
  res.statusCode = 502;
606
609
  res.setHeader('content-type', 'text/plain; charset=utf-8');
@@ -630,7 +633,12 @@ else {
630
633
  }
631
634
  proxy.web(req, res, {
632
635
  target: { socketPath: targetChild.socketPath },
633
- autoRewrite: true,
636
+ // NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's
637
+ // setRedirectHostRewrite does url.parse(options.target) on ANY 3xx response — which throws
638
+ // ("url must be a string, received Object") and CRASHES the gateway the moment a child
639
+ // returns a redirect (e.g. the auth IdP's /authorize → login). Host-rewrite is meaningless
640
+ // for a socket target anyway (no host to match), and our services emit ABSOLUTE external
641
+ // redirect URLs, so no rewrite is needed. (autoRewrite stays on the string-target route above.)
634
642
  headers: {
635
643
  'x-proxy-child-id': String(targetChild.id) // for logging/debugging
636
644
  }
@@ -722,7 +730,8 @@ else {
722
730
  }
723
731
  proxy.web(req, res, {
724
732
  target: { socketPath: targetChild.socketPath },
725
- autoRewrite: true,
733
+ // NO autoRewrite — a socketPath target crashes http-proxy's setRedirectHostRewrite on a 3xx
734
+ // (url.parse of the target OBJECT). See the public-port handler above.
726
735
  }, (err) => {
727
736
  targetChild.healthy = false;
728
737
  targetChild.lastError = String(err?.stack || err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@lensmcp/node-instrumentation": "1.10.0",
69
- "@lensmcp/nx-plugin": "1.10.0",
68
+ "@lensmcp/node-instrumentation": "1.12.0",
69
+ "@lensmcp/nx-plugin": "1.12.0",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",