@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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +130 -0
  3. package/basic-ssl.d.ts +7 -0
  4. package/basic-ssl.d.ts.map +1 -0
  5. package/basic-ssl.js +158 -0
  6. package/build-scope-patterns.d.ts +12 -0
  7. package/build-scope-patterns.d.ts.map +1 -0
  8. package/build-scope-patterns.js +40 -0
  9. package/create-webpack-dev.d.ts +27 -0
  10. package/create-webpack-dev.d.ts.map +1 -0
  11. package/create-webpack-dev.js +151 -0
  12. package/create-webpack-prod.d.ts +28 -0
  13. package/create-webpack-prod.d.ts.map +1 -0
  14. package/create-webpack-prod.js +169 -0
  15. package/executors/build/build.impl.d.ts +19 -0
  16. package/executors/build/build.impl.d.ts.map +1 -0
  17. package/executors/build/build.impl.js +98 -0
  18. package/executors/build/schema.d.ts +35 -0
  19. package/executors/build/schema.json +135 -0
  20. package/executors/gateway/gateway.impl.d.ts +23 -0
  21. package/executors/gateway/gateway.impl.d.ts.map +1 -0
  22. package/executors/gateway/gateway.impl.js +39 -0
  23. package/executors/gateway/gateway.lib.d.ts +130 -0
  24. package/executors/gateway/gateway.lib.d.ts.map +1 -0
  25. package/executors/gateway/gateway.lib.js +797 -0
  26. package/executors/gateway/jwks-verify.d.ts +28 -0
  27. package/executors/gateway/jwks-verify.d.ts.map +1 -0
  28. package/executors/gateway/jwks-verify.js +121 -0
  29. package/executors/gateway/main.prod-gateway.d.ts +2 -0
  30. package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
  31. package/executors/gateway/main.prod-gateway.js +290 -0
  32. package/executors/gateway/main.rollout.d.ts +2 -0
  33. package/executors/gateway/main.rollout.d.ts.map +1 -0
  34. package/executors/gateway/main.rollout.js +130 -0
  35. package/executors/gateway/manifest.d.ts +275 -0
  36. package/executors/gateway/manifest.d.ts.map +1 -0
  37. package/executors/gateway/manifest.js +344 -0
  38. package/executors/gateway/prod-gateway.lib.d.ts +58 -0
  39. package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
  40. package/executors/gateway/prod-gateway.lib.js +535 -0
  41. package/executors/gateway/providers-prod.d.ts +46 -0
  42. package/executors/gateway/providers-prod.d.ts.map +1 -0
  43. package/executors/gateway/providers-prod.js +199 -0
  44. package/executors/gateway/registry-source.d.ts +68 -0
  45. package/executors/gateway/registry-source.d.ts.map +1 -0
  46. package/executors/gateway/registry-source.js +131 -0
  47. package/executors/gateway/rollout-ops.d.ts +54 -0
  48. package/executors/gateway/rollout-ops.d.ts.map +1 -0
  49. package/executors/gateway/rollout-ops.js +167 -0
  50. package/executors/gateway/schema.d.ts +10 -0
  51. package/executors/gateway/schema.json +30 -0
  52. package/executors/serve/schema.d.ts +53 -0
  53. package/executors/serve/schema.json +196 -0
  54. package/executors/serve/serve.impl.d.ts +25 -0
  55. package/executors/serve/serve.impl.d.ts.map +1 -0
  56. package/executors/serve/serve.impl.js +243 -0
  57. package/executors/trust/schema.d.ts +6 -0
  58. package/executors/trust/schema.json +20 -0
  59. package/executors/trust/trust.impl.d.ts +42 -0
  60. package/executors/trust/trust.impl.d.ts.map +1 -0
  61. package/executors/trust/trust.impl.js +126 -0
  62. package/executors.json +24 -0
  63. package/index.d.ts +4 -0
  64. package/index.d.ts.map +1 -0
  65. package/index.js +9 -0
  66. package/main.devserver.d.ts +16 -0
  67. package/main.devserver.d.ts.map +1 -0
  68. package/main.devserver.js +812 -0
  69. package/package.json +66 -0
