@lensmcp/cluster 1.0.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/LICENSE +202 -0
- package/README.md +130 -0
- package/basic-ssl.d.ts +7 -0
- package/basic-ssl.d.ts.map +1 -0
- package/basic-ssl.js +158 -0
- package/build-scope-patterns.d.ts +12 -0
- package/build-scope-patterns.d.ts.map +1 -0
- package/build-scope-patterns.js +40 -0
- package/create-webpack-dev.d.ts +27 -0
- package/create-webpack-dev.d.ts.map +1 -0
- package/create-webpack-dev.js +151 -0
- package/create-webpack-prod.d.ts +28 -0
- package/create-webpack-prod.d.ts.map +1 -0
- package/create-webpack-prod.js +169 -0
- package/executors/build/build.impl.d.ts +19 -0
- package/executors/build/build.impl.d.ts.map +1 -0
- package/executors/build/build.impl.js +98 -0
- package/executors/build/schema.d.ts +35 -0
- package/executors/build/schema.json +135 -0
- package/executors/gateway/gateway.impl.d.ts +23 -0
- package/executors/gateway/gateway.impl.d.ts.map +1 -0
- package/executors/gateway/gateway.impl.js +39 -0
- package/executors/gateway/gateway.lib.d.ts +130 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -0
- package/executors/gateway/gateway.lib.js +797 -0
- package/executors/gateway/jwks-verify.d.ts +28 -0
- package/executors/gateway/jwks-verify.d.ts.map +1 -0
- package/executors/gateway/jwks-verify.js +121 -0
- package/executors/gateway/main.prod-gateway.d.ts +2 -0
- package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
- package/executors/gateway/main.prod-gateway.js +290 -0
- package/executors/gateway/main.rollout.d.ts +2 -0
- package/executors/gateway/main.rollout.d.ts.map +1 -0
- package/executors/gateway/main.rollout.js +130 -0
- package/executors/gateway/manifest.d.ts +275 -0
- package/executors/gateway/manifest.d.ts.map +1 -0
- package/executors/gateway/manifest.js +344 -0
- package/executors/gateway/prod-gateway.lib.d.ts +58 -0
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
- package/executors/gateway/prod-gateway.lib.js +535 -0
- package/executors/gateway/providers-prod.d.ts +46 -0
- package/executors/gateway/providers-prod.d.ts.map +1 -0
- package/executors/gateway/providers-prod.js +199 -0
- package/executors/gateway/registry-source.d.ts +68 -0
- package/executors/gateway/registry-source.d.ts.map +1 -0
- package/executors/gateway/registry-source.js +131 -0
- package/executors/gateway/rollout-ops.d.ts +54 -0
- package/executors/gateway/rollout-ops.d.ts.map +1 -0
- package/executors/gateway/rollout-ops.js +167 -0
- package/executors/gateway/schema.d.ts +10 -0
- package/executors/gateway/schema.json +30 -0
- package/executors/serve/schema.d.ts +53 -0
- package/executors/serve/schema.json +196 -0
- package/executors/serve/serve.impl.d.ts +25 -0
- package/executors/serve/serve.impl.d.ts.map +1 -0
- package/executors/serve/serve.impl.js +243 -0
- package/executors/trust/schema.d.ts +6 -0
- package/executors/trust/schema.json +20 -0
- package/executors/trust/trust.impl.d.ts +42 -0
- package/executors/trust/trust.impl.d.ts.map +1 -0
- package/executors/trust/trust.impl.js +126 -0
- package/executors.json +24 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/main.devserver.d.ts +16 -0
- package/main.devserver.d.ts.map +1 -0
- package/main.devserver.js +812 -0
- package/package.json +66 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface JwksVerifierOptions {
|
|
2
|
+
/** The IdP JWKS endpoint, e.g. `https://issuer/.well-known/jwks.json`. */
|
|
3
|
+
jwksUrl: string;
|
|
4
|
+
/** Expected `iss` claim (optional). */
|
|
5
|
+
issuer?: string;
|
|
6
|
+
/** Expected `aud` claim — string or any-of list (optional). */
|
|
7
|
+
audience?: string | string[];
|
|
8
|
+
/** Accepted JWS algorithms. Default `['RS256']`. RSA family only. */
|
|
9
|
+
algorithms?: string[];
|
|
10
|
+
/** Periodic JWKS refresh interval (ms). Default 600000 (10 min). */
|
|
11
|
+
cacheMaxAgeMs?: number;
|
|
12
|
+
/** Min interval between on-miss (unknown-kid) refreshes (ms). Default 30000. */
|
|
13
|
+
missRefreshMs?: number;
|
|
14
|
+
/** Clock skew tolerance for exp/nbf (seconds). Default 60. */
|
|
15
|
+
clockToleranceSec?: number;
|
|
16
|
+
/** Injectable fetch (tests). Defaults to global fetch. */
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
export interface JwtVerifier {
|
|
20
|
+
/** Verify a compact JWS; returns the claims, or undefined if invalid/unknown. Sync. */
|
|
21
|
+
verify(token: string): Record<string, unknown> | undefined;
|
|
22
|
+
/** Force a JWKS reload (awaited at startup; safe to call any time). */
|
|
23
|
+
refresh(): Promise<void>;
|
|
24
|
+
/** Stop the periodic refresh timer. */
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
export declare function createJwksVerifier(opts: JwksVerifierOptions): JwtVerifier;
|
|
28
|
+
//# sourceMappingURL=jwks-verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwks-verify.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/jwks-verify.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,mBAAmB;IAClC,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gFAAgF;IAChF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,uFAAuF;IACvF,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC3D,uEAAuE;IACvE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,uCAAuC;IACvC,IAAI,IAAI,IAAI,CAAC;CACd;AAQD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,mBAAmB,GAAG,WAAW,CAkEzE"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createJwksVerifier = createJwksVerifier;
|
|
4
|
+
/**
|
|
5
|
+
* RS256/JWKS JWT verifier — the production identity source for the prod
|
|
6
|
+
* gateway's attribute-based routing (`identify`). Fetches the IdP's JWKS,
|
|
7
|
+
* caches public keys by `kid`, and verifies tokens SYNCHRONOUSLY against that
|
|
8
|
+
* cache so it slots into the gateway's per-request `identify(req)` and rule
|
|
9
|
+
* evaluation without making the hot path async.
|
|
10
|
+
*
|
|
11
|
+
* Freshness without blocking the request:
|
|
12
|
+
* - initial JWKS load is awaited at startup (`refresh()`),
|
|
13
|
+
* - a periodic refresh keeps keys current (rotation),
|
|
14
|
+
* - an unknown `kid` triggers a rate-limited background refresh and the
|
|
15
|
+
* CURRENT request fails closed (undefined) — the token verifies on a later
|
|
16
|
+
* request once the new key is cached.
|
|
17
|
+
*
|
|
18
|
+
* Security: only the configured algorithms are accepted (default RS256) — `none`
|
|
19
|
+
* and HS* are rejected (alg-confusion guard); `exp`/`nbf` honoured with a small
|
|
20
|
+
* clock tolerance; `iss`/`aud` checked when configured.
|
|
21
|
+
*
|
|
22
|
+
* Scope: RSA families (RS256/384/512). EC (ES*) needs JOSE→DER signature
|
|
23
|
+
* conversion — a documented follow-up; most IdPs default to RS256.
|
|
24
|
+
*/
|
|
25
|
+
const node_crypto_1 = require("node:crypto");
|
|
26
|
+
const ALG_TO_HASH = { RS256: 'sha256', RS384: 'sha384', RS512: 'sha512' };
|
|
27
|
+
const b64urlJsonSafe = (s) => JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));
|
|
28
|
+
function createJwksVerifier(opts) {
|
|
29
|
+
const algorithms = (opts.algorithms ?? ['RS256']).filter((a) => a in ALG_TO_HASH);
|
|
30
|
+
const cacheMaxAgeMs = opts.cacheMaxAgeMs ?? 600_000;
|
|
31
|
+
const missRefreshMs = opts.missRefreshMs ?? 30_000;
|
|
32
|
+
const tol = opts.clockToleranceSec ?? 60;
|
|
33
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
34
|
+
const audiences = opts.audience === undefined ? undefined : (Array.isArray(opts.audience) ? opts.audience : [opts.audience]);
|
|
35
|
+
let keys = new Map(); // kid → public key
|
|
36
|
+
let inflight;
|
|
37
|
+
let lastMissAt = 0;
|
|
38
|
+
const load = async () => {
|
|
39
|
+
const res = await doFetch(opts.jwksUrl, { headers: { accept: 'application/json' } });
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`JWKS ${opts.jwksUrl} → HTTP ${res.status}`);
|
|
42
|
+
const body = (await res.json());
|
|
43
|
+
const next = new Map();
|
|
44
|
+
for (const jwk of body.keys ?? []) {
|
|
45
|
+
if (jwk.kty !== 'RSA' || !jwk.n || !jwk.e)
|
|
46
|
+
continue;
|
|
47
|
+
if (jwk.use && jwk.use !== 'sig')
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const key = (0, node_crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
|
|
51
|
+
next.set(jwk.kid ?? '__single__', key);
|
|
52
|
+
}
|
|
53
|
+
catch { /* skip an unparseable key, keep the rest */ }
|
|
54
|
+
}
|
|
55
|
+
if (next.size > 0)
|
|
56
|
+
keys = next; // never blank out a working set on a bad fetch
|
|
57
|
+
};
|
|
58
|
+
const refresh = () => {
|
|
59
|
+
if (!inflight)
|
|
60
|
+
inflight = load().finally(() => { inflight = undefined; });
|
|
61
|
+
return inflight;
|
|
62
|
+
};
|
|
63
|
+
const refreshOnMiss = (nowMs) => {
|
|
64
|
+
if (nowMs - lastMissAt < missRefreshMs)
|
|
65
|
+
return;
|
|
66
|
+
lastMissAt = nowMs;
|
|
67
|
+
void refresh().catch(() => undefined); // background; this request already failed closed
|
|
68
|
+
};
|
|
69
|
+
const timer = setInterval(() => void refresh().catch(() => undefined), cacheMaxAgeMs);
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
const verify = (token) => {
|
|
72
|
+
const parts = token.split('.');
|
|
73
|
+
if (parts.length !== 3)
|
|
74
|
+
return undefined;
|
|
75
|
+
let header;
|
|
76
|
+
try {
|
|
77
|
+
header = b64urlJsonSafe(parts[0]);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (!header.alg || !algorithms.includes(header.alg))
|
|
83
|
+
return undefined; // reject none/HS*/unconfigured
|
|
84
|
+
const key = keys.get(header.kid ?? '__single__') ?? (keys.size === 1 ? [...keys.values()][0] : undefined);
|
|
85
|
+
if (!key) {
|
|
86
|
+
refreshOnMiss(Date.now());
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
let ok;
|
|
90
|
+
try {
|
|
91
|
+
ok = (0, node_crypto_1.verify)(ALG_TO_HASH[header.alg], Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
if (!ok)
|
|
97
|
+
return undefined;
|
|
98
|
+
let payload;
|
|
99
|
+
try {
|
|
100
|
+
payload = b64urlJsonSafe(parts[1]);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const now = Date.now() / 1000;
|
|
106
|
+
if (typeof payload.exp === 'number' && now > payload.exp + tol)
|
|
107
|
+
return undefined;
|
|
108
|
+
if (typeof payload.nbf === 'number' && now < payload.nbf - tol)
|
|
109
|
+
return undefined;
|
|
110
|
+
if (opts.issuer && payload.iss !== opts.issuer)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (audiences) {
|
|
113
|
+
const aud = payload.aud;
|
|
114
|
+
const audList = Array.isArray(aud) ? aud : [aud];
|
|
115
|
+
if (!audList.some((a) => audiences.includes(a)))
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
return payload;
|
|
119
|
+
};
|
|
120
|
+
return { verify, refresh, stop: () => clearInterval(timer) };
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.prod-gateway.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/main.prod-gateway.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Production gateway process entry — the thing the Docker image runs. Reads
|
|
5
|
+
* its whole configuration from the environment (12-factor) and composes
|
|
6
|
+
* `startProdGateway` with the file-backed manifest provider + endpoint pod
|
|
7
|
+
* provider. Stateless and replica-safe.
|
|
8
|
+
*
|
|
9
|
+
* Env:
|
|
10
|
+
* LENSMCP_GW_PORTS csv of ports (default "8080", or "8443" with TLS)
|
|
11
|
+
* LENSMCP_GW_MANIFESTS path to a manifests JSON file (array or {manifests:[]})
|
|
12
|
+
* LENSMCP_GW_ENDPOINTS path to an endpoints JSON for gateway-side LB / rollout
|
|
13
|
+
* (only needed for services whose manifest has no `upstream`).
|
|
14
|
+
* Flat: {service:[url,...]}
|
|
15
|
+
* Rollout: {service:[{version,weight,urls:[url,...]}]} ← weighted
|
|
16
|
+
* split across version cohorts; file is watched so weight
|
|
17
|
+
* changes ramp/rollback traffic LIVE (no redeploy).
|
|
18
|
+
* LENSMCP_GW_KEYS path to a {service: x-internal-token} JSON (the trust registry)
|
|
19
|
+
* LENSMCP_GW_TLS_KEY path to TLS private key PEM (enables https)
|
|
20
|
+
* LENSMCP_GW_TLS_CERT path to TLS cert PEM
|
|
21
|
+
* LENSMCP_GW_JWT_REQUIRED "1" → jwt-mode routes require an `Authorization: Bearer …`
|
|
22
|
+
* (minimal edge guard; swap in a full JWKS validator here)
|
|
23
|
+
* LENSMCP_EVENT_FILE set → enable the lens bus (OPT-IN; off by default)
|
|
24
|
+
*
|
|
25
|
+
* Targeted rollout (attribute-based routing; docs/design/targeted-rollout.md):
|
|
26
|
+
* LENSMCP_GW_ENDPOINTS also accepts the targeted shape per service:
|
|
27
|
+
* {service:{rules:[{name,when,version}],cohorts:[…]}}
|
|
28
|
+
* LENSMCP_GW_JWT_SECRET HS256 secret → identify() exposes VERIFIED claims as
|
|
29
|
+
* rule attributes (sub/email/tenant/roles…). RS256/JWKS = prod upgrade.
|
|
30
|
+
* LENSMCP_GW_ATTR_HEADERS csv of request headers exposed to rules as `header.<name>`
|
|
31
|
+
* LENSMCP_GW_TRUST_PROXY "1" → take the `ip` attribute from X-Forwarded-For (behind an LB)
|
|
32
|
+
* LENSMCP_GW_UID_HEADER header carrying a device/user id — sticky key when no cookie
|
|
33
|
+
* LENSMCP_GW_COOKIE_SECRET set → enable the HMAC-signed device cookie (sticky bucketing)
|
|
34
|
+
* LENSMCP_GW_COOKIE_NAME device cookie name (default "lensmcp_did")
|
|
35
|
+
* LENSMCP_GW_COOKIE_DOMAIN cookie Domain (e.g. ".tetros.ai" to span subdomains)
|
|
36
|
+
* LENSMCP_GW_COOKIE_MAXAGE cookie Max-Age seconds (default 31536000)
|
|
37
|
+
*
|
|
38
|
+
* Manifests + endpoints + keys are FILE-backed here; a registry-backed
|
|
39
|
+
* ManifestProvider (Redis/ConfigMap) drops in behind the same interface.
|
|
40
|
+
*/
|
|
41
|
+
const node_fs_1 = require("node:fs");
|
|
42
|
+
const node_crypto_1 = require("node:crypto");
|
|
43
|
+
const node_path_1 = require("node:path");
|
|
44
|
+
const prod_gateway_lib_1 = require("./prod-gateway.lib");
|
|
45
|
+
const jwks_verify_1 = require("./jwks-verify");
|
|
46
|
+
const registry_source_1 = require("./registry-source");
|
|
47
|
+
const providers_prod_1 = require("./providers-prod");
|
|
48
|
+
function readJson(path, fallback) {
|
|
49
|
+
if (!path)
|
|
50
|
+
return fallback;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
console.error(`[gateway] could not read ${path}:`, e.message);
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Endpoints file is either flat `{svc:[url]}` or rollout `{svc:[{version,weight,urls}]}`. */
|
|
60
|
+
function isCohortShape(eps) {
|
|
61
|
+
const first = Object.values(eps)[0];
|
|
62
|
+
return Array.isArray(first) && first.length > 0 && typeof first[0] === 'object' && first[0] !== null && 'urls' in first[0];
|
|
63
|
+
}
|
|
64
|
+
/** Poll a JSON file and invoke `onChange` with its parsed contents whenever it
|
|
65
|
+
* changes — drives LIVE rollout ramps (edit weights → traffic shifts, no
|
|
66
|
+
* redeploy). Returns a stop fn. */
|
|
67
|
+
function watchJsonFile(path, onChange, pollMs = 1000) {
|
|
68
|
+
let mtime = -1;
|
|
69
|
+
const tick = () => {
|
|
70
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
71
|
+
return;
|
|
72
|
+
let m;
|
|
73
|
+
try {
|
|
74
|
+
m = (0, node_fs_1.statSync)(path).mtimeMs;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (m === mtime)
|
|
80
|
+
return;
|
|
81
|
+
let data;
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
console.error('[gateway] endpoints reload:', e.message);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
mtime = m;
|
|
90
|
+
onChange(data);
|
|
91
|
+
};
|
|
92
|
+
const timer = setInterval(tick, pollMs);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
return () => clearInterval(timer);
|
|
95
|
+
}
|
|
96
|
+
async function main() {
|
|
97
|
+
const env = process.env;
|
|
98
|
+
const ports = (env['LENSMCP_GW_PORTS'] ?? '').split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
99
|
+
// Process-level safety net: a single uncaught async error must not kill the
|
|
100
|
+
// pod and 503 every in-flight request — log and keep serving.
|
|
101
|
+
process.on('uncaughtException', (err) => console.error('[gateway] uncaughtException:', err));
|
|
102
|
+
process.on('unhandledRejection', (reason) => console.error('[gateway] unhandledRejection:', reason));
|
|
103
|
+
// Config source: a Redis REGISTRY (zero-touch auto-sync — a deploy / the
|
|
104
|
+
// `lensmcp rollout --redis` helper writes keys + publishes, gateway re-reads
|
|
105
|
+
// live) takes precedence; otherwise file-backed manifests + endpoints (watched).
|
|
106
|
+
const redisUrl = env['LENSMCP_GW_REDIS_URL'];
|
|
107
|
+
let manifests;
|
|
108
|
+
let pods;
|
|
109
|
+
let stopEndpointsWatch;
|
|
110
|
+
let registry;
|
|
111
|
+
if (redisUrl) {
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
113
|
+
const Redis = require('ioredis');
|
|
114
|
+
const client = new Redis(redisUrl);
|
|
115
|
+
const reg = (0, registry_source_1.createRegistryProviders)((0, registry_source_1.redisRegistrySource)(client, env['LENSMCP_GW_REDIS_PREFIX'] ? { prefix: env['LENSMCP_GW_REDIS_PREFIX'] } : {}), { onError: (e) => console.error('[gateway] registry:', e instanceof Error ? e.message : e) });
|
|
116
|
+
await reg.start(); // warm the route table before serving
|
|
117
|
+
manifests = reg.manifests;
|
|
118
|
+
pods = reg.pods;
|
|
119
|
+
registry = reg;
|
|
120
|
+
console.log(`[gateway] registry: Redis-backed, live (${manifests.list().length} service(s))`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const manifestFile = env['LENSMCP_GW_MANIFESTS'];
|
|
124
|
+
if (manifestFile) {
|
|
125
|
+
manifests = (0, providers_prod_1.fileManifestProvider)(manifestFile, { onError: (e) => console.error('[gateway] manifest:', e instanceof Error ? e.message : e) });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.warn('[gateway] no LENSMCP_GW_MANIFESTS / LENSMCP_GW_REDIS_URL — starting with an EMPTY route table (all requests 404 until manifests arrive).');
|
|
129
|
+
manifests = (0, providers_prod_1.staticManifestProvider)([]);
|
|
130
|
+
}
|
|
131
|
+
// Endpoints drive gateway-side LB / progressive rollout. Shapes:
|
|
132
|
+
// flat {svc:[url,...]} → endpointsPodProvider
|
|
133
|
+
// rollout {svc:[{version,weight,urls:[...]}]} → rolloutPodProvider (weighted split)
|
|
134
|
+
// targeted {svc:{rules,cohorts}} → rolloutPodProvider (ABAC)
|
|
135
|
+
// The file is watched so weights ramp or roll back LIVE — no gateway redeploy.
|
|
136
|
+
const endpointsFile = env['LENSMCP_GW_ENDPOINTS'];
|
|
137
|
+
const endpointsRaw = readJson(endpointsFile, {});
|
|
138
|
+
if (Object.keys(endpointsRaw).length) {
|
|
139
|
+
const rollout = isCohortShape(endpointsRaw);
|
|
140
|
+
pods = rollout
|
|
141
|
+
? (0, providers_prod_1.rolloutPodProvider)(endpointsRaw)
|
|
142
|
+
: (0, providers_prod_1.endpointsPodProvider)(endpointsRaw);
|
|
143
|
+
console.log(`[gateway] endpoints: ${rollout ? 'ROLLOUT (weighted cohorts)' : 'flat'} — ${Object.keys(endpointsRaw).length} service(s)`);
|
|
144
|
+
if (endpointsFile) {
|
|
145
|
+
stopEndpointsWatch = watchJsonFile(endpointsFile, (next) => {
|
|
146
|
+
try {
|
|
147
|
+
pods?.update?.(next);
|
|
148
|
+
console.log('[gateway] endpoints reloaded');
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
console.error('[gateway] endpoints reload failed:', e.message);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const serviceKeys = readJson(env['LENSMCP_GW_KEYS'], {});
|
|
158
|
+
const tls = env['LENSMCP_GW_TLS_KEY'] && env['LENSMCP_GW_TLS_CERT']
|
|
159
|
+
? { key: (0, node_fs_1.readFileSync)(env['LENSMCP_GW_TLS_KEY']), cert: (0, node_fs_1.readFileSync)(env['LENSMCP_GW_TLS_CERT']) }
|
|
160
|
+
: undefined;
|
|
161
|
+
// ── JWT verification: JWKS (RS256, prod) preferred, HS256 secret as fallback ──
|
|
162
|
+
// One verifier powers BOTH identify() (claims → ABAC attributes) and the edge
|
|
163
|
+
// authenticate() (reject jwt routes when LENSMCP_GW_JWT_REQUIRED). With neither
|
|
164
|
+
// configured, jwt routes fall back to a bearer-presence check and identity
|
|
165
|
+
// attributes are absent (IP/header/cookie rules still work).
|
|
166
|
+
const jwtRequired = env['LENSMCP_GW_JWT_REQUIRED'] === '1';
|
|
167
|
+
const jwksUrl = env['LENSMCP_GW_JWKS_URL'];
|
|
168
|
+
const jwtSecret = env['LENSMCP_GW_JWT_SECRET'];
|
|
169
|
+
const jwks = jwksUrl
|
|
170
|
+
? (0, jwks_verify_1.createJwksVerifier)({
|
|
171
|
+
jwksUrl,
|
|
172
|
+
...(env['LENSMCP_GW_JWT_ISS'] ? { issuer: env['LENSMCP_GW_JWT_ISS'] } : {}),
|
|
173
|
+
...(env['LENSMCP_GW_JWT_AUD'] ? { audience: env['LENSMCP_GW_JWT_AUD'].split(',').map((s) => s.trim()) } : {}),
|
|
174
|
+
})
|
|
175
|
+
: undefined;
|
|
176
|
+
const hs256 = (tok) => {
|
|
177
|
+
if (!jwtSecret)
|
|
178
|
+
return undefined;
|
|
179
|
+
const parts = tok.split('.');
|
|
180
|
+
if (parts.length !== 3)
|
|
181
|
+
return undefined;
|
|
182
|
+
try {
|
|
183
|
+
const expect = (0, node_crypto_1.createHmac)('sha256', jwtSecret).update(parts[0] + '.' + parts[1]).digest('base64url');
|
|
184
|
+
const a = Buffer.from(parts[2]), b = Buffer.from(expect);
|
|
185
|
+
if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b))
|
|
186
|
+
return undefined;
|
|
187
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
188
|
+
if (typeof payload.exp === 'number' && Date.now() / 1000 > payload.exp)
|
|
189
|
+
return undefined;
|
|
190
|
+
return payload;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const bearerClaims = (req) => {
|
|
197
|
+
const auth = String(req.headers['authorization'] ?? '');
|
|
198
|
+
if (!auth.startsWith('Bearer '))
|
|
199
|
+
return undefined;
|
|
200
|
+
const tok = auth.slice(7);
|
|
201
|
+
return jwks ? jwks.verify(tok) : hs256(tok);
|
|
202
|
+
};
|
|
203
|
+
const identify = (jwks || jwtSecret) ? bearerClaims : undefined;
|
|
204
|
+
const authenticate = (mode, req) => {
|
|
205
|
+
if (mode !== 'jwt' || !jwtRequired)
|
|
206
|
+
return;
|
|
207
|
+
if (jwks || jwtSecret) {
|
|
208
|
+
if (!bearerClaims(req))
|
|
209
|
+
throw new Error('invalid or missing token');
|
|
210
|
+
return;
|
|
211
|
+
} // real verification
|
|
212
|
+
if (!String(req.headers['authorization'] ?? '').startsWith('Bearer '))
|
|
213
|
+
throw new Error('missing bearer token');
|
|
214
|
+
};
|
|
215
|
+
const attributeHeaders = (env['LENSMCP_GW_ATTR_HEADERS'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
216
|
+
const trustProxyIp = env['LENSMCP_GW_TRUST_PROXY'] === '1';
|
|
217
|
+
const uidHeader = env['LENSMCP_GW_UID_HEADER'];
|
|
218
|
+
const cookie = (env['LENSMCP_GW_COOKIE_SECRET'] || env['LENSMCP_GW_COOKIE_NAME'] || env['LENSMCP_GW_COOKIE_DOMAIN'])
|
|
219
|
+
? {
|
|
220
|
+
...(env['LENSMCP_GW_COOKIE_NAME'] ? { name: env['LENSMCP_GW_COOKIE_NAME'] } : {}),
|
|
221
|
+
...(env['LENSMCP_GW_COOKIE_SECRET'] ? { secret: env['LENSMCP_GW_COOKIE_SECRET'] } : {}),
|
|
222
|
+
...(env['LENSMCP_GW_COOKIE_DOMAIN'] ? { domain: env['LENSMCP_GW_COOKIE_DOMAIN'] } : {}),
|
|
223
|
+
...(env['LENSMCP_GW_COOKIE_MAXAGE'] ? { maxAge: Number(env['LENSMCP_GW_COOKIE_MAXAGE']) } : {}),
|
|
224
|
+
}
|
|
225
|
+
: undefined;
|
|
226
|
+
// Lens bus is OPT-IN: only when LENSMCP_EVENT_FILE is set do we attach a sink.
|
|
227
|
+
const eventFile = env['LENSMCP_EVENT_FILE'];
|
|
228
|
+
let dirReady = false;
|
|
229
|
+
const emit = eventFile
|
|
230
|
+
? (event) => {
|
|
231
|
+
try {
|
|
232
|
+
if (!dirReady) {
|
|
233
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(eventFile), { recursive: true });
|
|
234
|
+
dirReady = true;
|
|
235
|
+
}
|
|
236
|
+
(0, node_fs_1.appendFileSync)(eventFile, JSON.stringify(event) + '\n');
|
|
237
|
+
}
|
|
238
|
+
catch { /* lens offline — never block traffic */ }
|
|
239
|
+
}
|
|
240
|
+
: undefined;
|
|
241
|
+
// Warm the JWKS before serving so the first identity-routed requests have keys.
|
|
242
|
+
if (jwks) {
|
|
243
|
+
try {
|
|
244
|
+
await jwks.refresh();
|
|
245
|
+
console.log('[gateway] JWKS loaded');
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
console.error('[gateway] initial JWKS load failed (will retry in background):', e.message);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const handle = await (0, prod_gateway_lib_1.startProdGateway)({
|
|
252
|
+
...(ports.length ? { ports } : {}),
|
|
253
|
+
manifests,
|
|
254
|
+
...(pods ? { pods } : {}),
|
|
255
|
+
serviceKeys,
|
|
256
|
+
authenticate,
|
|
257
|
+
...(tls ? { tls } : {}),
|
|
258
|
+
...(emit ? { emit } : {}),
|
|
259
|
+
...(identify ? { identify } : {}),
|
|
260
|
+
...(attributeHeaders.length ? { attributeHeaders } : {}),
|
|
261
|
+
...(trustProxyIp ? { trustProxyIp } : {}),
|
|
262
|
+
...(cookie ? { cookie } : {}),
|
|
263
|
+
...(uidHeader ? { uidHeader } : {}),
|
|
264
|
+
});
|
|
265
|
+
console.log(`[gateway] production gateway up on ${handle.ports.join(', ')} (${handle.routes().length} routes${emit ? ', lens ON' : ''})`);
|
|
266
|
+
const shutdown = async (sig) => {
|
|
267
|
+
console.log(`[gateway] ${sig} — draining…`);
|
|
268
|
+
try {
|
|
269
|
+
manifests.stop?.();
|
|
270
|
+
}
|
|
271
|
+
catch { /* ignore */ }
|
|
272
|
+
try {
|
|
273
|
+
stopEndpointsWatch?.();
|
|
274
|
+
}
|
|
275
|
+
catch { /* ignore */ }
|
|
276
|
+
try {
|
|
277
|
+
registry?.stop();
|
|
278
|
+
}
|
|
279
|
+
catch { /* ignore */ }
|
|
280
|
+
try {
|
|
281
|
+
jwks?.stop();
|
|
282
|
+
}
|
|
283
|
+
catch { /* ignore */ }
|
|
284
|
+
await handle.stop();
|
|
285
|
+
process.exit(0);
|
|
286
|
+
};
|
|
287
|
+
for (const sig of ['SIGTERM', 'SIGINT'])
|
|
288
|
+
process.on(sig, () => void shutdown(sig));
|
|
289
|
+
}
|
|
290
|
+
void main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.rollout.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/main.rollout.ts"],"names":[],"mappings":"AAuFA,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAKhE"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runRollout = runRollout;
|
|
4
|
+
/**
|
|
5
|
+
* `lensmcp rollout` CD helper entry — safe, scriptable edits to the gateway
|
|
6
|
+
* rollout config. Two targets:
|
|
7
|
+
* - a FILE (default): the watched config / mounted ConfigMap. Atomic write
|
|
8
|
+
* (tmp + rename) so the gateway watcher never reads a half file.
|
|
9
|
+
* - a REDIS registry (`--redis <url>`): writes the service's rollout key and
|
|
10
|
+
* PUBLISHES the change channel, so a registry-backed gateway re-reads live
|
|
11
|
+
* (zero-touch auto-sync; no file mounts).
|
|
12
|
+
*
|
|
13
|
+
* node main.rollout.js <config.json> <command> [args]
|
|
14
|
+
* node main.rollout.js --redis <url> [--prefix <p>] <command> [args]
|
|
15
|
+
*
|
|
16
|
+
* Available from the published @lensmcp/cluster package; `lensmcp rollout …`
|
|
17
|
+
* wraps it. See docs/design/targeted-rollout.md.
|
|
18
|
+
*/
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
20
|
+
const rollout_ops_1 = require("./rollout-ops");
|
|
21
|
+
const registry_source_1 = require("./registry-source");
|
|
22
|
+
const USAGE = `lensmcp rollout — edit the gateway rollout config (file or Redis registry)
|
|
23
|
+
|
|
24
|
+
Usage: <config.json> <command> [args]
|
|
25
|
+
--redis <url> [--prefix <p>] <command> [args]
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
status [service] show cohorts + rules
|
|
29
|
+
add-cohort <svc> <version> <weight> <url> [url...] add a version cohort
|
|
30
|
+
set-weight <svc> <version> <weight> ramp / adjust a cohort weight
|
|
31
|
+
remove-cohort <svc> <version> drop a cohort
|
|
32
|
+
promote <svc> <version> set version=100%, others=0
|
|
33
|
+
abort <svc> <version> set version weight=0 (rollback)
|
|
34
|
+
add-rule <svc> <name> <attr> <op> <value> <version> add/replace a targeting rule
|
|
35
|
+
remove-rule <svc> <name> remove a targeting rule
|
|
36
|
+
`;
|
|
37
|
+
const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
|
|
38
|
+
const err = (s) => process.stderr.write(s.endsWith('\n') ? s : s + '\n');
|
|
39
|
+
/** Pull `--flag <value>` out of argv, returning its value + the remaining args. */
|
|
40
|
+
function extractFlag(argv, name) {
|
|
41
|
+
const i = argv.indexOf(name);
|
|
42
|
+
if (i < 0)
|
|
43
|
+
return { rest: argv };
|
|
44
|
+
return { value: argv[i + 1], rest: [...argv.slice(0, i), ...argv.slice(i + 2)] };
|
|
45
|
+
}
|
|
46
|
+
function runFile(argv) {
|
|
47
|
+
const [file, ...rest] = argv;
|
|
48
|
+
if (!file || !rest.length) {
|
|
49
|
+
err(USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
let config;
|
|
53
|
+
try {
|
|
54
|
+
config = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
err(`cannot read ${file}: ${e.message}`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const parsed = (0, rollout_ops_1.parseRolloutCommand)(rest);
|
|
62
|
+
if (!parsed.op) {
|
|
63
|
+
out((0, rollout_ops_1.summarizeRollout)(config, parsed.status));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const next = (0, rollout_ops_1.applyRolloutOp)(config, parsed.op);
|
|
67
|
+
(0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(next, null, 2) + '\n');
|
|
68
|
+
(0, node_fs_1.renameSync)(`${file}.tmp`, file); // atomic — the watcher never sees a partial file
|
|
69
|
+
out(`✓ ${parsed.op.kind} ${parsed.op.service}\n\n${(0, rollout_ops_1.summarizeRollout)(next, parsed.op.service)}`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
err(`✗ ${e.message}`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function runRedis(url, prefix, argv) {
|
|
78
|
+
if (!argv.length) {
|
|
79
|
+
err(USAGE);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
let client;
|
|
83
|
+
try {
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
+
const Redis = require('ioredis');
|
|
86
|
+
client = new Redis(url);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
err('--redis needs the "ioredis" package installed (peer dep of @lensmcp/cluster)');
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const { endpoints } = await (0, registry_source_1.redisRegistrySource)(client, { prefix }).read();
|
|
94
|
+
const parsed = (0, rollout_ops_1.parseRolloutCommand)(argv);
|
|
95
|
+
if (!parsed.op) {
|
|
96
|
+
out((0, rollout_ops_1.summarizeRollout)(endpoints, parsed.status));
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
const next = (0, rollout_ops_1.applyRolloutOp)(endpoints, parsed.op);
|
|
100
|
+
const svc = parsed.op.service;
|
|
101
|
+
const entry = next[svc];
|
|
102
|
+
if (entry === undefined)
|
|
103
|
+
await client.del((0, registry_source_1.rolloutKey)(prefix, svc));
|
|
104
|
+
else
|
|
105
|
+
await client.set((0, registry_source_1.rolloutKey)(prefix, svc), JSON.stringify(entry));
|
|
106
|
+
await client.publish((0, registry_source_1.changeChannel)(prefix), svc); // gateway re-reads live
|
|
107
|
+
out(`✓ ${parsed.op.kind} ${svc} → redis ${prefix} (published)\n\n${(0, rollout_ops_1.summarizeRollout)(next, svc)}`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
err(`✗ ${e.message}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
try {
|
|
116
|
+
await client.quit();
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function runRollout(argv) {
|
|
122
|
+
const redis = extractFlag(argv, '--redis');
|
|
123
|
+
const pref = extractFlag(redis.rest, '--prefix');
|
|
124
|
+
if (redis.value)
|
|
125
|
+
return runRedis(redis.value, pref.value ?? registry_source_1.DEFAULT_REGISTRY_PREFIX, pref.rest);
|
|
126
|
+
return runFile(pref.rest);
|
|
127
|
+
}
|
|
128
|
+
// Run only when invoked directly (not when imported, e.g. by the lensmcp CLI).
|
|
129
|
+
if (require.main === module)
|
|
130
|
+
void runRollout(process.argv.slice(2)).then((code) => process.exit(code));
|