@lensmcp/cluster 1.23.1 → 1.23.2
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/README.md
CHANGED
|
@@ -115,7 +115,7 @@ One front door, two transports sharing one routing brain:
|
|
|
115
115
|
- **Routing** — every `cluster` declaration becomes a route; requests match by hostname (exact beats `*.suffix` wildcard) then by most-specific path prefix. A *claimed* host's unmatched root is served by the gateway itself (`/health` aggregate, `/.well-known/...`, CORS + preflights); an *unclaimed* host can only reach the `default` route or 404.
|
|
116
116
|
- **Dev transport** — `node:http` + `http-proxy`. It owns the unix-socket pods: **scale-from-zero** on first request, autoscale while requests overlap, idle-kill, supervised reforks, and ghost-socket sweeping.
|
|
117
117
|
- **Prod transport** — the same `matchRoute` composed over **Fastify + @fastify/reply-from (undici)** (connection-reusing, HTTP/1.1 pipelined; benchmarks materially lower CPU/req than http-proxy), shipped as a **Node 26 distroless** image. No process management — prod pods are always-on (k8s keeps them up).
|
|
118
|
-
- **Edge auth + identity** — for `jwt` routes the gateway verifies the end-user token (dev: HS256 shared secret; prod: RS256 via JWKS) and stamps trusted identity headers (`x-user-id`, `x-tenant-id`, roles, perms) onto the forwarded request; `internal.<host>` routes require the CALLER's own `x-api-key`, and once it is validated the `x-tenant-id` the caller named
|
|
118
|
+
- **Edge auth + identity** — for `jwt` routes the gateway verifies the end-user token (dev: HS256 shared secret; prod: RS256 via JWKS) and stamps trusted identity headers (`x-user-id`, `x-tenant-id`, roles, perms) onto the forwarded request; `internal.<host>` routes require the CALLER's own `x-api-key`, and once it is validated the `x-tenant-id` and `x-user-id` the caller named are forwarded to the callee — production's service-to-service path is direct, so the callee reads them there too; roles, permissions and every other owned header stay the gateway's. Per-path **ABAC** rules (a `permission` or full predicate over verified claims + IP/headers, scoped by HTTP method) are enforced at the edge → 403.
|
|
119
119
|
- **Rollout** — per-request upstream selection supports **weighted** cohorts (smooth nginx-style split for canary / blue-green) and **attribute-based targeting** (route by user / tenant / IP / header / sticky device key, consistent-hashed so a ramp moves a key across the boundary at most once). The serving version rides on `x-lensmcp-version` and the trace.
|
|
120
120
|
|
|
121
121
|
## How it fits
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var y=Object.defineProperty;var
|
|
1
|
+
"use strict";var y=Object.defineProperty;var f=(n,u)=>y(n,"name",{value:u,configurable:!0});var h=Object.defineProperty,d=f((n,u)=>h(n,"name",{value:u,configurable:!0}),"d");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createEdgeAuth=createEdgeAuth;const manifest_1=require("../manifest"),dev_auth_1=require("./dev-auth"),edge_1=require("./edge");function createEdgeAuth(n,u,m){const{traceStep:o}=u;return{internal:d((e,k,r)=>{const c=String(e.headers["x-api-key"]??""),a=n.keyToProject.get(c);if(!a)return o(r,"auth",{mode:"x-api-key",ok:!1}),{ok:!1,status:401,reason:"unauthorized",detail:"Invalid or missing x-api-key for internal route."};const t=n.keyToWorkspace?.get(c),l=k.svc?.wsKey;if(t&&l&&t!==l)return o(r,"auth",{mode:"x-api-key",ok:!1,reason:"cross-workspace",caller:a,callerWorkspace:t,routeWorkspace:l}),{ok:!1,status:401,reason:"unauthorized",detail:"Invalid or missing x-api-key for internal route."};e.headers["x-api-key-id"]=a,t?e.headers["x-api-key-workspace"]=t:delete e.headers["x-api-key-workspace"],e.__lensmcpCaller=a,delete e.headers["x-api-key"];const i=(0,edge_1.callerContextOf)(e);for(const[p,s]of Object.entries(i))e.headers[p]=s;return o(r,"auth",{mode:"x-api-key",ok:!0,caller:a,...t?{callerWorkspace:t}:{},...i["x-tenant-id"]!==void 0?{callerTenant:!0}:{},...i["x-user-id"]!==void 0?{callerUser:!0}:{}}),{ok:!0,caller:a}},"internal"),public:d((e,k,r,c)=>{const a=k.auth;if(a){const t=c??(0,edge_1.canonicalPath)(e.url??"/")??(e.url??"/").split("?")[0],l=(e.method??"GET").toUpperCase(),i=(0,manifest_1.authRuleFor)(a,t,l);if(i.mode==="jwt"){const p=String(e.headers.host??"").split(":")[0].toLowerCase(),s=(0,dev_auth_1.verifyDevJwt)(e,m?.for(p));return s?i.rule&&!(0,manifest_1.evalRule)(i.rule,(0,dev_auth_1.devAttrs)(e,s,t))?(o(r,"authz",{ok:!1}),{ok:!1,status:403,reason:"forbidden",detail:"Forbidden: insufficient permission"}):((0,dev_auth_1.stampIdentity)(e,s),o(r,"auth",{mode:"jwt",ok:!0}),{ok:!0,claims:s}):(o(r,"auth",{mode:"jwt",ok:!1}),{ok:!1,status:401,reason:"unauthorized",detail:"Unauthorized: invalid or missing token"})}return o(r,"auth",{mode:i.mode}),{ok:!0}}return o(r,"auth",{mode:"public",unconfigured:!0}),{ok:!0}},"publicAuth")}}f(createEdgeAuth,"createEdgeAuth"),d(createEdgeAuth,"createEdgeAuth");
|
|
@@ -37,25 +37,35 @@ export declare const sendError: (res: http.ServerResponse, req: http.IncomingMes
|
|
|
37
37
|
*/
|
|
38
38
|
export declare const GATEWAY_OWNED_HEADERS: string[];
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* The CONTEXT a calling service names on an east-west call — the tenant it works in and the user it acts
|
|
41
|
+
* for. Never authority: `x-user-roles` / `x-user-permissions` are not in this list and never come back.
|
|
42
42
|
*/
|
|
43
|
-
export declare const
|
|
43
|
+
export declare const CALLER_CONTEXT_HEADERS: readonly ["x-tenant-id", "x-user-id"];
|
|
44
|
+
/** One of {@link CALLER_CONTEXT_HEADERS}. */
|
|
45
|
+
export type CallerContextHeader = (typeof CALLER_CONTEXT_HEADERS)[number];
|
|
46
|
+
/** The caller context {@link stripOwnedHeaders} set aside — only the headers that held a usable value. */
|
|
47
|
+
export type CallerContext = Readonly<Partial<Record<CallerContextHeader, string>>>;
|
|
48
|
+
/**
|
|
49
|
+
* Where {@link stripOwnedHeaders} sets the caller context aside. Read back ONLY by the internal-route
|
|
50
|
+
* auth, once the calling service's own key has been validated ({@link callerContextOf}).
|
|
51
|
+
*/
|
|
52
|
+
export declare const CALLER_CONTEXT: unique symbol;
|
|
44
53
|
/**
|
|
45
54
|
* Strip every gateway-owned header at ingress — the HTTP handler and the WS upgrade both call this —
|
|
46
|
-
* keeping the caller
|
|
55
|
+
* keeping the caller context ({@link CALLER_CONTEXT_HEADERS}) aside on the request.
|
|
47
56
|
*
|
|
48
|
-
* Why
|
|
57
|
+
* Why those two headers survive for an internal route (and nothing else does): service-to-service traffic
|
|
49
58
|
* goes DIRECT in production — the caller's pod calls the callee's cluster service, with no gateway in
|
|
50
|
-
* between — so the callee receives the tenant its caller named and authorizes the call
|
|
51
|
-
* per-purpose secret, for one). Deleting
|
|
52
|
-
* accepts, and pushed services onto each other's pod ports to get around the gateway.
|
|
53
|
-
* after the caller has proven which service it is
|
|
54
|
-
* route's
|
|
59
|
+
* between — so the callee receives the tenant and the user its caller named, and authorizes the call
|
|
60
|
+
* itself (a per-purpose secret, for one). Deleting them on the dev `internal.<host>` made dev refuse calls
|
|
61
|
+
* prod accepts, and pushed services onto each other's pod ports to get around the gateway. They come back
|
|
62
|
+
* only after the caller has proven which service it is. Roles and permissions never do: a service names
|
|
63
|
+
* whom it acts for, never what that person may do. And a PUBLIC route's identity is only ever the verified
|
|
64
|
+
* token's.
|
|
55
65
|
*/
|
|
56
66
|
export declare function stripOwnedHeaders(req: http.IncomingMessage): void;
|
|
57
|
-
/** The
|
|
58
|
-
export declare const
|
|
67
|
+
/** The context the caller named — empty when it sent none, or only values not worth handing back. */
|
|
68
|
+
export declare const callerContextOf: (req: http.IncomingMessage) => CallerContext;
|
|
59
69
|
/**
|
|
60
70
|
* Is this `Origin` one of the hosts THIS gateway routes?
|
|
61
71
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var d=Object.defineProperty;var
|
|
1
|
+
"use strict";var d=Object.defineProperty;var c=(e,r)=>d(e,"name",{value:r,configurable:!0});var p=Object.defineProperty,o=c((e,r)=>p(e,"name",{value:r,configurable:!0}),"o");Object.defineProperty(exports,"__esModule",{value:!0}),exports.PREFLIGHT_METHODS_ROUTED=exports.PREFLIGHT_METHODS_NOROUTE=exports.corsPreflight=exports.cors=exports.isRoutedOrigin=exports.callerContextOf=exports.CALLER_CONTEXT=exports.CALLER_CONTEXT_HEADERS=exports.GATEWAY_OWNED_HEADERS=exports.sendError=exports.stampForwardingHeaders=exports.clientIpOf=exports.resolveClientIp=exports.CLIENT_IP=exports.rememberRawTarget=exports.rawTargetOf=exports.RAW_TARGET=exports.canonicalPath=exports.canonicalize=exports.ENCODED_STRUCTURE=void 0,exports.stripOwnedHeaders=stripOwnedHeaders;const gateway_errors_1=require("../gateway-errors"),client_ip_1=require("../client-ip"),manifest_1=require("../manifest");var canonical_path_1=require("../canonical-path");Object.defineProperty(exports,"ENCODED_STRUCTURE",{enumerable:!0,get:o(function(){return canonical_path_1.ENCODED_STRUCTURE},"get")}),Object.defineProperty(exports,"canonicalize",{enumerable:!0,get:o(function(){return canonical_path_1.canonicalize},"get")}),Object.defineProperty(exports,"canonicalPath",{enumerable:!0,get:o(function(){return canonical_path_1.canonicalPath},"get")}),Object.defineProperty(exports,"RAW_TARGET",{enumerable:!0,get:o(function(){return canonical_path_1.RAW_TARGET},"get")}),Object.defineProperty(exports,"rawTargetOf",{enumerable:!0,get:o(function(){return canonical_path_1.rawTargetOf},"get")}),Object.defineProperty(exports,"rememberRawTarget",{enumerable:!0,get:o(function(){return canonical_path_1.rememberRawTarget},"get")}),exports.CLIENT_IP=Symbol.for("lensmcp.gateway.clientIp");const FORWARDED=Symbol.for("lensmcp.gateway.forwarded"),peerOnly=(0,client_ip_1.createClientIpResolver)({}),resolverOf=o(e=>e.clientIp??peerOnly,"resolverOf"),peerOf=o(e=>e.socket?.remoteAddress??"","peerOf"),resolveClientIp=o((e,r)=>{const t=resolverOf(e).clientIpOf(r,peerOf(r));return r[exports.CLIENT_IP]=t,t},"resolveClientIp");exports.resolveClientIp=resolveClientIp;const clientIpOf=o(e=>e[exports.CLIENT_IP]??peerOf(e),"clientIpOf");exports.clientIpOf=clientIpOf;const stampForwardingHeaders=o((e,r)=>{const t=r;if(t[FORWARDED])return;t[FORWARDED]=!0;const s=peerOf(r);r.headers["x-forwarded-for"]=resolverOf(e).forwardedFor(r,s),r.headers["x-forwarded-proto"]=e.https?"https":"http";const n=r.headers.host;(resolverOf(e).trustProxyHops===0||!r.headers["x-forwarded-host"])&&(n?r.headers["x-forwarded-host"]=n:delete r.headers["x-forwarded-host"])},"stampForwardingHeaders");exports.stampForwardingHeaders=stampForwardingHeaders;const sendError=o((e,r,t,s,n,i,l)=>{const a=(0,gateway_errors_1.ensureRequestId)(r);return e.headersSent||e.writeHead(t,{"content-type":gateway_errors_1.GATEWAY_ERROR_CONTENT_TYPE,"x-request-id":a,...l??{}}),e.end((0,gateway_errors_1.gatewayErrorBody)(t,s,a,n,i)),a},"sendError");exports.sendError=sendError,exports.GATEWAY_OWNED_HEADERS=["x-internal-token","x-api-key-id","x-api-key-workspace","x-user-id","x-tenant-id","x-user-roles","x-user-permissions"],exports.CALLER_CONTEXT_HEADERS=["x-tenant-id","x-user-id"],exports.CALLER_CONTEXT=Symbol.for("lensmcp.gateway.callerContext");const CALLER_CONTEXT_VALUE=/^[\x21-\x2b\x2d-\x7e]{1,128}$/;function stripOwnedHeaders(e){const r={};for(const t of exports.CALLER_CONTEXT_HEADERS){const s=e.headers[t];typeof s=="string"&&CALLER_CONTEXT_VALUE.test(s)&&(r[t]=s)}e[exports.CALLER_CONTEXT]=r;for(const t of exports.GATEWAY_OWNED_HEADERS)delete e.headers[t]}c(stripOwnedHeaders,"stripOwnedHeaders"),o(stripOwnedHeaders,"stripOwnedHeaders");const callerContextOf=o(e=>e[exports.CALLER_CONTEXT]??{},"callerContextOf");exports.callerContextOf=callerContextOf;const isRoutedOrigin=o((e,r)=>{if(!e||e==="null")return!1;let t;try{t=new URL(e).hostname.toLowerCase()}catch{return!1}return t?t==="localhost"||t==="127.0.0.1"||t==="::1"?!0:r.some(s=>!!s.host&&(0,manifest_1.hostMatches)(s.host,t)):!1},"isRoutedOrigin");exports.isRoutedOrigin=isRoutedOrigin;const cors=o((e,r,t)=>{const s=e.headers.origin;typeof s!="string"||s===""||(r.setHeader("vary","Origin"),(0,exports.isRoutedOrigin)(s,t)&&(r.setHeader("access-control-allow-origin",s),r.setHeader("access-control-allow-credentials","true")))},"cors");exports.cors=cors;const corsPreflight=o((e,r,t,s)=>{(0,exports.cors)(e,r,s),r.writeHead(204,{"access-control-allow-methods":t,"access-control-allow-headers":String(e.headers["access-control-request-headers"]??"*")}),r.end()},"corsPreflight");exports.corsPreflight=corsPreflight,exports.PREFLIGHT_METHODS_NOROUTE="GET,HEAD,OPTIONS",exports.PREFLIGHT_METHODS_ROUTED="GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/schema",
|
|
3
3
|
"version": 2,
|
|
4
4
|
"title": "Gateway executor",
|
|
5
|
-
"description": "Workspace https front door. Reads every project's top-level `davnx` declaration ({ host, port|upstream, prependPrefix?, default? }), builds the hostname route table, serves a local-CA TLS cert with SANs for every host, and proxies requests + websocket upgrades. The optional middleware hook (JWT etc.) runs on every public request. Service-to-service traffic goes through each service's internal.<host>: the caller presents its own x-api-key, the middleware is skipped, and the x-tenant-id the caller names
|
|
5
|
+
"description": "Workspace https front door. Reads every project's top-level `davnx` declaration ({ host, port|upstream, prependPrefix?, default? }), builds the hostname route table, serves a local-CA TLS cert with SANs for every host, and proxies requests + websocket upgrades. The optional middleware hook (JWT etc.) runs on every public request. Service-to-service traffic goes through each service's internal.<host>: the caller presents its own x-api-key, the middleware is skipped, and the x-tenant-id and x-user-id the caller names reach the callee (as they do over production's direct service-to-service path; roles and permissions never do). A service's internalPort is a loopback diagnostics listener, not the service-to-service path.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
8
8
|
"ports": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/cluster",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.2",
|
|
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",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@lensmcp/core": "1.23.
|
|
61
|
-
"@lensmcp/node-instrumentation": "1.23.
|
|
62
|
-
"@lensmcp/nx-plugin": "1.23.
|
|
60
|
+
"@lensmcp/core": "1.23.2",
|
|
61
|
+
"@lensmcp/node-instrumentation": "1.23.2",
|
|
62
|
+
"@lensmcp/nx-plugin": "1.23.2",
|
|
63
63
|
"fastify": "^5.8.5",
|
|
64
64
|
"@fastify/reply-from": "^12.6.2",
|
|
65
65
|
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
|