@@ -0,0 +1,58 @@
1
+ import * as http from 'node:http';
2
+ import { type AuthMode, type ManifestProvider, type PodProvider, type Route } from './manifest';
3
+ export interface ProdGatewayOptions {
4
+ /** Ports to bind. Default `[8080]` (or `[8443]` with TLS). One Fastify
5
+ * instance per port; all share the route table + providers. */
6
+ ports?: number[];
7
+ manifests: ManifestProvider;
8
+ /** Resolves a service → upstream when a route has no direct `upstream`. */
9
+ pods?: PodProvider;
10
+ /** service → its `x-internal-token` (from a k8s Secret / Vault in prod). */
11
+ serviceKeys?: Record<string, string>;
12
+ /** Validate a `jwt`-mode request; THROW to reject (→ 401). Default: a
13
+ * pass-through with a one-time warning — wire a real JWKS validator in prod. */
14
+ authenticate?: (mode: AuthMode, req: http.IncomingMessage) => void;
15
+ tls?: {
16
+ key: string | Buffer;
17
+ cert: string | Buffer;
18
+ };
19
+ /** Lens bus sink — OPT-IN. Omit (default) for zero observability overhead. */
20
+ emit?: (event: Record<string, unknown>) => void;
21
+ /** Traffic-edge flush period (ms). Default 5000. */
22
+ trafficFlushMs?: number;
23
+ /** Request header that PINS a rollout cohort (sticky canary / debugging).
24
+ * Default `x-lensmcp-version`. The chosen version is echoed on the response. */
25
+ versionHeader?: string;
26
+ /** undici pool tuning passed to reply-from (connections, pipelining…). */
27
+ undici?: {
28
+ connections?: number;
29
+ pipelining?: number;
30
+ keepAliveTimeout?: number;
31
+ };
32
+ /** Return verified subject attributes (e.g. JWT claims) for ABAC routing.
33
+ * MUST verify (signature/exp) — forged claims must not reach the rules. */
34
+ identify?: (req: http.IncomingMessage) => Record<string, unknown> | undefined;
35
+ /** Allowlist of request headers exposed to rules as `header.<name>` attributes. */
36
+ attributeHeaders?: string[];
37
+ /** Take the `ip` attribute from `X-Forwarded-For` (behind a trusted LB) via
38
+ * Fastify `trustProxy`; default false → socket IP. */
39
+ trustProxyIp?: boolean;
40
+ /** Gateway-issued device cookie for sticky bucketing (anonymous-friendly).
41
+ * Omit to disable cookie stickiness. `secret` enables HMAC signing. */
42
+ cookie?: {
43
+ name?: string;
44
+ secret?: string;
45
+ domain?: string;
46
+ maxAge?: number;
47
+ };
48
+ /** Header carrying a client device/user id — the sticky key when no cookie
49
+ * is present (native/mobile/API clients), e.g. `x-device-id`. */
50
+ uidHeader?: string;
51
+ }
52
+ export interface ProdGatewayHandle {
53
+ ports: number[];
54
+ routes: () => Route[];
55
+ stop: () => Promise<void>;
56
+ }
57
+ export declare function startProdGateway(opts: ProdGatewayOptions): Promise<ProdGatewayHandle>;
58
+ //# sourceMappingURL=prod-gateway.lib.d.ts.map
@@ -0,0 +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;AAEpB,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,0EAA0E;IAC1E,MAAM,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAGlF;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,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAYD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAoY3F"}
@@ -0,0 +1,535 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startProdGateway = startProdGateway;
4
+ const tslib_1 = require("tslib");
5
+ /**
6
+ * Production gateway runtime — Fastify + @fastify/reply-from (undici) engine.
7
+ *
8
+ * The SAME routing brain as dev (shared `matchRoute` / `buildRouteTable` /
9
+ * manifest schema) composed over the prod providers (a manifest source +
10
+ * endpoint resolution). The HTTP transport differs from dev on purpose: dev
11
+ * uses node:http + http-proxy (it manages unix-socket pods + scale-from-zero),
12
+ * prod uses Fastify + reply-from's undici pool — connection-reusing, HTTP/1.1
13
+ * pipelined, materially faster than node-http-proxy for the high-RPS edge.
14
+ *
15
+ * The exported contract (`startProdGateway` / `ProdGatewayOptions` /
16
+ * `ProdGatewayHandle`) is unchanged from the previous engine, so the entry,
17
+ * the e2e suite, and the perf suite drive it identically — the swap is proven
18
+ * behind a stable seam.
19
+ *
20
+ * It keeps the dev trust boundary verbatim — strip client-supplied trust
21
+ * headers, validate `x-api-key` on internal routes, inject the target's
22
+ * `x-internal-token` — and the header-gated `gateway-request` flow trace, so
23
+ * the lens (when ENABLED via opts.emit; off by default) sees identical events.
24
+ * Per-request upstream selection (rollout cohort + sticky version) rides on
25
+ * reply-from's `getUpstream` hook. WebSocket upgrades are proxied raw (a
26
+ * net-level pipe) with the same trust model — no http-proxy in the prod path.
27
+ *
28
+ * No autoscale / idle-kill / process management here — prod pods are
29
+ * always-on (k8s keeps them up); that machinery is the dev PodProvider's.
30
+ */
31
+ const node_crypto_1 = require("node:crypto");
32
+ const net = tslib_1.__importStar(require("node:net"));
33
+ const fastify_1 = tslib_1.__importDefault(require("fastify"));
34
+ const reply_from_1 = tslib_1.__importDefault(require("@fastify/reply-from"));
35
+ const manifest_1 = require("./manifest");
36
+ const eid = () => Date.now().toString(36) + (0, node_crypto_1.randomBytes)(6).toString('hex');
37
+ const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
38
+ async function startProdGateway(opts) {
39
+ const ports = opts.ports?.length ? opts.ports : [opts.tls ? 8443 : 8080];
40
+ let warnedNoAuth = false;
41
+ const authenticate = opts.authenticate ?? ((mode) => {
42
+ if (mode === 'jwt' && !warnedNoAuth) {
43
+ warnedNoAuth = true;
44
+ console.warn('[gateway] no JWT authenticator configured — jwt routes pass through. Wire opts.authenticate for production.');
45
+ }
46
+ });
47
+ const emit = (severity, title, fingerprint, raw, ctx) => {
48
+ if (!opts.emit)
49
+ return; // lens off by default — no work, no allocation beyond the call
50
+ try {
51
+ opts.emit({
52
+ id: eid(), sessionId: 'pending', timestamp: Date.now(),
53
+ source: 'gateway', category: 'cluster', severity,
54
+ context: { sessionId: 'pending', ...ctx }, fingerprint, title, raw,
55
+ });
56
+ }
57
+ catch { /* never let the bus break traffic */ }
58
+ };
59
+ // ---- route table (rebuilt atomically from the manifest source) ----
60
+ let manifests = opts.manifests.list();
61
+ let routes = (0, manifest_1.buildRouteTable)(manifests);
62
+ let manifestByService = new Map(manifests.map((m) => [m.service, m]));
63
+ const rebuild = (next) => {
64
+ manifests = next;
65
+ routes = (0, manifest_1.buildRouteTable)(next);
66
+ manifestByService = new Map(next.map((m) => [m.service, m]));
67
+ emit('info', `gateway routes: ${routes.length} on ${manifests.length} services`, 'gateway-up', { kind: 'gateway-up', routes: routes.map((r) => ({ host: r.host ?? '(default)', service: r.service, internal: !!r.internal })) });
68
+ };
69
+ const unwatch = opts.manifests.watch?.((next) => rebuild(next));
70
+ const keyToProject = new Map(Object.entries(opts.serviceKeys ?? {}).map(([p, k]) => [k, p]));
71
+ const versionHeader = (opts.versionHeader ?? 'x-lensmcp-version').toLowerCase();
72
+ // ---- targeted rollout: attributes + sticky device key (cookie → uid header) ----
73
+ const attrHeaders = (opts.attributeHeaders ?? []).map((h) => h.toLowerCase());
74
+ const uidHeader = opts.uidHeader?.toLowerCase();
75
+ const cookieOn = !!opts.cookie;
76
+ const cookieName = opts.cookie?.name ?? 'lensmcp_did';
77
+ const cookieSecret = opts.cookie?.secret;
78
+ const cookieDomain = opts.cookie?.domain;
79
+ const cookieMaxAge = opts.cookie?.maxAge ?? 31_536_000; // 1y
80
+ if (cookieOn && !cookieSecret)
81
+ console.warn('[gateway] device cookie has no secret — values are UNSIGNED (set cookie.secret to prevent bucket-grinding).');
82
+ const signId = (id) => (0, node_crypto_1.createHmac)('sha256', cookieSecret).update(id).digest('base64url');
83
+ const signedCookie = (id) => (cookieSecret ? `${id}.${signId(id)}` : id);
84
+ const verifyCookie = (val) => {
85
+ if (!cookieSecret)
86
+ return val || undefined; // unsigned mode
87
+ const i = val.lastIndexOf('.');
88
+ if (i < 0)
89
+ return undefined;
90
+ const id = val.slice(0, i);
91
+ const a = Buffer.from(val.slice(i + 1)), b = Buffer.from(signId(id));
92
+ return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b) ? id : undefined;
93
+ };
94
+ const parseCookie = (header, name) => {
95
+ if (!header)
96
+ return undefined;
97
+ for (const part of header.split(';')) {
98
+ const eq = part.indexOf('=');
99
+ if (eq > 0 && part.slice(0, eq).trim() === name)
100
+ return decodeURIComponent(part.slice(eq + 1).trim());
101
+ }
102
+ return undefined;
103
+ };
104
+ const mintCookieHeader = (id) => {
105
+ let c = `${cookieName}=${encodeURIComponent(signedCookie(id))}; Path=/; Max-Age=${cookieMaxAge}; HttpOnly; SameSite=Lax; Secure`;
106
+ if (cookieDomain)
107
+ c += `; Domain=${cookieDomain}`;
108
+ return c;
109
+ };
110
+ /** Resolve the sticky key: valid cookie → UID header → freshly minted (cookie clients). */
111
+ const resolveStickyKey = (req) => {
112
+ if (cookieOn) {
113
+ const c = parseCookie(req.headers.cookie, cookieName);
114
+ const id = c ? verifyCookie(c) : undefined;
115
+ if (id)
116
+ return { key: id, mint: false };
117
+ }
118
+ if (uidHeader) {
119
+ const u = hdr(req.headers[uidHeader]);
120
+ if (u)
121
+ return { key: u, mint: false };
122
+ }
123
+ if (cookieOn)
124
+ return { key: 'd_' + (0, node_crypto_1.randomBytes)(12).toString('base64url'), mint: true };
125
+ return { mint: false };
126
+ };
127
+ /** Assemble the flat attribute bag a rule evaluates against. */
128
+ const buildAttrs = (request, principal, key) => {
129
+ const req = request.raw;
130
+ const a = { ...(principal ?? {}) };
131
+ a['ip'] = request.ip;
132
+ a['method'] = (req.method ?? 'GET').toUpperCase();
133
+ a['path'] = (req.url ?? '/').split('?')[0];
134
+ if (key)
135
+ a['device'] = key;
136
+ for (const h of attrHeaders) {
137
+ const v = hdr(req.headers[h]);
138
+ if (v !== undefined)
139
+ a['header.' + h] = v;
140
+ }
141
+ return a;
142
+ };
143
+ // ---- traffic edges (host→service[@version]), flushed to the lens periodically ----
144
+ const edges = new Map();
145
+ const recordEdge = (route, ms, status, caller, version) => {
146
+ const host = route.host ?? '(default)';
147
+ const key = (caller ? caller + '⇒' : '') + host + '→' + route.service + (version ? '@' + version : '');
148
+ const e = edges.get(key) ?? { host, service: route.service, caller, version, count: 0, totalMs: 0, errors: 0 };
149
+ e.count += 1;
150
+ e.totalMs += ms;
151
+ if (status >= 500 || status === 0)
152
+ e.errors += 1;
153
+ edges.set(key, e);
154
+ };
155
+ const edgeFlusher = setInterval(() => {
156
+ if (!opts.emit) {
157
+ edges.clear();
158
+ return;
159
+ }
160
+ for (const e of edges.values()) {
161
+ emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.service}${e.version ? '@' + e.version : ''}: ${e.count} req`, `gateway-traffic:${e.host}->${e.service}${e.version ? '@' + e.version : ''}`, { kind: 'gateway-traffic', host: e.host, project: e.service, caller: e.caller, version: e.version, count: e.count, avgMs: Math.round(e.totalMs / e.count), errors: e.errors });
162
+ }
163
+ edges.clear();
164
+ }, opts.trafficFlushMs ?? 5000);
165
+ edgeFlusher.unref?.();
166
+ // ---- per-request flow trace (only for flow-header-carrying requests) ----
167
+ const beginTrace = (req) => {
168
+ if (!opts.emit)
169
+ return undefined; // no lens → no trace overhead
170
+ const flowId = hdr(req.headers['x-lensmcp-flow-id']);
171
+ let requestId = hdr(req.headers['x-request-id']);
172
+ if (!flowId && !requestId && !hdr(req.headers['traceparent']))
173
+ return undefined;
174
+ if (!requestId) {
175
+ requestId = eid();
176
+ req.headers['x-request-id'] = requestId;
177
+ }
178
+ return { startedAt: Date.now(), method: (req.method ?? 'GET').toUpperCase(), host: (req.headers.host || '').split(':')[0], url: req.url ?? '/', steps: [], ...(flowId ? { flowId } : {}), ...(requestId ? { requestId } : {}) };
179
+ };
180
+ const step = (t, name, info) => { if (t)
181
+ t.steps.push({ step: name, atMs: Date.now() - t.startedAt, ...info }); };
182
+ const finish = (t, service, status, extra) => {
183
+ if (!t || t.done)
184
+ return;
185
+ t.done = true;
186
+ emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${Date.now() - t.startedAt}ms)`, `gateway-request:${service}`, { kind: 'gateway-request', project: service, host: t.host, method: t.method, url: t.url, status, durationMs: Date.now() - t.startedAt, startedAt: t.startedAt, steps: t.steps, ...extra }, { ...(t.flowId ? { flowId: t.flowId } : {}), ...(t.requestId ? { requestId: t.requestId } : {}) });
187
+ };
188
+ rebuild(manifests); // emit initial gateway-up
189
+ // ---- trust boundary (verbatim dev model) ----
190
+ // The gateway OWNS these headers — drop any client-supplied copy at ingress so identity
191
+ // can only originate from a verified token (closes header spoofing), then re-stamp on forward.
192
+ const TRUST_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
193
+ const stripTrust = (req) => { for (const h of TRUST_HEADERS)
194
+ delete req.headers[h]; };
195
+ /** Stamp the verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
196
+ const stampIdentity = (req, claims) => {
197
+ const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
198
+ req.headers[k] = String(v); };
199
+ set('x-user-id', claims['sub']);
200
+ set('x-tenant-id', claims['tid']);
201
+ const roles = claims['roles'];
202
+ if (Array.isArray(roles) && roles.length)
203
+ req.headers['x-user-roles'] = roles.map(String).join(',');
204
+ const perms = claims['perms'];
205
+ if (Array.isArray(perms) && perms.length)
206
+ req.headers['x-user-permissions'] = perms.map(String).join(',');
207
+ };
208
+ // ---- per-request upstream resolution (direct upstream OR pod pick + sticky version) ----
209
+ const resolveUpstream = async (route, trace, pinned) => {
210
+ if (route.upstream)
211
+ return { url: route.upstream };
212
+ const opt = pinned ? { version: pinned } : undefined;
213
+ let up = opts.pods?.pick(route.service, opt);
214
+ if (!up && opts.pods?.ensureUp) {
215
+ step(trace, 'ensure-up', { project: route.service });
216
+ if (await opts.pods.ensureUp(route.service))
217
+ up = opts.pods.pick(route.service, opt);
218
+ }
219
+ return up;
220
+ };
221
+ const reqState = new WeakMap();
222
+ // ---- no-route fallbacks: CORS preflight, health, discovery, 404 ----
223
+ const setCors = (reply, req) => {
224
+ reply.header('access-control-allow-origin', String(req.headers.origin ?? '*'));
225
+ reply.header('access-control-allow-credentials', 'true');
226
+ reply.header('vary', 'Origin');
227
+ };
228
+ const handleNoRoute = (request, reply, host, url) => {
229
+ const req = request.raw;
230
+ setCors(reply, req);
231
+ if (req.method === 'OPTIONS') {
232
+ reply.code(204)
233
+ .header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
234
+ .header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
235
+ .send();
236
+ return;
237
+ }
238
+ const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
239
+ const p = url.split('?')[0];
240
+ if (mounted.length > 0 && (p === '/health' || p === '/healthz')) {
241
+ reply.code(200).type('application/json').send(JSON.stringify({ status: 'ok', host, services: mounted.map((r) => ({ service: r.service, prefix: r.prefix })) }));
242
+ return;
243
+ }
244
+ if (p === '/.well-known/lensmcp-routes') {
245
+ 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 })) }));
246
+ return;
247
+ }
248
+ 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.');
249
+ };
250
+ // ---- the request handler (catch-all): route → trust → auth → forward ----
251
+ const handler = async (request, reply) => {
252
+ const req = request.raw;
253
+ const host = (req.headers.host || '').split(':')[0];
254
+ const url = req.url ?? '/';
255
+ const trace = beginTrace(req);
256
+ step(trace, 'received', { host, method: req.method, url });
257
+ const route = (0, manifest_1.matchRoute)(routes, host, url);
258
+ if (!route) {
259
+ handleNoRoute(request, reply, host, url);
260
+ return reply;
261
+ }
262
+ step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
263
+ // Verify the end-user JWT ONCE (when a verifier is configured) — reused for both edge
264
+ // authorization and rollout targeting. Internal east-west routes use x-api-key instead.
265
+ const claims = route.internal ? undefined : opts.identify?.(req);
266
+ let caller;
267
+ if (route.internal) {
268
+ caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
269
+ if (!caller) {
270
+ step(trace, 'auth', { mode: 'x-api-key', ok: false });
271
+ finish(trace, route.service, 401);
272
+ reply.code(401).type('text/plain').send('Invalid or missing x-api-key for internal route.');
273
+ return reply;
274
+ }
275
+ req.headers['x-api-key-id'] = caller;
276
+ delete req.headers['x-api-key'];
277
+ step(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
278
+ }
279
+ else {
280
+ const m = manifestByService.get(route.service);
281
+ const resolved = m ? (0, manifest_1.authRuleForPath)(m, url, req.method ?? 'GET', false) : { mode: route.auth };
282
+ if (resolved.mode === 'jwt') {
283
+ if (opts.identify) {
284
+ if (!claims) {
285
+ step(trace, 'auth', { mode: 'jwt', ok: false });
286
+ finish(trace, route.service, 401);
287
+ reply.code(401).type('text/plain').send('Unauthorized: invalid or missing token');
288
+ return reply;
289
+ }
290
+ }
291
+ else {
292
+ try {
293
+ authenticate('jwt', req);
294
+ }
295
+ catch (e) {
296
+ step(trace, 'auth', { mode: 'jwt', ok: false });
297
+ finish(trace, route.service, 401);
298
+ reply.code(401).type('text/plain').send(`Unauthorized: ${e.message}`);
299
+ return reply;
300
+ }
301
+ }
302
+ // edge ABAC: the required permission/rule must hold against the VERIFIED claims.
303
+ if (resolved.rule && claims && !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(request, claims, undefined))) {
304
+ step(trace, 'authz', { ok: false });
305
+ finish(trace, route.service, 403);
306
+ reply.code(403).type('text/plain').send('Forbidden: insufficient permission');
307
+ return reply;
308
+ }
309
+ step(trace, 'auth', { mode: 'jwt', ok: true });
310
+ if (claims)
311
+ stampIdentity(req, claims);
312
+ }
313
+ else {
314
+ step(trace, 'auth', { mode: resolved.mode });
315
+ }
316
+ }
317
+ if (opts.serviceKeys?.[route.service])
318
+ req.headers['x-internal-token'] = opts.serviceKeys[route.service];
319
+ if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix))
320
+ req.url = route.prependPrefix + (req.url ?? '/');
321
+ // ── version selection: explicit pin → ABAC rule → sticky-key weighted → SWRR ──
322
+ // (targeting/stickiness only for external routes; internal east-west has no "device")
323
+ const explicit = hdr(req.headers[versionHeader]);
324
+ let pinned = explicit;
325
+ let mintKey; // set → append Set-Cookie on the response
326
+ if (!route.internal && !explicit) {
327
+ const sk = resolveStickyKey(req);
328
+ if (sk.mint)
329
+ mintKey = sk.key;
330
+ const attrs = buildAttrs(request, claims, sk.key);
331
+ const targeted = opts.pods?.resolveVersion?.(route.service, attrs);
332
+ if (targeted) {
333
+ pinned = targeted.version;
334
+ step(trace, 'targeting', { rule: targeted.rule, version: targeted.version });
335
+ }
336
+ else if (sk.key) {
337
+ const b = opts.pods?.bucket?.(route.service, sk.key);
338
+ if (b) {
339
+ pinned = b.version;
340
+ step(trace, 'targeting', { mode: 'sticky', version: b.version });
341
+ }
342
+ }
343
+ }
344
+ const up = await resolveUpstream(route, trace, pinned);
345
+ if (!up) {
346
+ finish(trace, route.service, 503);
347
+ reply.code(503).type('text/plain').send(`Service ${route.service} has no available upstream.`);
348
+ return reply;
349
+ }
350
+ if (!('url' in up)) {
351
+ finish(trace, route.service, 502);
352
+ reply.code(502).type('text/plain').send(`Service ${route.service}: socket upstreams are not supported by the prod gateway.`);
353
+ return reply;
354
+ }
355
+ const origin = new URL(up.url).origin;
356
+ const upLabel = up.url;
357
+ reqState.set(request, { route, caller, startedAt: Date.now(), trace, version: up.version, upLabel });
358
+ step(trace, up.version ? 'rollout' : 'upstream', { upstream: upLabel, ...(up.version ? { version: up.version, ...(explicit ? { pinned: true } : {}) } : {}) });
359
+ // No source → reply-from forwards the (prefix-rewritten) req.url to `origin`.
360
+ return reply.from(undefined, {
361
+ getUpstream: () => origin,
362
+ rewriteHeaders: (headers) => {
363
+ headers['x-lensmcp-upstream'] = upLabel;
364
+ if (up.version)
365
+ headers['x-lensmcp-version'] = up.version; // which rollout cohort served
366
+ // The gateway owns the edge response: drop the upstream framework leak
367
+ // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
368
+ delete headers['x-powered-by'];
369
+ headers['server'] = 'LensMCP';
370
+ if (mintKey) { // first contact → stamp the device cookie (appended to any upstream Set-Cookie)
371
+ const ex = headers['set-cookie'];
372
+ const list = Array.isArray(ex) ? ex : ex ? [ex] : [];
373
+ headers['set-cookie'] = [...list, mintCookieHeader(mintKey)];
374
+ }
375
+ return headers;
376
+ },
377
+ onError: (rep, { error }) => {
378
+ opts.pods?.drop?.(route.service, up);
379
+ rep.code(502).type('text/plain').send(`Upstream ${route.service} unavailable — ${error.message}`);
380
+ },
381
+ });
382
+ };
383
+ // ---- raw WebSocket upgrade proxy (same trust model; net-level pipe) ----
384
+ const upgrade = (req, socket, head) => {
385
+ stripTrust(req);
386
+ const host = (req.headers.host || '').split(':')[0];
387
+ const route = (0, manifest_1.matchRoute)(routes, host, req.url ?? '/');
388
+ if (!route) {
389
+ socket.destroy();
390
+ return;
391
+ }
392
+ const claims = route.internal ? undefined : opts.identify?.(req);
393
+ let caller;
394
+ if (route.internal) {
395
+ caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
396
+ if (!caller) {
397
+ socket.destroy();
398
+ return;
399
+ }
400
+ }
401
+ else {
402
+ const m = manifestByService.get(route.service);
403
+ const resolved = m ? (0, manifest_1.authRuleForPath)(m, req.url ?? '/', req.method ?? 'GET', false) : { mode: route.auth };
404
+ if (resolved.mode === 'jwt') {
405
+ if (opts.identify) {
406
+ if (!claims) {
407
+ socket.destroy();
408
+ return;
409
+ }
410
+ }
411
+ else {
412
+ try {
413
+ authenticate('jwt', req);
414
+ }
415
+ catch {
416
+ socket.destroy();
417
+ return;
418
+ }
419
+ }
420
+ if (resolved.rule && claims && !(0, manifest_1.evalRule)(resolved.rule, { ...claims, ip: req.socket.remoteAddress ?? '', method: req.method ?? 'GET', path: (req.url ?? '/').split('?')[0] })) {
421
+ socket.destroy();
422
+ return;
423
+ }
424
+ }
425
+ }
426
+ if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix))
427
+ req.url = route.prependPrefix + (req.url ?? '/');
428
+ const pinned = hdr(req.headers[versionHeader]);
429
+ void (async () => {
430
+ const up = await resolveUpstream(route, undefined, pinned);
431
+ if (!up || !('url' in up)) {
432
+ socket.destroy();
433
+ return;
434
+ }
435
+ const u = new URL(up.url);
436
+ const upstream = net.connect(Number(u.port) || (u.protocol === 'https:' ? 443 : 80), u.hostname, () => {
437
+ // rebuild the upgrade request from rawHeaders (verbatim), minus the trust headers
438
+ // we own (incl. client identity), plus the validated caller + target token + identity.
439
+ const strip = new Set(['x-internal-token', 'x-api-key-id', 'x-api-key', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions']);
440
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
441
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
442
+ const k = req.rawHeaders[i];
443
+ if (!strip.has(k.toLowerCase()))
444
+ lines.push(`${k}: ${req.rawHeaders[i + 1]}`);
445
+ }
446
+ if (caller)
447
+ lines.push(`x-api-key-id: ${caller}`);
448
+ const token = opts.serviceKeys?.[route.service];
449
+ if (token)
450
+ lines.push(`x-internal-token: ${token}`);
451
+ if (claims) { // stamp the verified identity (mirrors the HTTP path's stampIdentity)
452
+ if (claims['sub'])
453
+ lines.push(`x-user-id: ${String(claims['sub'])}`);
454
+ if (claims['tid'])
455
+ lines.push(`x-tenant-id: ${String(claims['tid'])}`);
456
+ const roles = claims['roles'];
457
+ if (Array.isArray(roles) && roles.length)
458
+ lines.push(`x-user-roles: ${roles.map(String).join(',')}`);
459
+ const perms = claims['perms'];
460
+ if (Array.isArray(perms) && perms.length)
461
+ lines.push(`x-user-permissions: ${perms.map(String).join(',')}`);
462
+ }
463
+ upstream.write(lines.join('\r\n') + '\r\n\r\n');
464
+ if (head?.length)
465
+ upstream.write(head);
466
+ socket.pipe(upstream);
467
+ upstream.pipe(socket);
468
+ });
469
+ upstream.on('error', () => socket.destroy());
470
+ socket.on('error', () => upstream.destroy());
471
+ })();
472
+ };
473
+ // ---- build one Fastify app per port (shared route table + providers) ----
474
+ const buildApp = async () => {
475
+ const app = (0, fastify_1.default)({ logger: false, ...(opts.trustProxyIp ? { trustProxy: true } : {}), ...(opts.tls ? { https: { key: opts.tls.key, cert: opts.tls.cert } } : {}) });
476
+ // Stream every body straight through — never parse/buffer (this is a proxy).
477
+ app.removeAllContentTypeParsers();
478
+ app.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
479
+ await app.register(reply_from_1.default, {
480
+ disableRequestLogging: true,
481
+ undici: {
482
+ connections: opts.undici?.connections ?? 256,
483
+ pipelining: opts.undici?.pipelining ?? 1,
484
+ keepAliveTimeout: opts.undici?.keepAliveTimeout ?? 60_000,
485
+ connect: { rejectUnauthorized: false }, // upstreams may use the local CA / self-signed (mirrors http-proxy secure:false)
486
+ },
487
+ });
488
+ // Host-agnostic operational probes (answered before routing).
489
+ app.get('/livez', (_req, reply) => { reply.type('text/plain').send('ok'); });
490
+ app.get('/readyz', (_req, reply) => {
491
+ const ready = routes.length > 0;
492
+ reply.code(ready ? 200 : 503).type('text/plain').send(ready ? 'ready' : 'no routes loaded');
493
+ });
494
+ app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
495
+ app.addHook('onResponse', (request, reply, done) => {
496
+ const st = reqState.get(request);
497
+ if (st) {
498
+ recordEdge(st.route, Date.now() - st.startedAt, reply.statusCode ?? 0, st.caller, st.version);
499
+ finish(st.trace, st.route.service, reply.statusCode ?? 0, { upstream: st.upLabel, ...(st.version ? { version: st.version } : {}), ...(st.caller ? { caller: st.caller } : {}) });
500
+ reqState.delete(request);
501
+ }
502
+ done();
503
+ });
504
+ app.all('/', handler);
505
+ app.all('/*', handler);
506
+ app.server.on('upgrade', upgrade);
507
+ return app;
508
+ };
509
+ // ---- listeners ----
510
+ const apps = [];
511
+ const boundPorts = [];
512
+ for (const port of ports) {
513
+ const app = await buildApp();
514
+ await app.listen({ port, host: '0.0.0.0' });
515
+ const addr = app.server.address();
516
+ const bound = typeof addr === 'object' && addr ? addr.port : port;
517
+ boundPorts.push(bound);
518
+ console.log(`[gateway] listening on ${opts.tls ? 'https' : 'http'}://0.0.0.0:${bound}`);
519
+ apps.push(app);
520
+ }
521
+ let stopped = false;
522
+ return {
523
+ ports: boundPorts,
524
+ routes: () => routes,
525
+ stop: async () => {
526
+ if (stopped)
527
+ return;
528
+ stopped = true;
529
+ clearInterval(edgeFlusher);
530
+ unwatch?.();
531
+ emit('info', 'gateway down', 'gateway-down', { kind: 'gateway-down' });
532
+ await Promise.all(apps.map((a) => a.close())); // reply-from closes its undici pool on close
533
+ },
534
+ };
535
+ }
@@ -0,0 +1,46 @@
1
+ import { type ManifestProvider, type PodProvider, type RouteManifest, type VersionCohort, type TargetRule } from './manifest';
2
+ /** A service's rollout spec: optional targeting rules + the weighted cohorts. */
3
+ export interface RolloutSpec {
4
+ rules?: TargetRule[];
5
+ cohorts: VersionCohort[];
6
+ }
7
+ /** Per-service rollout table — a bare cohort array (no rules) or a full spec. */
8
+ export type RolloutTable = Record<string, VersionCohort[] | RolloutSpec>;
9
+ /** A fixed set of manifests (tests, single-shot config). */
10
+ export declare function staticManifestProvider(manifests: RouteManifest[]): ManifestProvider;
11
+ /**
12
+ * Watch a manifests JSON file; atomically swap the in-memory set on a valid
13
+ * change, reject and keep last-good on a bad one. `watch` notifies subscribers
14
+ * with the new set. Poll fallback covers editors/atomic-rename + platforms
15
+ * where fs.watch is unreliable.
16
+ */
17
+ export declare function fileManifestProvider(filePath: string, opts?: {
18
+ pollMs?: number;
19
+ onError?: (e: unknown) => void;
20
+ }): ManifestProvider & {
21
+ stop: () => void;
22
+ };
23
+ /**
24
+ * Rollout-aware pod provider: each service is a set of VERSION COHORTS, and
25
+ * `pick` splits traffic across them by weight (smooth weighted round-robin —
26
+ * a 90/10 canary interleaves, never bursts) and round-robins endpoints WITHIN
27
+ * the chosen cohort. `pick(svc, {version})` pins a cohort (sticky canary /
28
+ * debugging). `update` swaps weights/cohorts live — a rollout ramps
29
+ * (10→25→50→100) or rolls back without redeploying the gateway. The returned
30
+ * Upstream carries `version` so the gateway can surface which cohort served.
31
+ */
32
+ export declare function rolloutPodProvider(table: RolloutTable): PodProvider & {
33
+ update: (next: RolloutTable) => void;
34
+ };
35
+ /**
36
+ * Flat single-version load balancing — the common case (no rollout in
37
+ * progress). A thin wrapper over `rolloutPodProvider` with one synthetic
38
+ * cohort per service, so the rollout path is the only code path. The synthetic
39
+ * version is STRIPPED from the result: flat LB carries no `version`, so a
40
+ * response only gets `x-lensmcp-version` (and a `rollout` trace step) when a
41
+ * genuine multi-cohort rollout served it.
42
+ */
43
+ export declare function endpointsPodProvider(endpoints: Record<string, string[]>): PodProvider & {
44
+ update: (next: Record<string, string[]>) => void;
45
+ };
46
+ //# sourceMappingURL=providers-prod.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"providers-prod.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/providers-prod.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,KAAK,aAAa,EAAE,KAAK,UAAU,EAChH,MAAM,YAAY,CAAC;AAEpB,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,aAAa,EAAE,CAAA;CAAE;AAC/E,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,WAAW,CAAC,CAAC;AAIzE,4DAA4D;AAC5D,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,gBAAgB,CAEnF;AAgBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CAAO,GAC7D,gBAAgB,GAAG;IAAE,IAAI,EAAE,MAAM,IAAI,CAAA;CAAE,CA+BzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,YAAY,GAClB,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;CAAE,CAkDxD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAClC,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,CAapE"}