@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.
- package/executors/gateway/gateway-errors.d.ts +36 -0
- package/executors/gateway/gateway-errors.d.ts.map +1 -0
- package/executors/gateway/gateway-errors.js +43 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +76 -19
- package/executors/gateway/prod-gateway.lib.d.ts +8 -0
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +80 -24
- package/main.devserver.js +12 -3
- package/package.json +3 -3
|
@@ -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":"AAAA;;;;GAIG;AACH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAG9D,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAGlC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAqE,KAAK,UAAU,EAAc,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAG9D,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAGlC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAqE,KAAK,UAAU,EAAc,MAAM,YAAY,CAAC;AAS5H,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAsBzC;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAiC3F;AAED,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAQ9F;AASD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;CACzE;AAED,2FAA2F;AAC3F,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sGAAsG;IACtG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC9B;;iGAE6F;IAC7F,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2FAA2F;IAC3F,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AAE1F,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;gEAC4D;IAC5D,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AAED;;;yBAGyB;AACzB,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAC;IACpB,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,kBAAkB,CAAC;IACzB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,2DAA2D;AAC3D,MAAM,WAAW,qBAAsB,SAAQ,qBAAqB;IAClE,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAWD;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAED;;;+EAG+E;AAC/E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAmBpG;AAuBD,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,SAAO,GAAG,MAAM,GAAG,SAAS,CAa7E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAC1C,WAAW,SAAc,GACxB;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,QAAQ,EAAE,UAAU,EAAE,CAAA;CAAE,CAyD7C;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAguBxB"}
|
|
@@ -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
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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":"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;
|
|
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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"}
|
|
@@ -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
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
69
|
-
"@lensmcp/nx-plugin": "1.
|
|
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",
|