@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,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route manifests + the provider seams that make the gateway ONE library
|
|
3
|
+
* across dev and prod.
|
|
4
|
+
*
|
|
5
|
+
* The dev gateway and the production gateway share this routing core. A
|
|
6
|
+
* service DESCRIBES itself with a `RouteManifest` (in dev, synthesized from
|
|
7
|
+
* the project.json `cluster` field; in prod, published by the service to a
|
|
8
|
+
* registry / served at `/.well-known/lensmcp-routes`). The gateway builds a
|
|
9
|
+
* flat `Route[]` from the manifests and matches requests against it — the
|
|
10
|
+
* SAME `matchRoute` in both environments, so routing can never drift.
|
|
11
|
+
*
|
|
12
|
+
* What differs between dev and prod is supplied through two interfaces:
|
|
13
|
+
* - ManifestProvider — WHERE the manifests come from (workspace scan vs
|
|
14
|
+
* registry watch) and how changes arrive.
|
|
15
|
+
* - PodProvider — HOW a service name resolves to a concrete upstream
|
|
16
|
+
* (unix-socket pool with scale-from-zero vs a TCP endpoint).
|
|
17
|
+
*
|
|
18
|
+
* This module is PURE (no IO, no process, no http) so it is exhaustively
|
|
19
|
+
* unit-testable; the providers carry the environment-specific machinery.
|
|
20
|
+
*/
|
|
21
|
+
/** A host[+path] a service is mounted at (e.g. `api.tetros.ai` + `/crm`). */
|
|
22
|
+
export interface RouteMount {
|
|
23
|
+
host: string;
|
|
24
|
+
/** Path prefix under the host; omit for the host root. */
|
|
25
|
+
path?: string;
|
|
26
|
+
}
|
|
27
|
+
/** How the edge guards a route. `jwt` runs the configured authenticator
|
|
28
|
+
* (dev middleware seam / prod JWKS); `internal` requires a valid x-api-key;
|
|
29
|
+
* `public` is unguarded. */
|
|
30
|
+
export type AuthMode = 'jwt' | 'public' | 'internal';
|
|
31
|
+
/**
|
|
32
|
+
* A per-path auth refinement. `mode` overrides the service default for paths under
|
|
33
|
+
* `path` (longest / first match wins — callers emit most-specific-first). For `jwt`
|
|
34
|
+
* paths an optional ABAC requirement gates the request at the edge:
|
|
35
|
+
* - `permission` is sugar for "the verified token's `perms` claim contains it"
|
|
36
|
+
* (→ `{ attr:'perms', op:'contains', value }`).
|
|
37
|
+
* - `rule` is the full predicate over the request {@link Attrs} (verified claims + ip/headers).
|
|
38
|
+
* - `methods` scopes the override to specific HTTP methods — permissions are per-handler
|
|
39
|
+
* (POST needs `campaigns.write`, GET needs `campaigns.read`). Omit = all methods.
|
|
40
|
+
* (`RuleNode`/`Attrs` are declared with the targeting engine below.)
|
|
41
|
+
*/
|
|
42
|
+
export interface AuthOverride {
|
|
43
|
+
path: string;
|
|
44
|
+
mode: AuthMode;
|
|
45
|
+
methods?: string[];
|
|
46
|
+
permission?: string;
|
|
47
|
+
rule?: RuleNode;
|
|
48
|
+
}
|
|
49
|
+
export interface AuthPolicy {
|
|
50
|
+
default?: AuthMode;
|
|
51
|
+
overrides?: AuthOverride[];
|
|
52
|
+
}
|
|
53
|
+
/** Per-route rate limit (carried on the manifest; enforced by the prod
|
|
54
|
+
* provider's Redis token buckets — the core just transports it). */
|
|
55
|
+
export interface RateLimit {
|
|
56
|
+
/** Path prefix this limit applies to; omit for the whole service. */
|
|
57
|
+
path?: string;
|
|
58
|
+
/** Bucket key dimension, e.g. `ip` | `tenant` | `user`. */
|
|
59
|
+
key: string;
|
|
60
|
+
points: number;
|
|
61
|
+
duration: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A service's self-description — the canonical thing the gateway routes from.
|
|
65
|
+
* In dev this is synthesized from project.json; in prod the service publishes
|
|
66
|
+
* it. One manifest per service, with one or more mounts.
|
|
67
|
+
*/
|
|
68
|
+
export interface RouteManifest {
|
|
69
|
+
service: string;
|
|
70
|
+
/** Build/image version — lets a gateway log/track which manifest it serves. */
|
|
71
|
+
version?: string;
|
|
72
|
+
mounts: RouteMount[];
|
|
73
|
+
/** Default auth for the service + per-path overrides (incl. edge permission rules). */
|
|
74
|
+
auth?: AuthPolicy;
|
|
75
|
+
/** Expose `internal.<host>` (east-west, x-api-key) for this service. */
|
|
76
|
+
internal?: boolean;
|
|
77
|
+
/** Override the auto `internal.<host>` name, or `false` to disable it. */
|
|
78
|
+
internalHost?: string | false;
|
|
79
|
+
/** This service is the catch-all for unclaimed hostnames. */
|
|
80
|
+
default?: boolean;
|
|
81
|
+
/** Rewrite forwarded URLs (e.g. mount at root in dev, `/api` downstream). */
|
|
82
|
+
prependPrefix?: string;
|
|
83
|
+
/** TCP upstream URL (apps/vite, or a prod service addressed directly).
|
|
84
|
+
* When absent, the PodProvider resolves `service` to an upstream. */
|
|
85
|
+
upstream?: string;
|
|
86
|
+
rateLimits?: RateLimit[];
|
|
87
|
+
}
|
|
88
|
+
/** Where a request is forwarded: a unix socket (dev pods) or a TCP url.
|
|
89
|
+
* `version` (when set) is the rollout cohort that served it — surfaced on the
|
|
90
|
+
* trace + `x-lensmcp-version` so a canary's traffic is visible. */
|
|
91
|
+
export type Upstream = ({
|
|
92
|
+
socketPath: string;
|
|
93
|
+
} | {
|
|
94
|
+
url: string;
|
|
95
|
+
}) & {
|
|
96
|
+
version?: string;
|
|
97
|
+
};
|
|
98
|
+
/** A discovered version of a service + the endpoints serving it. The unit of
|
|
99
|
+
* progressive rollout: weights across cohorts split traffic (canary 90/10,
|
|
100
|
+
* blue/green 0→100). */
|
|
101
|
+
export interface VersionCohort {
|
|
102
|
+
version: string;
|
|
103
|
+
/** Relative traffic share. 0 = drain (receive no new traffic). */
|
|
104
|
+
weight: number;
|
|
105
|
+
urls: string[];
|
|
106
|
+
}
|
|
107
|
+
/** Supplies + watches the manifest source. */
|
|
108
|
+
export interface ManifestProvider {
|
|
109
|
+
/** The current manifests (synchronously). */
|
|
110
|
+
list(): RouteManifest[];
|
|
111
|
+
/** Subscribe to changes; returns an unsubscribe. Optional (static sources). */
|
|
112
|
+
watch?(cb: (manifests: RouteManifest[]) => void): () => void;
|
|
113
|
+
}
|
|
114
|
+
/** Resolves a service to a concrete upstream, with optional scale-from-zero. */
|
|
115
|
+
export interface PodProvider {
|
|
116
|
+
/** Pick an upstream for this service. `opts.version` pins a rollout cohort
|
|
117
|
+
* (sticky canary / debugging); otherwise traffic is split by cohort weight. */
|
|
118
|
+
pick(service: string, opts?: {
|
|
119
|
+
version?: string;
|
|
120
|
+
}): Upstream | undefined;
|
|
121
|
+
/** Bring a scaled-to-zero service up (dev). Prod returns true (always on). */
|
|
122
|
+
ensureUp?(service: string): Promise<boolean>;
|
|
123
|
+
/** Drop a dead upstream so the next pick avoids it (refused socket / 502). */
|
|
124
|
+
drop?(service: string, upstream: Upstream): void;
|
|
125
|
+
/** Evaluate this service's targeting rules against a request's attributes;
|
|
126
|
+
* the first matching rule's version (sticky-by-attribute). */
|
|
127
|
+
resolveVersion?(service: string, attrs: Attrs): {
|
|
128
|
+
version: string;
|
|
129
|
+
rule?: string;
|
|
130
|
+
} | undefined;
|
|
131
|
+
/** Consistent-hash a stable device/user key to a cohort version — the
|
|
132
|
+
* sticky weighted split (same key → same version across requests + ramps). */
|
|
133
|
+
bucket?(service: string, key: string): {
|
|
134
|
+
version: string;
|
|
135
|
+
} | undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Smooth weighted round-robin (nginx-style). Returns a stateful selector that,
|
|
139
|
+
* over time, hands out indices in proportion to `weights` — but INTERLEAVED
|
|
140
|
+
* (90/10 yields the rare cohort roughly every tenth pick, not in a burst at
|
|
141
|
+
* the end). Deterministic given the weights, so rollout splits are testable.
|
|
142
|
+
* A zero/negative total (all cohorts drained) falls back to index 0.
|
|
143
|
+
*/
|
|
144
|
+
export declare function smoothWeightedSelector(weights: readonly number[]): () => number;
|
|
145
|
+
/** A flat routing entry built from manifests — provider-agnostic. The pod
|
|
146
|
+
* resolution happens at request time via the PodProvider, keyed by `service`. */
|
|
147
|
+
export interface Route {
|
|
148
|
+
service: string;
|
|
149
|
+
host?: string;
|
|
150
|
+
prefix?: string;
|
|
151
|
+
prependPrefix?: string;
|
|
152
|
+
internal?: boolean;
|
|
153
|
+
default?: boolean;
|
|
154
|
+
/** Direct TCP upstream (app/vite/prod-direct) — bypasses pod resolution. */
|
|
155
|
+
upstream?: string;
|
|
156
|
+
/** Resolved auth for the route root (overrides refine per-path at match). */
|
|
157
|
+
auth: AuthMode;
|
|
158
|
+
}
|
|
159
|
+
/** Exact hostname or `*.suffix` wildcard (the bare suffix also matches). */
|
|
160
|
+
export declare function hostMatches(pattern: string, host: string): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* THE matcher — shared by dev and prod. Generic over the route shape so the
|
|
163
|
+
* dev gateway can pass its pod-rich routes and prod its upstream routes; only
|
|
164
|
+
* `host`/`prefix` are read here.
|
|
165
|
+
*
|
|
166
|
+
* Rules (each one a fixed bug):
|
|
167
|
+
* - A host CLAIMED by any declaration never falls through to the catch-all
|
|
168
|
+
* default; its unmatched paths belong to the gateway's own infra.
|
|
169
|
+
* - An UNCLAIMED host may only reach the host-less `default` route — never a
|
|
170
|
+
* host route by accident of sort order (unknown host → default or 404).
|
|
171
|
+
* - An EXACT host match beats a wildcard (`internal.api.x` is served by its
|
|
172
|
+
* own route, not a co-existing `*.x` app).
|
|
173
|
+
* - Within the candidates, the most specific path prefix wins (callers sort
|
|
174
|
+
* routes by specificity: host+path before host before catch-all).
|
|
175
|
+
*/
|
|
176
|
+
export declare function matchRoute<R extends {
|
|
177
|
+
host?: string;
|
|
178
|
+
prefix?: string;
|
|
179
|
+
}>(routes: readonly R[], host: string, url: string): R | undefined;
|
|
180
|
+
/** Specificity sort: host+path beats host-only beats catch-all. Mutates+returns. */
|
|
181
|
+
export declare function sortBySpecificity<R extends {
|
|
182
|
+
host?: string;
|
|
183
|
+
prefix?: string;
|
|
184
|
+
}>(routes: R[]): R[];
|
|
185
|
+
/** Resolve the auth mode for a concrete request path against a manifest. */
|
|
186
|
+
export declare function authForPath(manifest: RouteManifest, path: string, internal: boolean): AuthMode;
|
|
187
|
+
/** The auth mode for a request + the ABAC predicate (if any) to enforce at the edge. */
|
|
188
|
+
export interface ResolvedAuth {
|
|
189
|
+
mode: AuthMode;
|
|
190
|
+
rule?: RuleNode;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the auth mode + any edge ABAC rule for a concrete (path, method) against an
|
|
194
|
+
* {@link AuthPolicy} — the shared core both gateways use. First override whose `path` is a
|
|
195
|
+
* prefix and whose `methods` include the method wins (callers emit most-specific-first);
|
|
196
|
+
* `permission` is compiled to a `perms contains` condition.
|
|
197
|
+
*/
|
|
198
|
+
/**
|
|
199
|
+
* Segment-prefix match where a pattern segment of `*` or `:param` matches ANY one path segment —
|
|
200
|
+
* so a route override like `/engage/tenants/:companyId/campaigns` matches the real request
|
|
201
|
+
* `/engage/tenants/abc-123/campaigns` (and anything deeper). Stricter than `startsWith` (it won't
|
|
202
|
+
* cross a segment boundary), which is also more correct. Lets `@RequirePermission` enforce on
|
|
203
|
+
* tenant-scoped (`:param`) routes, not just static prefixes.
|
|
204
|
+
*/
|
|
205
|
+
export declare function authPathMatches(pattern: string, path: string): boolean;
|
|
206
|
+
export declare function authRuleFor(policy: AuthPolicy | undefined, path: string, method: string): ResolvedAuth;
|
|
207
|
+
/**
|
|
208
|
+
* Resolve the auth mode AND any edge ABAC rule for a concrete (path, method) on a manifest —
|
|
209
|
+
* the permission-aware companion to {@link authForPath}. Internal routes are always `internal`;
|
|
210
|
+
* otherwise delegates to {@link authRuleFor}. The gateway verifies the JWT for `jwt` mode and,
|
|
211
|
+
* when a `rule` is present, requires `evalRule(rule, attrs)` (else 403).
|
|
212
|
+
*/
|
|
213
|
+
export declare function authRuleForPath(manifest: RouteManifest, path: string, method: string, internal: boolean): ResolvedAuth;
|
|
214
|
+
/**
|
|
215
|
+
* Build the flat route table from manifests. Each mount becomes a host route;
|
|
216
|
+
* a service flagged `internal` also gets an `internal.<host>` route per mount;
|
|
217
|
+
* a `default` service contributes the host-less catch-all. Sorted by
|
|
218
|
+
* specificity so `matchRoute` picks the most specific.
|
|
219
|
+
*/
|
|
220
|
+
export declare function buildRouteTable(manifests: readonly RouteManifest[]): Route[];
|
|
221
|
+
/** Synthesize a manifest from a dev project.json `cluster` declaration —
|
|
222
|
+
* the dev ManifestProvider's adapter, and the seed for `lensmcp`-generated
|
|
223
|
+
* prod manifests (same fields, prod adds version/rateLimits). */
|
|
224
|
+
export declare function manifestFromClusterDecl(project: string, decl: {
|
|
225
|
+
host?: string;
|
|
226
|
+
path?: string;
|
|
227
|
+
service?: string;
|
|
228
|
+
upstream?: string;
|
|
229
|
+
port?: number;
|
|
230
|
+
prependPrefix?: string;
|
|
231
|
+
default?: boolean;
|
|
232
|
+
internalHost?: string | false;
|
|
233
|
+
auth?: AuthPolicy;
|
|
234
|
+
}): RouteManifest | undefined;
|
|
235
|
+
/** A flat, read-only attribute bag assembled per request: verified JWT claims,
|
|
236
|
+
* `ip`, `header.<name>`, `device` (sticky key), `path`, `method`. */
|
|
237
|
+
export type Attrs = Record<string, unknown>;
|
|
238
|
+
export type RuleOp = 'eq' | 'ne' | 'in' | 'nin' | 'prefix' | 'suffix' | 'contains' | 'regex' | 'exists' | 'gt' | 'lt' | 'cidr';
|
|
239
|
+
export interface Condition {
|
|
240
|
+
attr: string;
|
|
241
|
+
op: RuleOp;
|
|
242
|
+
value?: unknown;
|
|
243
|
+
}
|
|
244
|
+
export interface RuleGroup {
|
|
245
|
+
all?: RuleNode[];
|
|
246
|
+
any?: RuleNode[];
|
|
247
|
+
not?: RuleNode;
|
|
248
|
+
}
|
|
249
|
+
export type RuleNode = Condition | RuleGroup;
|
|
250
|
+
/** A targeting rule: when the predicate matches the request attributes, pin `version`. */
|
|
251
|
+
export interface TargetRule {
|
|
252
|
+
name?: string;
|
|
253
|
+
when: RuleNode;
|
|
254
|
+
version: string;
|
|
255
|
+
}
|
|
256
|
+
/** `ip ∈ cidr` for IPv4 (handles IPv4-mapped IPv6). False for IPv6/malformed. */
|
|
257
|
+
export declare function matchCidr(ip: string, cidr: string): boolean;
|
|
258
|
+
/** Evaluate a rule node (condition or all/any/not group) against attributes. Pure. */
|
|
259
|
+
export declare function evalRule(node: RuleNode, attrs: Attrs): boolean;
|
|
260
|
+
/** First matching rule's version (top-to-bottom), or undefined. */
|
|
261
|
+
export declare function pickTargetedVersion(rules: readonly TargetRule[], attrs: Attrs): {
|
|
262
|
+
version: string;
|
|
263
|
+
rule?: string;
|
|
264
|
+
} | undefined;
|
|
265
|
+
/** 32-bit FNV-1a hash → unsigned int. Stable across processes (no seed). */
|
|
266
|
+
export declare function fnv1a(s: string): number;
|
|
267
|
+
/**
|
|
268
|
+
* Consistent-hash a stable key to a cohort INDEX by weight. The hash point uses
|
|
269
|
+
* a FIXED modulus (10000) so it is stable per key regardless of the weights;
|
|
270
|
+
* only the cohort thresholds move when weights change — so ramping a weight
|
|
271
|
+
* moves a key across the boundary AT MOST ONCE (no reshuffle/flapping). Fixed
|
|
272
|
+
* cohort order. All-zero weights → 0.
|
|
273
|
+
*/
|
|
274
|
+
export declare function bucketByWeight(key: string, weights: readonly number[]): number;
|
|
275
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/manifest.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Route manifests + the provider seams that make the gateway ONE library
|
|
4
|
+
* across dev and prod.
|
|
5
|
+
*
|
|
6
|
+
* The dev gateway and the production gateway share this routing core. A
|
|
7
|
+
* service DESCRIBES itself with a `RouteManifest` (in dev, synthesized from
|
|
8
|
+
* the project.json `cluster` field; in prod, published by the service to a
|
|
9
|
+
* registry / served at `/.well-known/lensmcp-routes`). The gateway builds a
|
|
10
|
+
* flat `Route[]` from the manifests and matches requests against it — the
|
|
11
|
+
* SAME `matchRoute` in both environments, so routing can never drift.
|
|
12
|
+
*
|
|
13
|
+
* What differs between dev and prod is supplied through two interfaces:
|
|
14
|
+
* - ManifestProvider — WHERE the manifests come from (workspace scan vs
|
|
15
|
+
* registry watch) and how changes arrive.
|
|
16
|
+
* - PodProvider — HOW a service name resolves to a concrete upstream
|
|
17
|
+
* (unix-socket pool with scale-from-zero vs a TCP endpoint).
|
|
18
|
+
*
|
|
19
|
+
* This module is PURE (no IO, no process, no http) so it is exhaustively
|
|
20
|
+
* unit-testable; the providers carry the environment-specific machinery.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.smoothWeightedSelector = smoothWeightedSelector;
|
|
24
|
+
exports.hostMatches = hostMatches;
|
|
25
|
+
exports.matchRoute = matchRoute;
|
|
26
|
+
exports.sortBySpecificity = sortBySpecificity;
|
|
27
|
+
exports.authForPath = authForPath;
|
|
28
|
+
exports.authPathMatches = authPathMatches;
|
|
29
|
+
exports.authRuleFor = authRuleFor;
|
|
30
|
+
exports.authRuleForPath = authRuleForPath;
|
|
31
|
+
exports.buildRouteTable = buildRouteTable;
|
|
32
|
+
exports.manifestFromClusterDecl = manifestFromClusterDecl;
|
|
33
|
+
exports.matchCidr = matchCidr;
|
|
34
|
+
exports.evalRule = evalRule;
|
|
35
|
+
exports.pickTargetedVersion = pickTargetedVersion;
|
|
36
|
+
exports.fnv1a = fnv1a;
|
|
37
|
+
exports.bucketByWeight = bucketByWeight;
|
|
38
|
+
/**
|
|
39
|
+
* Smooth weighted round-robin (nginx-style). Returns a stateful selector that,
|
|
40
|
+
* over time, hands out indices in proportion to `weights` — but INTERLEAVED
|
|
41
|
+
* (90/10 yields the rare cohort roughly every tenth pick, not in a burst at
|
|
42
|
+
* the end). Deterministic given the weights, so rollout splits are testable.
|
|
43
|
+
* A zero/negative total (all cohorts drained) falls back to index 0.
|
|
44
|
+
*/
|
|
45
|
+
function smoothWeightedSelector(weights) {
|
|
46
|
+
const cur = weights.map(() => 0);
|
|
47
|
+
const total = weights.reduce((s, w) => s + Math.max(0, w), 0);
|
|
48
|
+
return () => {
|
|
49
|
+
if (total <= 0 || weights.length === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
let best = 0;
|
|
52
|
+
for (let i = 0; i < weights.length; i++) {
|
|
53
|
+
cur[i] += Math.max(0, weights[i]);
|
|
54
|
+
if (cur[i] > cur[best])
|
|
55
|
+
best = i;
|
|
56
|
+
}
|
|
57
|
+
cur[best] -= total;
|
|
58
|
+
return best;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Exact hostname or `*.suffix` wildcard (the bare suffix also matches). */
|
|
62
|
+
function hostMatches(pattern, host) {
|
|
63
|
+
return pattern.startsWith('*.')
|
|
64
|
+
? host.endsWith(pattern.slice(1)) || host === pattern.slice(2)
|
|
65
|
+
: host === pattern;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* THE matcher — shared by dev and prod. Generic over the route shape so the
|
|
69
|
+
* dev gateway can pass its pod-rich routes and prod its upstream routes; only
|
|
70
|
+
* `host`/`prefix` are read here.
|
|
71
|
+
*
|
|
72
|
+
* Rules (each one a fixed bug):
|
|
73
|
+
* - A host CLAIMED by any declaration never falls through to the catch-all
|
|
74
|
+
* default; its unmatched paths belong to the gateway's own infra.
|
|
75
|
+
* - An UNCLAIMED host may only reach the host-less `default` route — never a
|
|
76
|
+
* host route by accident of sort order (unknown host → default or 404).
|
|
77
|
+
* - An EXACT host match beats a wildcard (`internal.api.x` is served by its
|
|
78
|
+
* own route, not a co-existing `*.x` app).
|
|
79
|
+
* - Within the candidates, the most specific path prefix wins (callers sort
|
|
80
|
+
* routes by specificity: host+path before host before catch-all).
|
|
81
|
+
*/
|
|
82
|
+
function matchRoute(routes, host, url) {
|
|
83
|
+
const claimed = routes.some((r) => r.host && hostMatches(r.host, host));
|
|
84
|
+
let candidates = claimed
|
|
85
|
+
? routes.filter((r) => r.host && hostMatches(r.host, host))
|
|
86
|
+
: routes.filter((r) => !r.host);
|
|
87
|
+
if (candidates.some((r) => r.host === host)) {
|
|
88
|
+
candidates = candidates.filter((r) => r.host === host);
|
|
89
|
+
}
|
|
90
|
+
return candidates.find((r) => !r.prefix || url.startsWith(r.prefix));
|
|
91
|
+
}
|
|
92
|
+
/** Specificity sort: host+path beats host-only beats catch-all. Mutates+returns. */
|
|
93
|
+
function sortBySpecificity(routes) {
|
|
94
|
+
routes.sort((a, b) => ((b.host ? 2 : 0) + (b.prefix ? 1 : 0)) - ((a.host ? 2 : 0) + (a.prefix ? 1 : 0)));
|
|
95
|
+
return routes;
|
|
96
|
+
}
|
|
97
|
+
/** Resolve the auth mode for a concrete request path against a manifest. */
|
|
98
|
+
function authForPath(manifest, path, internal) {
|
|
99
|
+
if (internal)
|
|
100
|
+
return 'internal';
|
|
101
|
+
const overrides = manifest.auth?.overrides ?? [];
|
|
102
|
+
for (const o of overrides)
|
|
103
|
+
if (path.startsWith(o.path))
|
|
104
|
+
return o.mode;
|
|
105
|
+
return manifest.auth?.default ?? 'jwt';
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the auth mode + any edge ABAC rule for a concrete (path, method) against an
|
|
109
|
+
* {@link AuthPolicy} — the shared core both gateways use. First override whose `path` is a
|
|
110
|
+
* prefix and whose `methods` include the method wins (callers emit most-specific-first);
|
|
111
|
+
* `permission` is compiled to a `perms contains` condition.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Segment-prefix match where a pattern segment of `*` or `:param` matches ANY one path segment —
|
|
115
|
+
* so a route override like `/engage/tenants/:companyId/campaigns` matches the real request
|
|
116
|
+
* `/engage/tenants/abc-123/campaigns` (and anything deeper). Stricter than `startsWith` (it won't
|
|
117
|
+
* cross a segment boundary), which is also more correct. Lets `@RequirePermission` enforce on
|
|
118
|
+
* tenant-scoped (`:param`) routes, not just static prefixes.
|
|
119
|
+
*/
|
|
120
|
+
function authPathMatches(pattern, path) {
|
|
121
|
+
const ps = pattern.split('/').filter(Boolean);
|
|
122
|
+
const xs = (path.split('?')[0] ?? '').split('/').filter(Boolean);
|
|
123
|
+
if (xs.length < ps.length)
|
|
124
|
+
return false;
|
|
125
|
+
for (let i = 0; i < ps.length; i++) {
|
|
126
|
+
const seg = ps[i];
|
|
127
|
+
if (seg === '*' || seg.startsWith(':'))
|
|
128
|
+
continue;
|
|
129
|
+
if (seg !== xs[i])
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
function authRuleFor(policy, path, method) {
|
|
135
|
+
const m = method.toUpperCase();
|
|
136
|
+
for (const o of policy?.overrides ?? []) {
|
|
137
|
+
if (!authPathMatches(o.path, path))
|
|
138
|
+
continue;
|
|
139
|
+
if (o.methods && !o.methods.some((x) => x.toUpperCase() === m))
|
|
140
|
+
continue;
|
|
141
|
+
const rule = o.rule ?? (o.permission ? { attr: 'perms', op: 'contains', value: o.permission } : undefined);
|
|
142
|
+
return { mode: o.mode, ...(rule ? { rule } : {}) };
|
|
143
|
+
}
|
|
144
|
+
return { mode: policy?.default ?? 'jwt' };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the auth mode AND any edge ABAC rule for a concrete (path, method) on a manifest —
|
|
148
|
+
* the permission-aware companion to {@link authForPath}. Internal routes are always `internal`;
|
|
149
|
+
* otherwise delegates to {@link authRuleFor}. The gateway verifies the JWT for `jwt` mode and,
|
|
150
|
+
* when a `rule` is present, requires `evalRule(rule, attrs)` (else 403).
|
|
151
|
+
*/
|
|
152
|
+
function authRuleForPath(manifest, path, method, internal) {
|
|
153
|
+
if (internal)
|
|
154
|
+
return { mode: 'internal' };
|
|
155
|
+
return authRuleFor(manifest.auth, path, method);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Build the flat route table from manifests. Each mount becomes a host route;
|
|
159
|
+
* a service flagged `internal` also gets an `internal.<host>` route per mount;
|
|
160
|
+
* a `default` service contributes the host-less catch-all. Sorted by
|
|
161
|
+
* specificity so `matchRoute` picks the most specific.
|
|
162
|
+
*/
|
|
163
|
+
function buildRouteTable(manifests) {
|
|
164
|
+
const routes = [];
|
|
165
|
+
for (const m of manifests) {
|
|
166
|
+
const baseAuth = m.auth?.default ?? 'jwt';
|
|
167
|
+
for (const mount of m.mounts) {
|
|
168
|
+
const base = {
|
|
169
|
+
service: m.service,
|
|
170
|
+
...(mount.path ? { prefix: mount.path } : {}),
|
|
171
|
+
...(m.prependPrefix ? { prependPrefix: m.prependPrefix } : {}),
|
|
172
|
+
...(m.upstream ? { upstream: m.upstream } : {}),
|
|
173
|
+
};
|
|
174
|
+
routes.push({ ...base, host: mount.host, auth: baseAuth });
|
|
175
|
+
if (m.internal && m.internalHost !== false) {
|
|
176
|
+
routes.push({
|
|
177
|
+
...base,
|
|
178
|
+
host: typeof m.internalHost === 'string' ? m.internalHost : `internal.${mount.host}`,
|
|
179
|
+
internal: true,
|
|
180
|
+
auth: 'internal',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (m.default) {
|
|
185
|
+
routes.push({
|
|
186
|
+
service: m.service,
|
|
187
|
+
...(m.prependPrefix ? { prependPrefix: m.prependPrefix } : {}),
|
|
188
|
+
...(m.upstream ? { upstream: m.upstream } : {}),
|
|
189
|
+
default: true,
|
|
190
|
+
auth: baseAuth,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return sortBySpecificity(routes);
|
|
195
|
+
}
|
|
196
|
+
/** Synthesize a manifest from a dev project.json `cluster` declaration —
|
|
197
|
+
* the dev ManifestProvider's adapter, and the seed for `lensmcp`-generated
|
|
198
|
+
* prod manifests (same fields, prod adds version/rateLimits). */
|
|
199
|
+
function manifestFromClusterDecl(project, decl) {
|
|
200
|
+
const upstream = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
|
|
201
|
+
// A service must route SOMEWHERE: either a pod service name or a TCP upstream.
|
|
202
|
+
if (!decl.service && !upstream)
|
|
203
|
+
return undefined;
|
|
204
|
+
return {
|
|
205
|
+
service: decl.service ?? project,
|
|
206
|
+
mounts: decl.host ? [{ host: decl.host, ...(decl.path ? { path: decl.path } : {}) }] : [],
|
|
207
|
+
// pod services get an internal east-west host by default; TCP upstreams don't.
|
|
208
|
+
internal: !!decl.service,
|
|
209
|
+
...(decl.internalHost !== undefined ? { internalHost: decl.internalHost } : {}),
|
|
210
|
+
...(decl.default ? { default: true } : {}),
|
|
211
|
+
...(decl.prependPrefix ? { prependPrefix: decl.prependPrefix } : {}),
|
|
212
|
+
...(decl.auth ? { auth: decl.auth } : {}),
|
|
213
|
+
...(upstream ? { upstream } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const isGroup = (n) => 'all' in n || 'any' in n || 'not' in n;
|
|
217
|
+
const reCache = new Map();
|
|
218
|
+
const compileRe = (src) => {
|
|
219
|
+
if (src.length > 512)
|
|
220
|
+
return null; // ReDoS guard — bound pattern length
|
|
221
|
+
if (reCache.has(src))
|
|
222
|
+
return reCache.get(src);
|
|
223
|
+
let re;
|
|
224
|
+
try {
|
|
225
|
+
re = new RegExp(src);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
re = null;
|
|
229
|
+
}
|
|
230
|
+
reCache.set(src, re);
|
|
231
|
+
return re;
|
|
232
|
+
};
|
|
233
|
+
/** IPv4 dotted-quad → uint32, or null if malformed. */
|
|
234
|
+
function ipToInt(ip) {
|
|
235
|
+
const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip; // IPv4-mapped IPv6
|
|
236
|
+
const parts = v4.split('.');
|
|
237
|
+
if (parts.length !== 4)
|
|
238
|
+
return null;
|
|
239
|
+
let n = 0;
|
|
240
|
+
for (const p of parts) {
|
|
241
|
+
const o = Number(p);
|
|
242
|
+
if (!Number.isInteger(o) || o < 0 || o > 255 || (p.length > 1 && p[0] === '0'))
|
|
243
|
+
return null;
|
|
244
|
+
n = n * 256 + o;
|
|
245
|
+
}
|
|
246
|
+
return n >>> 0;
|
|
247
|
+
}
|
|
248
|
+
/** `ip ∈ cidr` for IPv4 (handles IPv4-mapped IPv6). False for IPv6/malformed. */
|
|
249
|
+
function matchCidr(ip, cidr) {
|
|
250
|
+
const slash = cidr.indexOf('/');
|
|
251
|
+
if (slash < 0)
|
|
252
|
+
return false;
|
|
253
|
+
const bits = Number(cidr.slice(slash + 1));
|
|
254
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > 32)
|
|
255
|
+
return false;
|
|
256
|
+
const ipN = ipToInt(ip);
|
|
257
|
+
const rangeN = ipToInt(cidr.slice(0, slash));
|
|
258
|
+
if (ipN === null || rangeN === null)
|
|
259
|
+
return false;
|
|
260
|
+
if (bits === 0)
|
|
261
|
+
return true;
|
|
262
|
+
const mask = (bits === 32 ? 0xffffffff : ~((1 << (32 - bits)) - 1)) >>> 0;
|
|
263
|
+
return ((ipN & mask) >>> 0) === ((rangeN & mask) >>> 0);
|
|
264
|
+
}
|
|
265
|
+
const str = (v) => (v === null || v === undefined ? '' : String(v));
|
|
266
|
+
const eq = (a, b) => str(a) === str(b);
|
|
267
|
+
/** Evaluate one condition against the attributes. Undefined attribute → false
|
|
268
|
+
* for every op except `ne` (absent ≠ value → true) and `exists` (→ false). */
|
|
269
|
+
function evalCondition(c, attrs) {
|
|
270
|
+
const v = attrs[c.attr];
|
|
271
|
+
const present = v !== undefined && v !== null;
|
|
272
|
+
switch (c.op) {
|
|
273
|
+
case 'exists': return present;
|
|
274
|
+
case 'ne': return !present || !eq(v, c.value);
|
|
275
|
+
}
|
|
276
|
+
if (!present)
|
|
277
|
+
return false;
|
|
278
|
+
switch (c.op) {
|
|
279
|
+
case 'eq': return eq(v, c.value);
|
|
280
|
+
case 'in': return Array.isArray(c.value) && c.value.some((x) => Array.isArray(v) ? v.some((vv) => eq(vv, x)) : eq(v, x));
|
|
281
|
+
case 'nin': return !(Array.isArray(c.value) && c.value.some((x) => Array.isArray(v) ? v.some((vv) => eq(vv, x)) : eq(v, x)));
|
|
282
|
+
case 'prefix': return str(v).startsWith(str(c.value));
|
|
283
|
+
case 'suffix': return str(v).endsWith(str(c.value));
|
|
284
|
+
case 'contains': return Array.isArray(v) ? v.some((vv) => eq(vv, c.value)) : str(v).includes(str(c.value));
|
|
285
|
+
case 'regex': {
|
|
286
|
+
const re = compileRe(str(c.value));
|
|
287
|
+
return re ? re.test(str(v)) : false;
|
|
288
|
+
}
|
|
289
|
+
case 'gt': return Number(v) > Number(c.value);
|
|
290
|
+
case 'lt': return Number(v) < Number(c.value);
|
|
291
|
+
case 'cidr': return matchCidr(str(v), str(c.value));
|
|
292
|
+
default: return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Evaluate a rule node (condition or all/any/not group) against attributes. Pure. */
|
|
296
|
+
function evalRule(node, attrs) {
|
|
297
|
+
if (!isGroup(node))
|
|
298
|
+
return evalCondition(node, attrs);
|
|
299
|
+
if (node.all && !node.all.every((n) => evalRule(n, attrs)))
|
|
300
|
+
return false;
|
|
301
|
+
if (node.any && !node.any.some((n) => evalRule(n, attrs)))
|
|
302
|
+
return false;
|
|
303
|
+
if (node.not && evalRule(node.not, attrs))
|
|
304
|
+
return false;
|
|
305
|
+
// an empty group ({}) matches nothing meaningful → treat as no-match
|
|
306
|
+
return !!(node.all || node.any || node.not);
|
|
307
|
+
}
|
|
308
|
+
/** First matching rule's version (top-to-bottom), or undefined. */
|
|
309
|
+
function pickTargetedVersion(rules, attrs) {
|
|
310
|
+
for (const r of rules) {
|
|
311
|
+
if (evalRule(r.when, attrs))
|
|
312
|
+
return { version: r.version, ...(r.name ? { rule: r.name } : {}) };
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
/** 32-bit FNV-1a hash → unsigned int. Stable across processes (no seed). */
|
|
317
|
+
function fnv1a(s) {
|
|
318
|
+
let h = 0x811c9dc5;
|
|
319
|
+
for (let i = 0; i < s.length; i++) {
|
|
320
|
+
h ^= s.charCodeAt(i);
|
|
321
|
+
h = Math.imul(h, 0x01000193);
|
|
322
|
+
}
|
|
323
|
+
return h >>> 0;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Consistent-hash a stable key to a cohort INDEX by weight. The hash point uses
|
|
327
|
+
* a FIXED modulus (10000) so it is stable per key regardless of the weights;
|
|
328
|
+
* only the cohort thresholds move when weights change — so ramping a weight
|
|
329
|
+
* moves a key across the boundary AT MOST ONCE (no reshuffle/flapping). Fixed
|
|
330
|
+
* cohort order. All-zero weights → 0.
|
|
331
|
+
*/
|
|
332
|
+
function bucketByWeight(key, weights) {
|
|
333
|
+
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
|
334
|
+
if (total <= 0 || weights.length === 0)
|
|
335
|
+
return 0;
|
|
336
|
+
const point = fnv1a(key) % 10000;
|
|
337
|
+
let acc = 0;
|
|
338
|
+
for (let i = 0; i < weights.length; i++) {
|
|
339
|
+
acc += (Math.max(0, weights[i]) / total) * 10000;
|
|
340
|
+
if (point < acc)
|
|
341
|
+
return i;
|
|
342
|
+
}
|
|
343
|
+
return weights.length - 1;
|
|
344
|
+
}
|