drupal-mcp-connector 2.10.0 → 2.11.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/CHANGELOG.md +68 -0
- package/bin/drupal-mcp-edge.js +13 -3
- package/config/config.example.json +9 -0
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +42 -8
- package/src/lib/config.js +48 -0
- package/src/lib/http-auth.js +25 -1
- package/src/lib/principal.js +159 -4
- package/src/lib/relay/edge.js +369 -38
- package/src/lib/relay/frames.js +35 -9
- package/src/lib/verify.js +10 -0
- package/src/tools/site.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.11.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Expected policy digest on the relay edge (#250).** Optional
|
|
14
|
+
`auth.policies` maps inbound `sub` (then `azp`) to a SHA-256 digest.
|
|
15
|
+
When that table is present, non-diagnostic `tools/call` without a
|
|
16
|
+
mapping is `not_entitled` with no frames (including a call that omits
|
|
17
|
+
the tool name, and even when no agent is connected). The granted digest
|
|
18
|
+
is stamped on `identity.policy` and `correlation.policyDigest`. Caller
|
|
19
|
+
`policy` / `digest` arguments are ignored and stripped. Diagnostic
|
|
20
|
+
tools stay callable. Omitting `auth.policies` keeps the prior path.
|
|
21
|
+
Lab/loopback only — not dual-control promotion, bundle distribution, or
|
|
22
|
+
a hosted-service claim.
|
|
23
|
+
- **Principal → Drupal actor mapping on the relay edge (#247).** Optional
|
|
24
|
+
`auth.actors` maps inbound `sub` (then `azp`) to a Drupal user UUID. When
|
|
25
|
+
that table is present, write-like `tools/call` without a mapping is
|
|
26
|
+
`not_entitled` with no frames. The granted actor is stamped on
|
|
27
|
+
`identity.actor`; JWT `act.sub` is a confirming hint inside `delegators`
|
|
28
|
+
and becomes `identity.delegator`. Caller `actor` / `delegator` arguments
|
|
29
|
+
and spoofable identity headers are ignored and stripped from the framed
|
|
30
|
+
body. JSON:API writes attach `relationships.uid` from the grant. The
|
|
31
|
+
northbound JWT is never the southbound Drupal credential. Omitting
|
|
32
|
+
`auth.actors` keeps writes on the site OAuth consumer's owner. Lab/loopback
|
|
33
|
+
only — not a live revision-uid or public-URL claim.
|
|
34
|
+
- **Authoritative tenant routing on the relay edge (#244).** Optional
|
|
35
|
+
`auth.tenantGrants` maps inbound client id to tenant agent ids. When that
|
|
36
|
+
table is present, the edge selects the tenant from the grant (JWT `azp`),
|
|
37
|
+
not from caller `tenant` / `site` input. A hint for another tenant is
|
|
38
|
+
`not_entitled` with no frames on any tunnel. The granted tenant is stamped
|
|
39
|
+
on `identity.tenant` and on `correlation: { requestId, tenant, target,
|
|
40
|
+
source }`; the caller `tenant` argument is stripped from the framed body.
|
|
41
|
+
`drupal_list_sites` reports grant-sourced `tenants`. Installs without
|
|
42
|
+
`tenantGrants` keep the site-derived unique-agent path. Lab/loopback
|
|
43
|
+
token-resolved tenancy only — not a public hostname or path-prefix claim.
|
|
44
|
+
- **Two-tenant isolation on the relay edge (#242).** The edge keeps one
|
|
45
|
+
agent-channel session per `agentId` instead of replacing the previous
|
|
46
|
+
tenant. Channel records may bind `sites` (catalog names); that bind is
|
|
47
|
+
the tenant boundary. A second unscoped agent is denied `unbound_tenant`,
|
|
48
|
+
overlapping site claims are denied `overlapping_tenant`, and fan-down
|
|
49
|
+
selects the unique agent granted to the northbound principal. Cross-tenant
|
|
50
|
+
hints still fail closed before any frame reaches the wrong process. A
|
|
51
|
+
tenant disconnect rejects only that tenant's in-flight requests. A
|
|
52
|
+
spoofed `mcp-response` from another agent cannot complete the wait.
|
|
53
|
+
A hot-reload that changes a connected agent's `sites` bind drops that
|
|
54
|
+
tunnel on the next request (`no_agent`) instead of routing on the stale
|
|
55
|
+
hello snapshot.
|
|
56
|
+
Fan-down frames carry `{ requestId, tenant, target, source }` correlation
|
|
57
|
+
and still never carry site secrets, the channel token, or the northbound JWT. A single
|
|
58
|
+
unscoped agent remains the previous compatibility path. Lab/loopback
|
|
59
|
+
proof only — not a hosted-service or public-URL claim.
|
|
60
|
+
|
|
61
|
+
## [2.10.1] - 2026-08-28
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
- **Node reads include the numeric Drupal node ID (#237).** JSON:API-backed
|
|
65
|
+
`drupal_get_node` and `drupal_entity_get` results retain
|
|
66
|
+
`fields.drupal_internal__nid`, matching schema discovery while preserving
|
|
67
|
+
entity allowlists and configured field redaction. The canonical top-level
|
|
68
|
+
`id` remains the UUID.
|
|
69
|
+
|
|
70
|
+
### Deprecated
|
|
71
|
+
- **Loopback `shared_bearer` inbound auth (#231).** Startup warns when
|
|
72
|
+
`resolveInboundAuthMode` is `shared_bearer`, naming removal in v3.0.0.
|
|
73
|
+
Resource-server mode is silent. `npm run verify` lists the residual.
|
|
74
|
+
This is the deprecation half only; removal waits for the next major.
|
|
75
|
+
|
|
10
76
|
## [2.10.0] - 2026-08-27
|
|
11
77
|
|
|
12
78
|
### Security
|
|
@@ -1314,6 +1380,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1314
1380
|
- User tools gained explicit PII-access assertions.
|
|
1315
1381
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1316
1382
|
|
|
1383
|
+
[2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
|
|
1384
|
+
[2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
|
|
1317
1385
|
[1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
|
|
1318
1386
|
[0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
|
|
1319
1387
|
[0.9.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.1
|
package/bin/drupal-mcp-edge.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* MCP_CHANNEL_CREDENTIALS_FILE
|
|
17
17
|
* Agent channel credential store (or config
|
|
18
18
|
* relay.channelCredentialsFile). Required. JSON:
|
|
19
|
-
* {"agents": {"<id>": {"tokenSha256": "<hex>"
|
|
19
|
+
* {"agents": {"<id>": {"tokenSha256": "<hex>",
|
|
20
|
+
* "sites": ["site-name"]}}}. `sites` binds the tunnel
|
|
21
|
+
* to catalog names (the tenant boundary). Omit `sites`
|
|
22
|
+
* only for a single-agent install.
|
|
20
23
|
* MCP_EDGE_PORT Northbound port (default: MCP_PORT / config tls.port).
|
|
21
24
|
* MCP_EDGE_AGENT_PORT
|
|
22
25
|
* Agent channel port (default: northbound port + 1).
|
|
@@ -31,12 +34,16 @@
|
|
|
31
34
|
* primary entry point).
|
|
32
35
|
*
|
|
33
36
|
* Config: auth.grants (client id -> [site names]) is mandatory; the edge
|
|
34
|
-
* refuses to start without it.
|
|
37
|
+
* refuses to start without it. Optional auth.tenantGrants (client id ->
|
|
38
|
+
* [tenant agent ids]) makes tenant routing grant-authoritative. Optional
|
|
39
|
+
* auth.actors (sub / azp -> Drupal user UUID) maps the inbound principal
|
|
40
|
+
* to a Drupal actor for write-like tools. Optional auth.policies
|
|
41
|
+
* (sub / azp -> SHA-256 digest) is the expected signed policy on the edge.
|
|
35
42
|
*/
|
|
36
43
|
|
|
37
44
|
import { readFileSync } from "node:fs";
|
|
38
45
|
import process from "node:process";
|
|
39
|
-
import { getInboundGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
|
|
46
|
+
import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
|
|
40
47
|
import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
|
|
41
48
|
import { createRateLimiter } from "../src/lib/rate-limit.js";
|
|
42
49
|
import {
|
|
@@ -121,6 +128,9 @@ try {
|
|
|
121
128
|
edge = await startEdge({
|
|
122
129
|
auth: inboundCfg,
|
|
123
130
|
grants,
|
|
131
|
+
tenantGrants: getInboundTenantGrants(),
|
|
132
|
+
actors: getInboundActors(),
|
|
133
|
+
policies: getInboundPolicies(),
|
|
124
134
|
sites,
|
|
125
135
|
defaultSite: config.defaultSite,
|
|
126
136
|
channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
|
|
@@ -32,6 +32,15 @@
|
|
|
32
32
|
"grants": {
|
|
33
33
|
"_comment": "Optional. Map inbound OAuth client_id (azp) to site names, e.g. \"content-agent\": [\"production\", \"staging\"]. When the map names any client, unknown clients receive no sites. Omit this object (or leave only this comment) to let every resolvable site stay visible; inbound scopes still gate the tool surface."
|
|
34
34
|
},
|
|
35
|
+
"tenantGrants": {
|
|
36
|
+
"_comment": "Optional on the library path; used by drupal-mcp-edge for token-resolved tenancy. Map inbound client_id (azp) to tenant agent ids, e.g. \"content-agent\": [\"tenant-a\"]. Caller tenant/site arguments are hints checked against this grant, never authority. Omit to derive the tenant from the unique agent covering the site grant."
|
|
37
|
+
},
|
|
38
|
+
"actors": {
|
|
39
|
+
"_comment": "Optional. Map inbound sub or client_id (azp) to a Drupal user UUID for write authorship, e.g. \"content-agent\": { \"uuid\": \"11111111-1111-4111-8111-111111111111\", \"delegators\": [\"operator-1\"] }. When present, write-like tools/call without a mapping are not_entitled. JWT act.sub is a confirming hint inside delegators. Caller actor/delegator arguments are never authority. Omit to keep writes on the site OAuth consumer's owner account."
|
|
40
|
+
},
|
|
41
|
+
"policies": {
|
|
42
|
+
"_comment": "Optional. Map inbound sub or client_id (azp) to a SHA-256 digest of the expected signed Sentinel policy bundle, e.g. \"content-agent\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\". When present, non-diagnostic tools/call without a mapping are not_entitled. Caller policy/digest arguments are never authority. Omit to keep the prior path (no digest required at the edge). Local verify/activate stays on mcp_sentinel."
|
|
43
|
+
},
|
|
35
44
|
"revocationFile": "",
|
|
36
45
|
"introspectionUrl": "",
|
|
37
46
|
"introspectionClientIdEnv": "",
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
makeBearerCheck,
|
|
40
40
|
resolveInboundAuthConfig,
|
|
41
41
|
resolveInboundAuthMode,
|
|
42
|
+
inboundAuthDeprecationWarning,
|
|
42
43
|
createInboundHttpsAuth,
|
|
43
44
|
} from "./lib/http-auth.js";
|
|
44
45
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
@@ -404,6 +405,8 @@ if (transport === "stdio") {
|
|
|
404
405
|
console.error(`[drupal-mcp-connector] FATAL: ${inboundMode.reason}`);
|
|
405
406
|
process.exit(1);
|
|
406
407
|
}
|
|
408
|
+
const deprecation = inboundAuthDeprecationWarning(inboundMode.mode);
|
|
409
|
+
if (deprecation) console.error(deprecation);
|
|
407
410
|
|
|
408
411
|
let checkAuth = makeBearerCheck(inboundMode.mode === "shared_bearer" ? authToken : "");
|
|
409
412
|
let authenticate = null;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
11
|
+
import { getRequestIdentity } from "../principal.js";
|
|
11
12
|
import { validateUuid, validateMachineName } from "../validate.js";
|
|
12
13
|
import { parseFieldConfigObject } from "../field-definition.js";
|
|
13
14
|
import { Backend } from "./backend-interface.js";
|
|
@@ -17,8 +18,9 @@ import {
|
|
|
17
18
|
BASE_ATTRIBUTE_FIELDS,
|
|
18
19
|
} from "../canonical.js";
|
|
19
20
|
|
|
20
|
-
// Drupal exposes internal
|
|
21
|
-
//
|
|
21
|
+
// Drupal exposes internal identifiers under drupal_internal__* attributes.
|
|
22
|
+
// They are dropped from canonical `fields` except for the identifiers that
|
|
23
|
+
// governed read/write workflows explicitly need.
|
|
22
24
|
const INTERNAL_ATTR_RE = /^drupal_internal__/;
|
|
23
25
|
|
|
24
26
|
// countEntities() pagination. Drupal core JSON:API returns no total in `meta`,
|
|
@@ -133,8 +135,34 @@ function applyFilter(params, { field, op = "eq", value }) {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
/**
|
|
136
|
-
*
|
|
138
|
+
* Bind JSON:API `uid` from the grant-stamped identity. Caller uid is overwritten.
|
|
139
|
+
* User entities are left unchanged. No actor claim (auth.actors not in effect
|
|
140
|
+
* for this principal) leaves relationships as-is — the prior, pre-DEV-123 path.
|
|
141
|
+
* A *present* actor claim that fails UUID validation fails closed (throws)
|
|
142
|
+
* rather than silently keeping a caller-supplied uid: resolveActor()/
|
|
143
|
+
* normalizeActors() already validate the shape before stamping identity.actor,
|
|
144
|
+
* so this should be unreachable in the normal path — it exists so a future
|
|
145
|
+
* bug upstream of this call can never downgrade into "trust the caller".
|
|
146
|
+
*
|
|
147
|
+
* @param {string} entityType
|
|
148
|
+
* @param {object|undefined} relationships
|
|
149
|
+
* @returns {object|undefined}
|
|
137
150
|
*/
|
|
151
|
+
export function grantActorUid(entityType, relationships) {
|
|
152
|
+
if (entityType === "user") return relationships;
|
|
153
|
+
const uuid = getRequestIdentity()?.actor;
|
|
154
|
+
if (typeof uuid !== "string") return relationships;
|
|
155
|
+
validateUuid(uuid, "actor");
|
|
156
|
+
const base = relationships && typeof relationships === "object" && !Array.isArray(relationships)
|
|
157
|
+
? relationships
|
|
158
|
+
: {};
|
|
159
|
+
return {
|
|
160
|
+
...base,
|
|
161
|
+
uid: { data: { type: "user--user", id: uuid } },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read/write Backend adapter backed by Drupal core JSON:API. */
|
|
138
166
|
export class JsonApiBackend extends Backend {
|
|
139
167
|
/** @param {object} site Site config (must include `_name`). */
|
|
140
168
|
constructor(site) {
|
|
@@ -194,8 +222,9 @@ export class JsonApiBackend extends Backend {
|
|
|
194
222
|
|
|
195
223
|
/**
|
|
196
224
|
* Convert a JSON:API resource object into a CanonicalEntity. Base attributes
|
|
197
|
-
* (title/status/...) are promoted;
|
|
198
|
-
* stripped from `fields`; relationships
|
|
225
|
+
* (title/status/...) are promoted; base fields and unneeded
|
|
226
|
+
* drupal_internal__* attributes are stripped from `fields`; relationships
|
|
227
|
+
* are normalized to canonical refs.
|
|
199
228
|
* @param {object} resource A JSON:API resource object.
|
|
200
229
|
* @returns {import("../canonical.js").CanonicalEntity}
|
|
201
230
|
*/
|
|
@@ -208,10 +237,13 @@ export class JsonApiBackend extends Backend {
|
|
|
208
237
|
Object.entries(attrs).filter(([k]) => {
|
|
209
238
|
if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
|
|
210
239
|
if (INTERNAL_ATTR_RE.test(k)) {
|
|
240
|
+
// Node reads expose the numeric nid advertised by schema discovery
|
|
241
|
+
// while the UUID remains the canonical top-level id (#237).
|
|
211
242
|
// Paragraph ERR attach needs the current revision id (#192).
|
|
212
243
|
// Node / revisionable writes need the working vs live vid (#166).
|
|
213
244
|
// Other drupal_internal__* attributes stay stripped.
|
|
214
|
-
return k === "
|
|
245
|
+
return (entityType === "node" && k === "drupal_internal__nid")
|
|
246
|
+
|| k === "drupal_internal__vid"
|
|
215
247
|
|| (entityType === "paragraph" && k === "drupal_internal__revision_id");
|
|
216
248
|
}
|
|
217
249
|
return true;
|
|
@@ -332,7 +364,8 @@ export class JsonApiBackend extends Backend {
|
|
|
332
364
|
async createEntity({ entityType, bundle, attributes = {}, relationships }) {
|
|
333
365
|
const buildPayload = (attrs) => {
|
|
334
366
|
const payload = { data: { type: `${entityType}--${bundle}`, attributes: attrs } };
|
|
335
|
-
|
|
367
|
+
const rels = grantActorUid(entityType, relationships);
|
|
368
|
+
if (rels) payload.data.relationships = rels;
|
|
336
369
|
return payload;
|
|
337
370
|
};
|
|
338
371
|
const data = await this.writeWithModerationFallback(this.resourcePath(entityType, bundle), "POST", buildPayload, attributes);
|
|
@@ -352,7 +385,8 @@ export class JsonApiBackend extends Backend {
|
|
|
352
385
|
validateUuid(id);
|
|
353
386
|
const buildPayload = (attrs) => {
|
|
354
387
|
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
355
|
-
|
|
388
|
+
const rels = grantActorUid(entityType, relationships);
|
|
389
|
+
if (rels) payload.data.relationships = rels;
|
|
356
390
|
return payload;
|
|
357
391
|
};
|
|
358
392
|
let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
package/src/lib/config.js
CHANGED
|
@@ -244,6 +244,54 @@ export function getInboundGrants() {
|
|
|
244
244
|
return entries.length ? Object.fromEntries(entries) : null;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Server-resolved inbound tenant grants keyed by OAuth client id.
|
|
249
|
+
* When present, the edge routes by tenant agent id, never by caller input.
|
|
250
|
+
* A missing or empty map means tenant is derived from site grants (compat).
|
|
251
|
+
* @returns {object|null}
|
|
252
|
+
*/
|
|
253
|
+
export function getInboundTenantGrants() {
|
|
254
|
+
const grants = loadConfig().auth?.tenantGrants;
|
|
255
|
+
if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
|
|
256
|
+
const entries = Object.entries(grants)
|
|
257
|
+
.filter(([clientId, tenants]) => !clientId.startsWith("_") && Array.isArray(tenants))
|
|
258
|
+
.map(([clientId, tenants]) => [
|
|
259
|
+
clientId,
|
|
260
|
+
[...new Set(
|
|
261
|
+
tenants.map((id) => String(id).trim()).filter((id) => id && !id.startsWith("_")),
|
|
262
|
+
)],
|
|
263
|
+
]);
|
|
264
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Server-resolved inbound actor map keyed by OAuth `sub` or client id.
|
|
269
|
+
* When present, write-like tools require a mapped Drupal user UUID.
|
|
270
|
+
* @returns {object|null}
|
|
271
|
+
*/
|
|
272
|
+
export function getInboundActors() {
|
|
273
|
+
const actors = loadConfig().auth?.actors;
|
|
274
|
+
if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
|
|
275
|
+
const entries = Object.entries(actors)
|
|
276
|
+
.map(([key, value]) => [key.trim(), value])
|
|
277
|
+
.filter(([key]) => key && !key.startsWith("_"));
|
|
278
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Server-resolved inbound policy map keyed by OAuth `sub` or client id.
|
|
283
|
+
* When present, non-diagnostic tools/call require a mapped SHA-256 digest.
|
|
284
|
+
* @returns {object|null}
|
|
285
|
+
*/
|
|
286
|
+
export function getInboundPolicies() {
|
|
287
|
+
const policies = loadConfig().auth?.policies;
|
|
288
|
+
if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
|
|
289
|
+
const entries = Object.entries(policies)
|
|
290
|
+
.map(([key, value]) => [key.trim(), value])
|
|
291
|
+
.filter(([key]) => key && !key.startsWith("_"));
|
|
292
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
247
295
|
// ---------------------------------------------------------------------------
|
|
248
296
|
// Auth headers — never logged, never exposed in tool responses
|
|
249
297
|
// ---------------------------------------------------------------------------
|
package/src/lib/http-auth.js
CHANGED
|
@@ -68,7 +68,7 @@ export function scopesFromClaim(scope) {
|
|
|
68
68
|
/**
|
|
69
69
|
* Freeze a request identity from validated claims. Caller headers are not an input.
|
|
70
70
|
* @param {object} claims
|
|
71
|
-
* @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string}>}
|
|
71
|
+
* @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string, actSub: ?string}>}
|
|
72
72
|
*/
|
|
73
73
|
export function buildIdentity(claims) {
|
|
74
74
|
const scopes = scopesFromClaim(claims.scope ?? claims.scp);
|
|
@@ -95,6 +95,10 @@ export function buildIdentity(claims) {
|
|
|
95
95
|
&& (claims.client_id === undefined || claims.client_id === null)
|
|
96
96
|
? null
|
|
97
97
|
: String(claims.azp ?? claims.client_id),
|
|
98
|
+
actSub: (claims.act && typeof claims.act === "object" && !Array.isArray(claims.act)
|
|
99
|
+
&& typeof claims.act.sub === "string" && claims.act.sub.trim())
|
|
100
|
+
? claims.act.sub.trim()
|
|
101
|
+
: null,
|
|
98
102
|
});
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -479,6 +483,26 @@ export function resolveInboundAuthMode({
|
|
|
479
483
|
};
|
|
480
484
|
}
|
|
481
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Startup deprecation warning for inbound auth mode `shared_bearer` (#231).
|
|
488
|
+
*
|
|
489
|
+
* Removal is the next major (v3.0.0). Resource-server and unauthenticated
|
|
490
|
+
* modes are silent. The warning names the issue and the removal version so
|
|
491
|
+
* a loopback install cannot miss the kill criterion.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} mode
|
|
494
|
+
* @returns {?string} Warning text, or null when this mode is not deprecated.
|
|
495
|
+
*/
|
|
496
|
+
export function inboundAuthDeprecationWarning(mode) {
|
|
497
|
+
if (mode !== "shared_bearer") return null;
|
|
498
|
+
return (
|
|
499
|
+
"[drupal-mcp-connector] WARNING: inbound auth mode shared_bearer is deprecated " +
|
|
500
|
+
"and will be removed in v3.0.0 (#231). Configure an OAuth resource server " +
|
|
501
|
+
"(auth.issuer + auth.audience) or run unauthenticated loopback. " +
|
|
502
|
+
"MCP_ALLOW_UNAUTHENTICATED remains the explicit trusted-proxy opt-in."
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
482
506
|
/**
|
|
483
507
|
* Merge config.auth with environment overrides.
|
|
484
508
|
* @param {object} [cfg]
|
package/src/lib/principal.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* inbound principal and keep the existing site + source-governance filter
|
|
8
8
|
* so a local operator is not hollowed out.
|
|
9
9
|
*
|
|
10
|
-
* Caller-supplied site, environment, tenant, target,
|
|
11
|
-
* hints. They never become authority.
|
|
12
|
-
* not a wildcard.
|
|
10
|
+
* Caller-supplied site, environment, tenant, target, actor, delegator,
|
|
11
|
+
* policy, digest, or scope fields are hints. They never become authority.
|
|
12
|
+
* Empty inbound scopes are no grants, not a wildcard.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -22,6 +22,16 @@ const identityStore = new AsyncLocalStorage();
|
|
|
22
22
|
/** Caller fields that look like a target but are never authority. */
|
|
23
23
|
export const TARGET_HINT_KEYS = Object.freeze(["site", "environment", "tenant", "target"]);
|
|
24
24
|
|
|
25
|
+
const ACTOR_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
|
+
const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
|
|
27
|
+
|
|
28
|
+
function tableHasKeys(table) {
|
|
29
|
+
return Object.keys(table).some((key) => {
|
|
30
|
+
const id = key.trim();
|
|
31
|
+
return id && !id.startsWith("_");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/** Always discoverable; they are how an operator sees a denial. */
|
|
26
36
|
export const DIAGNOSTIC_TOOLS = new Set([
|
|
27
37
|
"drupal_list_sites",
|
|
@@ -104,6 +114,147 @@ export function principalHasScope(identity, scope) {
|
|
|
104
114
|
return (identity?.scopes ?? []).includes(scope);
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
function grantNameList(values) {
|
|
118
|
+
if (!Array.isArray(values)) return [];
|
|
119
|
+
return [...new Set(
|
|
120
|
+
values.map((value) => String(value).trim()).filter((value) => value && !value.startsWith("_")),
|
|
121
|
+
)];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalize `auth.actors` (principal key → Drupal user UUID).
|
|
126
|
+
* Keys are inbound `sub` or `azp`/`clientId`. Values may be a UUID string
|
|
127
|
+
* or `{ uuid, delegators?, revoked? }`. Comment keys are dropped.
|
|
128
|
+
*
|
|
129
|
+
* @param {object|null} actors
|
|
130
|
+
* @returns {object|null}
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeActors(actors) {
|
|
133
|
+
if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
|
|
134
|
+
const entries = [];
|
|
135
|
+
for (const [rawKey, value] of Object.entries(actors)) {
|
|
136
|
+
const key = rawKey.trim();
|
|
137
|
+
if (!key || key.startsWith("_")) continue;
|
|
138
|
+
let uuid = "";
|
|
139
|
+
let revoked = false;
|
|
140
|
+
let delegators = [];
|
|
141
|
+
if (typeof value === "string") {
|
|
142
|
+
uuid = value.trim();
|
|
143
|
+
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
144
|
+
uuid = typeof value.uuid === "string" ? value.uuid.trim() : "";
|
|
145
|
+
revoked = value.revoked === true;
|
|
146
|
+
delegators = grantNameList(value.delegators);
|
|
147
|
+
} else {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!uuid || !ACTOR_UUID.test(uuid)) continue;
|
|
151
|
+
entries.push([key, Object.freeze({ uuid, revoked, delegators: Object.freeze(delegators) })]);
|
|
152
|
+
}
|
|
153
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve the Drupal actor for this principal from server-owned grants.
|
|
158
|
+
* Caller `identity.actor` is ignored. JWT `act.sub` is a confirming hint
|
|
159
|
+
* inside `delegators`, never authority.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} params
|
|
162
|
+
* @param {object|null} params.identity
|
|
163
|
+
* @param {object|null} [params.actors]
|
|
164
|
+
* @returns {{actor: string|null, delegator: string|null, required: boolean, reason: "not_entitled"|null}}
|
|
165
|
+
*/
|
|
166
|
+
export function resolveActor({ identity = null, actors = null } = {}) {
|
|
167
|
+
if (!actors || typeof actors !== "object" || Array.isArray(actors)) {
|
|
168
|
+
return { actor: null, delegator: null, required: false, reason: null };
|
|
169
|
+
}
|
|
170
|
+
const hasKeys = tableHasKeys(actors);
|
|
171
|
+
const table = normalizeActors(actors);
|
|
172
|
+
if (!table) {
|
|
173
|
+
return {
|
|
174
|
+
actor: null,
|
|
175
|
+
delegator: null,
|
|
176
|
+
required: hasKeys,
|
|
177
|
+
reason: hasKeys ? "not_entitled" : null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const lookup = new Map(Object.entries(table));
|
|
181
|
+
const record = (identity?.sub && lookup.get(identity.sub))
|
|
182
|
+
|| (identity?.clientId && lookup.get(identity.clientId))
|
|
183
|
+
|| null;
|
|
184
|
+
if (!record || record.revoked) {
|
|
185
|
+
return { actor: null, delegator: null, required: true, reason: "not_entitled" };
|
|
186
|
+
}
|
|
187
|
+
const actSub = typeof identity?.actSub === "string" && identity.actSub.trim()
|
|
188
|
+
? identity.actSub.trim()
|
|
189
|
+
: null;
|
|
190
|
+
if (actSub && !record.delegators.includes(actSub)) {
|
|
191
|
+
return { actor: null, delegator: null, required: true, reason: "not_entitled" };
|
|
192
|
+
}
|
|
193
|
+
return { actor: record.uuid, delegator: actSub, required: true, reason: null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Normalize `auth.policies` (principal key → SHA-256 digest).
|
|
198
|
+
* Keys are inbound `sub` or `azp`/`clientId`. Values may be a digest string
|
|
199
|
+
* or `{ digest, revoked? }`. Comment keys are dropped.
|
|
200
|
+
*
|
|
201
|
+
* @param {object|null} policies
|
|
202
|
+
* @returns {object|null}
|
|
203
|
+
*/
|
|
204
|
+
export function normalizePolicies(policies) {
|
|
205
|
+
if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
|
|
206
|
+
const entries = [];
|
|
207
|
+
for (const [rawKey, value] of Object.entries(policies)) {
|
|
208
|
+
const key = rawKey.trim();
|
|
209
|
+
if (!key || key.startsWith("_")) continue;
|
|
210
|
+
let digest = "";
|
|
211
|
+
let revoked = false;
|
|
212
|
+
if (typeof value === "string") {
|
|
213
|
+
digest = value.trim().toLowerCase();
|
|
214
|
+
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
215
|
+
digest = typeof value.digest === "string" ? value.digest.trim().toLowerCase() : "";
|
|
216
|
+
revoked = value.revoked === true;
|
|
217
|
+
} else {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!digest || !POLICY_DIGEST.test(digest)) continue;
|
|
221
|
+
entries.push([key, Object.freeze({ digest, revoked })]);
|
|
222
|
+
}
|
|
223
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the expected policy digest for this principal from server-owned grants.
|
|
228
|
+
* Caller `identity.policy` is ignored.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} params
|
|
231
|
+
* @param {object|null} params.identity
|
|
232
|
+
* @param {object|null} [params.policies]
|
|
233
|
+
* @returns {{policy: string|null, required: boolean, reason: "not_entitled"|null}}
|
|
234
|
+
*/
|
|
235
|
+
export function resolvePolicy({ identity = null, policies = null } = {}) {
|
|
236
|
+
if (!policies || typeof policies !== "object" || Array.isArray(policies)) {
|
|
237
|
+
return { policy: null, required: false, reason: null };
|
|
238
|
+
}
|
|
239
|
+
const hasKeys = tableHasKeys(policies);
|
|
240
|
+
const table = normalizePolicies(policies);
|
|
241
|
+
if (!table) {
|
|
242
|
+
return {
|
|
243
|
+
policy: null,
|
|
244
|
+
required: hasKeys,
|
|
245
|
+
reason: hasKeys ? "not_entitled" : null,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const lookup = new Map(Object.entries(table));
|
|
249
|
+
const record = (identity?.sub && lookup.get(identity.sub))
|
|
250
|
+
|| (identity?.clientId && lookup.get(identity.clientId))
|
|
251
|
+
|| null;
|
|
252
|
+
if (!record || record.revoked) {
|
|
253
|
+
return { policy: null, required: true, reason: "not_entitled" };
|
|
254
|
+
}
|
|
255
|
+
return { policy: record.digest, required: true, reason: null };
|
|
256
|
+
}
|
|
257
|
+
|
|
107
258
|
/**
|
|
108
259
|
* Site names this principal may address. Unknown names in a grant are dropped.
|
|
109
260
|
*
|
|
@@ -240,7 +391,7 @@ export function describeTarget(site, source) {
|
|
|
240
391
|
* @param {Array<object>} resolvable
|
|
241
392
|
* @param {string[]} configuredNames
|
|
242
393
|
* @param {object|null} [grants]
|
|
243
|
-
* @returns {{sites: string[], targets: Array<{name: string, baseUrl?: string, source: string}>}}
|
|
394
|
+
* @returns {{sites: string[], targets: Array<{name: string, baseUrl?: string, source: string}>, tenants: Array<{id: string, source: string}>}}
|
|
244
395
|
*/
|
|
245
396
|
export function visibleSiteTargets(identity, resolvable, configuredNames, grants) {
|
|
246
397
|
const granted = identity ? resolveGrantedSites(identity, resolvable, grants) : resolvable;
|
|
@@ -249,12 +400,16 @@ export function visibleSiteTargets(identity, resolvable, configuredNames, grants
|
|
|
249
400
|
: [...configuredNames];
|
|
250
401
|
const byName = new Map(granted.map((site) => [site._name, site]));
|
|
251
402
|
const source = identity ? "grant" : "config";
|
|
403
|
+
const tenantId = identity && typeof identity.tenant === "string" && identity.tenant
|
|
404
|
+
? identity.tenant
|
|
405
|
+
: null;
|
|
252
406
|
return {
|
|
253
407
|
sites: names,
|
|
254
408
|
targets: names.map((name) => {
|
|
255
409
|
const site = byName.get(name);
|
|
256
410
|
return site ? describeTarget(site, source) : { name, source };
|
|
257
411
|
}),
|
|
412
|
+
tenants: tenantId ? [{ id: tenantId, source: "grant" }] : [],
|
|
258
413
|
};
|
|
259
414
|
}
|
|
260
415
|
|
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232) —
|
|
2
|
+
* Relay northbound edge (#232, #242, #244, #247, #250) — DEV-294 AC4, DEV-122
|
|
3
|
+
* isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125 policy digest.
|
|
3
4
|
*
|
|
4
5
|
* Terminates northbound MCP over the OAuth resource server and fans requests
|
|
5
|
-
* down
|
|
6
|
+
* down outbound tenant-agent channels. The edge proposes; the tenant-side
|
|
6
7
|
* connector disposes. Policy at this seam, fail-closed from birth:
|
|
7
8
|
*
|
|
8
9
|
* - `createInboundHttpsAuth` is the ONLY authentication arm. There is no
|
|
@@ -16,6 +17,11 @@
|
|
|
16
17
|
* before framing. The frame carries the validated identity object only.
|
|
17
18
|
* - The edge holds no site credentials: a catalog entry carrying credential
|
|
18
19
|
* material refuses startup. It cannot leak what it does not hold.
|
|
20
|
+
* - Tunnel identity is the tenant boundary. Agent sessions are keyed by
|
|
21
|
+
* `agentId`; channel records may bind `sites`. A second unscoped agent
|
|
22
|
+
* or overlapping site claim is denied at hello. Fan-down selects the
|
|
23
|
+
* unique bound agent from server-owned grants. Single unscoped agent
|
|
24
|
+
* remains the DEV-294 compatibility path.
|
|
19
25
|
* - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
|
|
20
26
|
* no `Mcp-Session-Id` crosses in either direction.
|
|
21
27
|
* - Revocation is per-request with no grace window, for both credential
|
|
@@ -31,7 +37,8 @@ import { createServer as createTlsServer } from "node:tls";
|
|
|
31
37
|
import { createLocalRelay } from "../contracts/relay.js";
|
|
32
38
|
import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
|
|
33
39
|
import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
|
|
34
|
-
import {
|
|
40
|
+
import { isWriteLikeCall } from "../operations.js";
|
|
41
|
+
import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
|
|
35
42
|
import {
|
|
36
43
|
attachFramer,
|
|
37
44
|
createRequestBroker,
|
|
@@ -79,6 +86,230 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
|
|
|
79
86
|
|
|
80
87
|
const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
|
|
81
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a channel-record `sites` list. Empty / missing means unscoped
|
|
91
|
+
* (legal only as the sole connected agent — the DEV-294 compatibility path).
|
|
92
|
+
*
|
|
93
|
+
* @param {unknown} sites
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function boundSiteNames(sites) {
|
|
97
|
+
if (!Array.isArray(sites)) return [];
|
|
98
|
+
return sites.map(String).filter((name) => name && !name.startsWith("_"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Decide whether a hello may join the agent-channel session table.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} params
|
|
105
|
+
* @param {{agentId: string, revoked?: boolean, sites?: string[]|null}|null} params.record
|
|
106
|
+
* @param {Array<{agentId: string, sites: string[]|null}>} [params.sessions]
|
|
107
|
+
* Currently connected agents, excluding a reconnect of the same agentId.
|
|
108
|
+
* @param {Iterable<string>} [params.catalogNames]
|
|
109
|
+
* @returns {{ok: true, sites: string[]|null}|{ok: false, reason: string}}
|
|
110
|
+
*/
|
|
111
|
+
export function acceptAgentHello({ record, sessions = [], catalogNames = [] } = {}) {
|
|
112
|
+
if (!record || typeof record.agentId !== "string" || !record.agentId) {
|
|
113
|
+
return { ok: false, reason: "unauthenticated" };
|
|
114
|
+
}
|
|
115
|
+
if (record.revoked) return { ok: false, reason: "revoked" };
|
|
116
|
+
|
|
117
|
+
const known = new Set([...catalogNames].map(String));
|
|
118
|
+
let incoming = boundSiteNames(record.sites);
|
|
119
|
+
if (incoming.length && known.size) {
|
|
120
|
+
incoming = incoming.filter((name) => known.has(name));
|
|
121
|
+
if (!incoming.length) return { ok: false, reason: "unbound_tenant" };
|
|
122
|
+
}
|
|
123
|
+
const incomingScoped = incoming.length > 0;
|
|
124
|
+
const others = sessions.filter((session) => session.agentId !== record.agentId);
|
|
125
|
+
|
|
126
|
+
if (others.length === 0) {
|
|
127
|
+
return { ok: true, sites: incomingScoped ? incoming : null };
|
|
128
|
+
}
|
|
129
|
+
if (!incomingScoped) return { ok: false, reason: "unbound_tenant" };
|
|
130
|
+
if (others.some((session) => !boundSiteNames(session.sites).length)) {
|
|
131
|
+
return { ok: false, reason: "unbound_tenant" };
|
|
132
|
+
}
|
|
133
|
+
const claimed = new Set(others.flatMap((session) => boundSiteNames(session.sites)));
|
|
134
|
+
if (incoming.some((name) => claimed.has(name))) {
|
|
135
|
+
return { ok: false, reason: "overlapping_tenant" };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, sites: incoming };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pick the unique tenant agent this principal may use.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} params
|
|
144
|
+
* @param {string[]} params.grantedSiteNames
|
|
145
|
+
* @param {string|null} [params.targetName]
|
|
146
|
+
* @param {Array<{agentId: string, sites: string[]|null}>} params.sessions
|
|
147
|
+
* @returns {{session: object|null, reason: "not_entitled"|"no_agent"|null}}
|
|
148
|
+
*/
|
|
149
|
+
export function selectTenantSession({
|
|
150
|
+
grantedSiteNames = [],
|
|
151
|
+
targetName = null,
|
|
152
|
+
sessions = [],
|
|
153
|
+
} = {}) {
|
|
154
|
+
if (!grantedSiteNames.length) return { session: null, reason: "not_entitled" };
|
|
155
|
+
if (targetName && !grantedSiteNames.includes(targetName)) {
|
|
156
|
+
return { session: null, reason: "not_entitled" };
|
|
157
|
+
}
|
|
158
|
+
const needed = targetName ? [targetName] : [...grantedSiteNames];
|
|
159
|
+
if (!sessions.length) return { session: null, reason: "no_agent" };
|
|
160
|
+
|
|
161
|
+
const scoped = [];
|
|
162
|
+
const unscoped = [];
|
|
163
|
+
for (const session of sessions) {
|
|
164
|
+
if (boundSiteNames(session.sites).length) scoped.push(session);
|
|
165
|
+
else unscoped.push(session);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (unscoped.length && scoped.length) {
|
|
169
|
+
return { session: null, reason: "no_agent" };
|
|
170
|
+
}
|
|
171
|
+
if (unscoped.length === 1 && sessions.length === 1) {
|
|
172
|
+
return { session: unscoped[0], reason: null };
|
|
173
|
+
}
|
|
174
|
+
if (unscoped.length > 1) return { session: null, reason: "no_agent" };
|
|
175
|
+
|
|
176
|
+
const matches = scoped.filter((session) => {
|
|
177
|
+
const names = new Set(boundSiteNames(session.sites));
|
|
178
|
+
return needed.every((name) => names.has(name));
|
|
179
|
+
});
|
|
180
|
+
if (matches.length === 1) return { session: matches[0], reason: null };
|
|
181
|
+
if (matches.length > 1) return { session: null, reason: "not_entitled" };
|
|
182
|
+
|
|
183
|
+
const partial = scoped.filter((session) => {
|
|
184
|
+
const names = new Set(boundSiteNames(session.sites));
|
|
185
|
+
return needed.some((name) => names.has(name));
|
|
186
|
+
});
|
|
187
|
+
if (partial.length > 0 && needed.length > 1) {
|
|
188
|
+
return { session: null, reason: "not_entitled" };
|
|
189
|
+
}
|
|
190
|
+
return { session: null, reason: "no_agent" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve tenant and target from server-owned grants. Caller `tenant` is a
|
|
195
|
+
* confirming hint inside the grant, never authority. When `tenantGrants` is
|
|
196
|
+
* omitted, tenant is derived from the unique agent covering the site grant
|
|
197
|
+
* (DEV-122 / DEV-294 compatibility).
|
|
198
|
+
*
|
|
199
|
+
* @param {object} params
|
|
200
|
+
* @param {object|null} params.identity
|
|
201
|
+
* @param {string|null} [params.callerTenant]
|
|
202
|
+
* @param {object|null} [params.tenantGrants]
|
|
203
|
+
* @param {string[]} params.grantedSiteNames
|
|
204
|
+
* @param {string|null} [params.targetName]
|
|
205
|
+
* @param {Array<{agentId: string, sites: string[]|null}>} params.sessions
|
|
206
|
+
* @returns {{session: object|null, tenant: string|null, target: string|null, source: string, reason: "not_entitled"|"no_agent"|null}}
|
|
207
|
+
*/
|
|
208
|
+
export function resolveTenantRoute({
|
|
209
|
+
identity,
|
|
210
|
+
callerTenant = null,
|
|
211
|
+
tenantGrants = null,
|
|
212
|
+
grantedSiteNames = [],
|
|
213
|
+
targetName = null,
|
|
214
|
+
sessions = [],
|
|
215
|
+
} = {}) {
|
|
216
|
+
if (tenantGrants) {
|
|
217
|
+
const listed = identity?.clientId
|
|
218
|
+
? new Map(Object.entries(tenantGrants)).get(identity.clientId)
|
|
219
|
+
: undefined;
|
|
220
|
+
const grantedTenants = grantIds(listed);
|
|
221
|
+
if (!grantedTenants.length) {
|
|
222
|
+
return {
|
|
223
|
+
session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (callerTenant && !grantedTenants.includes(callerTenant)) {
|
|
227
|
+
return {
|
|
228
|
+
session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (!callerTenant && grantedTenants.length > 1) {
|
|
232
|
+
return {
|
|
233
|
+
session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const tenant = callerTenant || grantedTenants[0];
|
|
237
|
+
const session = sessions.find((entry) => entry.agentId === tenant) ?? null;
|
|
238
|
+
if (!session) {
|
|
239
|
+
return {
|
|
240
|
+
session: null, tenant, target: targetName, source: "grant", reason: "no_agent",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const tenantSites = boundSiteNames(session.sites);
|
|
244
|
+
const allowed = tenantSites.length
|
|
245
|
+
? grantedSiteNames.filter((name) => tenantSites.includes(name))
|
|
246
|
+
: grantedSiteNames;
|
|
247
|
+
if (targetName && !allowed.includes(targetName)) {
|
|
248
|
+
return {
|
|
249
|
+
session: null, tenant, target: targetName, source: "grant", reason: "not_entitled",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (!targetName && grantedSiteNames.length && allowed.length === 0) {
|
|
253
|
+
return {
|
|
254
|
+
session: null, tenant, target: targetName, source: "grant", reason: "not_entitled",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { session, tenant, target: targetName, source: "grant", reason: null };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const selected = selectTenantSession({ grantedSiteNames, targetName, sessions });
|
|
261
|
+
return {
|
|
262
|
+
session: selected.session,
|
|
263
|
+
tenant: selected.session?.agentId ?? null,
|
|
264
|
+
target: targetName,
|
|
265
|
+
source: "grant",
|
|
266
|
+
reason: selected.reason,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function callerTenantHint(args = {}) {
|
|
271
|
+
const value = new Map(Object.entries(args ?? {})).get("tenant");
|
|
272
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const CALLER_HINT_KEYS = Object.freeze(["tenant", "actor", "delegator", "policy", "digest"]);
|
|
276
|
+
|
|
277
|
+
function stripCallerHints(body) {
|
|
278
|
+
const args = body?.params?.arguments;
|
|
279
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return body;
|
|
280
|
+
const bag = new Map(Object.entries(args));
|
|
281
|
+
let changed = false;
|
|
282
|
+
for (const key of CALLER_HINT_KEYS) {
|
|
283
|
+
if (bag.has(key)) {
|
|
284
|
+
bag.delete(key);
|
|
285
|
+
changed = true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!changed) return body;
|
|
289
|
+
return { ...body, params: { ...body.params, arguments: Object.fromEntries(bag) } };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function identityWithGrant(identity, { tenant, actor = null, delegator = null, policy = null }) {
|
|
293
|
+
const bag = new Map(Object.entries(identity ?? {}));
|
|
294
|
+
for (const key of ["actor", "delegator", "policy", "digest"]) bag.delete(key);
|
|
295
|
+
bag.set("tenant", tenant);
|
|
296
|
+
if (actor) bag.set("actor", actor);
|
|
297
|
+
if (delegator) bag.set("delegator", delegator);
|
|
298
|
+
if (policy) bag.set("policy", policy);
|
|
299
|
+
return Object.freeze(Object.fromEntries(bag));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Canonical form of a tenant site bind. Null and empty both mean unscoped.
|
|
304
|
+
*
|
|
305
|
+
* @param {unknown} sites
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
export function siteBindingKey(sites) {
|
|
309
|
+
const names = boundSiteNames(sites);
|
|
310
|
+
return names.length ? names.slice().sort().join("\0") : "";
|
|
311
|
+
}
|
|
312
|
+
|
|
82
313
|
/** Startup refusals: configuration that must not become a listener. */
|
|
83
314
|
export class EdgeStartupError extends Error {
|
|
84
315
|
constructor(message) {
|
|
@@ -110,14 +341,17 @@ export function fanDownHeaders(headers = {}) {
|
|
|
110
341
|
* Hot-reloaded agent channel credential store.
|
|
111
342
|
*
|
|
112
343
|
* File shape: `{ "agents": { "<agentId>": { "tokenSha256": "<hex>",
|
|
113
|
-
* "revoked": false } } }`. The edge stores only
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
344
|
+
* "sites": ["site-name"], "revoked": false } } }`. The edge stores only
|
|
345
|
+
* SHA-256 digests — never a raw channel token. `sites` binds the tunnel to
|
|
346
|
+
* catalog names (the tenant boundary). Omitted `sites` is unscoped and is
|
|
347
|
+
* only accepted as the sole connected agent. A missing or unreadable file
|
|
348
|
+
* denies every lookup (a credential table that cannot be read authorizes
|
|
349
|
+
* nobody), and the file is re-read when its mtime changes so a revoke needs
|
|
350
|
+
* no restart.
|
|
117
351
|
*
|
|
118
352
|
* @param {object} options
|
|
119
353
|
* @param {string} options.filePath
|
|
120
|
-
* @returns {{lookup: (token: string) => {agentId: string, revoked: boolean}|null}}
|
|
354
|
+
* @returns {{lookup: (token: string) => {agentId: string, revoked: boolean, sites: string[]|null}|null}}
|
|
121
355
|
*/
|
|
122
356
|
export function createChannelCredentialStore({
|
|
123
357
|
filePath,
|
|
@@ -146,6 +380,7 @@ export function createChannelCredentialStore({
|
|
|
146
380
|
agentId,
|
|
147
381
|
tokenSha256: entry.tokenSha256.toLowerCase(),
|
|
148
382
|
revoked: entry.revoked === true,
|
|
383
|
+
sites: Array.isArray(entry.sites) ? boundSiteNames(entry.sites) : null,
|
|
149
384
|
}));
|
|
150
385
|
cache = { mtimeMs: info.mtimeMs, agents, denyAll: false };
|
|
151
386
|
} catch {
|
|
@@ -166,7 +401,7 @@ export function createChannelCredentialStore({
|
|
|
166
401
|
for (const agent of agents) {
|
|
167
402
|
const expected = Buffer.from(agent.tokenSha256, "utf8");
|
|
168
403
|
if (digest.length === expected.length && timingSafeEqual(digest, expected)) {
|
|
169
|
-
return { agentId: agent.agentId, revoked: agent.revoked };
|
|
404
|
+
return { agentId: agent.agentId, revoked: agent.revoked, sites: agent.sites };
|
|
170
405
|
}
|
|
171
406
|
}
|
|
172
407
|
return null;
|
|
@@ -178,11 +413,18 @@ function isLoopback(host) {
|
|
|
178
413
|
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
179
414
|
}
|
|
180
415
|
|
|
416
|
+
function grantIds(values) {
|
|
417
|
+
if (!Array.isArray(values)) return [];
|
|
418
|
+
return [...new Set(
|
|
419
|
+
values.map((value) => String(value).trim()).filter((value) => value && !value.startsWith("_")),
|
|
420
|
+
)];
|
|
421
|
+
}
|
|
422
|
+
|
|
181
423
|
function normalizeGrants(grants) {
|
|
182
424
|
if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
|
|
183
425
|
const entries = Object.entries(grants)
|
|
184
426
|
.filter(([clientId, sites]) => !clientId.startsWith("_") && Array.isArray(sites))
|
|
185
|
-
.map(([clientId, sites]) => [clientId, sites
|
|
427
|
+
.map(([clientId, sites]) => [clientId, grantIds(sites)]);
|
|
186
428
|
return entries.length ? Object.fromEntries(entries) : null;
|
|
187
429
|
}
|
|
188
430
|
|
|
@@ -222,6 +464,11 @@ function jsonResponse(res, status, body) {
|
|
|
222
464
|
* @param {{issuer: string, audience: string}} options.auth Inbound
|
|
223
465
|
* resource-server config (`resolveInboundAuthConfig` shape). Mandatory.
|
|
224
466
|
* @param {object} options.grants Non-empty client-id → site-name grant table.
|
|
467
|
+
* @param {object|null} [options.tenantGrants] Optional client-id → tenant
|
|
468
|
+
* agent-id table. When present, tenant routing is grant-authoritative.
|
|
469
|
+
* @param {object|null} [options.actors] Optional principal → Drupal actor
|
|
470
|
+
* table (`sub` / `azp` → `{ uuid, delegators? }`). When present, write-like
|
|
471
|
+
* tools/call require a mapping.
|
|
225
472
|
* @param {Array<{_name: string}>} options.sites Credential-free catalog.
|
|
226
473
|
* @param {string} [options.defaultSite]
|
|
227
474
|
* @param {{lookup: Function}} options.channelCredentials Agent channel store.
|
|
@@ -240,6 +487,9 @@ function jsonResponse(res, status, body) {
|
|
|
240
487
|
export async function startEdge({
|
|
241
488
|
auth,
|
|
242
489
|
grants,
|
|
490
|
+
tenantGrants = null,
|
|
491
|
+
actors = null,
|
|
492
|
+
policies = null,
|
|
243
493
|
sites,
|
|
244
494
|
defaultSite,
|
|
245
495
|
channelCredentials,
|
|
@@ -268,6 +518,13 @@ export async function startEdge({
|
|
|
268
518
|
+ "The library's all-sites fallback does not apply to this entry point.",
|
|
269
519
|
);
|
|
270
520
|
}
|
|
521
|
+
const tenantGrantTable = normalizeGrants(tenantGrants);
|
|
522
|
+
const actorTable = actors && typeof actors === "object" && !Array.isArray(actors)
|
|
523
|
+
? actors
|
|
524
|
+
: null;
|
|
525
|
+
const policyTable = policies && typeof policies === "object" && !Array.isArray(policies)
|
|
526
|
+
? policies
|
|
527
|
+
: null;
|
|
271
528
|
if (typeof channelCredentials?.lookup !== "function") {
|
|
272
529
|
throw new EdgeStartupError(
|
|
273
530
|
"Relay edge requires an agent channel credential store; without one no "
|
|
@@ -293,7 +550,9 @@ export async function startEdge({
|
|
|
293
550
|
const targetRelay = createLocalRelay({ sites: catalog, grants: grantTable, defaultSite });
|
|
294
551
|
const broker = createRequestBroker({ timeoutMs: fanDownTimeoutMs });
|
|
295
552
|
|
|
296
|
-
|
|
553
|
+
/** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null}>} */
|
|
554
|
+
const sessions = new Map();
|
|
555
|
+
const catalogNames = catalog.map((site) => site._name);
|
|
297
556
|
|
|
298
557
|
const channelServer = (hasTls ? createTlsServer(tls) : createNetServer())
|
|
299
558
|
.on("connection", handleChannelSocket)
|
|
@@ -303,35 +562,49 @@ export async function startEdge({
|
|
|
303
562
|
// Plain server emits "connection"; the TLS server emits both "connection"
|
|
304
563
|
// (raw) and "secureConnection" (cleartext). Attach once, post-handshake.
|
|
305
564
|
if (hasTls && !socket.encrypted) return;
|
|
565
|
+
let agentId = null;
|
|
306
566
|
attachFramer(socket, (frame) => {
|
|
307
567
|
if (frame.type === "hello") {
|
|
308
568
|
const record = channelCredentials.lookup(frame.token);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
569
|
+
const others = [...sessions.values()].filter((entry) => entry.socket !== socket);
|
|
570
|
+
const decision = acceptAgentHello({
|
|
571
|
+
record,
|
|
572
|
+
sessions: others,
|
|
573
|
+
catalogNames,
|
|
574
|
+
});
|
|
575
|
+
if (!decision.ok) {
|
|
576
|
+
writeFrame(socket, { type: "denied", reason: decision.reason });
|
|
316
577
|
socket.end();
|
|
317
578
|
return;
|
|
318
579
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
580
|
+
const existing = sessions.get(record.agentId);
|
|
581
|
+
if (existing && existing.socket !== socket) existing.socket.destroy();
|
|
582
|
+
agentId = record.agentId;
|
|
583
|
+
sessions.set(agentId, {
|
|
584
|
+
socket,
|
|
585
|
+
token: frame.token,
|
|
586
|
+
agentId,
|
|
587
|
+
sites: decision.sites,
|
|
588
|
+
});
|
|
589
|
+
writeFrame(socket, { type: "hello-ok", agent: { agentId } });
|
|
322
590
|
return;
|
|
323
591
|
}
|
|
324
592
|
if (frame.type === "mcp-response") {
|
|
325
|
-
|
|
593
|
+
if (!agentId) {
|
|
594
|
+
socket.destroy();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
broker.settle(frame, { owner: agentId });
|
|
326
598
|
return;
|
|
327
599
|
}
|
|
328
600
|
// Any other frame type on the agent channel is a protocol violation.
|
|
329
601
|
socket.destroy();
|
|
330
602
|
});
|
|
331
603
|
socket.on("close", () => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
604
|
+
const current = agentId ? sessions.get(agentId) : null;
|
|
605
|
+
if (current?.socket === socket) {
|
|
606
|
+
sessions.delete(agentId);
|
|
607
|
+
broker.rejectByOwner(agentId, new Error("Relay agent channel closed."));
|
|
335
608
|
}
|
|
336
609
|
});
|
|
337
610
|
}
|
|
@@ -352,38 +625,92 @@ export async function startEdge({
|
|
|
352
625
|
jsonResponse(res, 403, { error: "not_entitled" });
|
|
353
626
|
return;
|
|
354
627
|
}
|
|
628
|
+
const args = body?.params?.arguments ?? {};
|
|
629
|
+
const callerTenant = callerTenantHint(args);
|
|
630
|
+
const siteArgs = { ...args };
|
|
631
|
+
delete siteArgs.tenant;
|
|
632
|
+
let targetName = null;
|
|
355
633
|
if (body?.method === "tools/call") {
|
|
356
634
|
try {
|
|
357
|
-
targetRelay.resolve(identity,
|
|
635
|
+
targetName = targetRelay.resolve(identity, siteArgs).name;
|
|
358
636
|
} catch {
|
|
359
637
|
jsonResponse(res, 403, { error: "not_entitled" });
|
|
360
638
|
return;
|
|
361
639
|
}
|
|
362
640
|
}
|
|
363
641
|
|
|
364
|
-
|
|
365
|
-
|
|
642
|
+
const mapped = resolveActor({ identity, actors: actorTable });
|
|
643
|
+
const boundPolicy = resolvePolicy({ identity, policies: policyTable });
|
|
644
|
+
const isCall = body?.method === "tools/call";
|
|
645
|
+
const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
|
|
646
|
+
? body.params.name.trim()
|
|
647
|
+
: null;
|
|
648
|
+
if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
|
|
649
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (
|
|
653
|
+
boundPolicy.required && boundPolicy.reason && isCall
|
|
654
|
+
&& (!toolName || !DIAGNOSTIC_TOOLS.has(toolName))
|
|
655
|
+
) {
|
|
656
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const selected = resolveTenantRoute({
|
|
661
|
+
identity,
|
|
662
|
+
callerTenant,
|
|
663
|
+
tenantGrants: tenantGrantTable,
|
|
664
|
+
grantedSiteNames: granted.map((site) => site._name),
|
|
665
|
+
targetName,
|
|
666
|
+
sessions: [...sessions.values()],
|
|
667
|
+
});
|
|
668
|
+
if (!selected.session) {
|
|
669
|
+
const entitled = selected.reason === "not_entitled";
|
|
670
|
+
jsonResponse(res, entitled ? 403 : 503, {
|
|
671
|
+
error: entitled ? "not_entitled" : "no_agent",
|
|
672
|
+
});
|
|
366
673
|
return;
|
|
367
674
|
}
|
|
368
|
-
const record = channelCredentials.lookup(session.token);
|
|
675
|
+
const record = channelCredentials.lookup(selected.session.token);
|
|
369
676
|
if (!record || record.revoked) {
|
|
370
677
|
jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
|
|
371
678
|
return;
|
|
372
679
|
}
|
|
680
|
+
if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
|
|
681
|
+
selected.session.socket.destroy();
|
|
682
|
+
jsonResponse(res, 503, { error: "no_agent" });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
373
685
|
|
|
374
686
|
const id = randomUUID();
|
|
375
|
-
const waited = broker.track(id);
|
|
376
|
-
const
|
|
687
|
+
const waited = broker.track(id, { owner: selected.session.agentId });
|
|
688
|
+
const routedIdentity = identityWithGrant(identity, {
|
|
689
|
+
tenant: selected.tenant,
|
|
690
|
+
actor: mapped.actor,
|
|
691
|
+
delegator: mapped.delegator,
|
|
692
|
+
policy: boundPolicy.policy,
|
|
693
|
+
});
|
|
694
|
+
const wrote = writeFrame(selected.session.socket, {
|
|
377
695
|
type: "mcp-request",
|
|
378
696
|
id,
|
|
379
697
|
method: req.method,
|
|
380
698
|
url: "/mcp",
|
|
381
699
|
headers: fanDownHeaders(req.headers),
|
|
382
|
-
identity,
|
|
383
|
-
body,
|
|
700
|
+
identity: routedIdentity,
|
|
701
|
+
body: stripCallerHints(body),
|
|
702
|
+
correlation: {
|
|
703
|
+
requestId: id,
|
|
704
|
+
tenant: selected.tenant,
|
|
705
|
+
target: selected.target,
|
|
706
|
+
source: selected.source,
|
|
707
|
+
...(mapped.actor ? { actor: mapped.actor } : {}),
|
|
708
|
+
...(mapped.delegator ? { delegator: mapped.delegator } : {}),
|
|
709
|
+
...(boundPolicy.policy ? { policyDigest: boundPolicy.policy } : {}),
|
|
710
|
+
},
|
|
384
711
|
});
|
|
385
712
|
if (!wrote) {
|
|
386
|
-
broker.settle({ id, status: 503 });
|
|
713
|
+
broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
|
|
387
714
|
jsonResponse(res, 503, { error: "no_agent" });
|
|
388
715
|
return;
|
|
389
716
|
}
|
|
@@ -446,16 +773,20 @@ export async function startEdge({
|
|
|
446
773
|
agentPort: channelAddr.port,
|
|
447
774
|
resourceMetadataUrl: inbound.resourceMetadataUrl,
|
|
448
775
|
get hasAgent() {
|
|
449
|
-
return
|
|
776
|
+
return sessions.size > 0;
|
|
450
777
|
},
|
|
451
778
|
get agentId() {
|
|
452
|
-
|
|
779
|
+
if (sessions.size !== 1) return null;
|
|
780
|
+
return sessions.keys().next().value;
|
|
781
|
+
},
|
|
782
|
+
get agentIds() {
|
|
783
|
+
return [...sessions.keys()];
|
|
453
784
|
},
|
|
454
785
|
async close() {
|
|
455
786
|
if (closed) return;
|
|
456
787
|
closed = true;
|
|
457
|
-
|
|
458
|
-
|
|
788
|
+
for (const entry of sessions.values()) entry.socket.destroy();
|
|
789
|
+
sessions.clear();
|
|
459
790
|
broker.rejectAll(new Error("Relay edge closed."));
|
|
460
791
|
await closeServer(channelServer);
|
|
461
792
|
await closeServer(northServer);
|
package/src/lib/relay/frames.js
CHANGED
|
@@ -118,13 +118,25 @@ export function writeFrame(socket, frame) {
|
|
|
118
118
|
/**
|
|
119
119
|
* Correlate mcp-request ids with their mcp-response frames.
|
|
120
120
|
*
|
|
121
|
+
* Optional `owner` binds a tracked id to one tenant agent. A response from a
|
|
122
|
+
* different owner is ignored (cross-tenant injection cannot complete the
|
|
123
|
+
* wait). Disconnecting one owner rejects only that owner's pending ids.
|
|
124
|
+
*
|
|
121
125
|
* @param {object} [options]
|
|
122
126
|
* @param {number} [options.timeoutMs] Per-request wait bound.
|
|
123
|
-
* @returns {
|
|
127
|
+
* @returns {object}
|
|
124
128
|
*/
|
|
125
129
|
export function createRequestBroker({ timeoutMs = 10_000 } = {}) {
|
|
126
130
|
const pending = new Map();
|
|
127
131
|
|
|
132
|
+
function drop(id, apply) {
|
|
133
|
+
const waiter = pending.get(id);
|
|
134
|
+
if (!waiter) return false;
|
|
135
|
+
pending.delete(id);
|
|
136
|
+
apply(waiter);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
return {
|
|
129
141
|
get size() {
|
|
130
142
|
return pending.size;
|
|
@@ -132,9 +144,10 @@ export function createRequestBroker({ timeoutMs = 10_000 } = {}) {
|
|
|
132
144
|
|
|
133
145
|
/**
|
|
134
146
|
* @param {string} id
|
|
147
|
+
* @param {{owner?: string|null}} [options]
|
|
135
148
|
* @returns {Promise<object>} Resolves with the matching response frame.
|
|
136
149
|
*/
|
|
137
|
-
track(id) {
|
|
150
|
+
track(id, { owner = null } = {}) {
|
|
138
151
|
return new Promise((resolve, reject) => {
|
|
139
152
|
const timer = setTimeout(() => {
|
|
140
153
|
pending.delete(id);
|
|
@@ -142,6 +155,7 @@ export function createRequestBroker({ timeoutMs = 10_000 } = {}) {
|
|
|
142
155
|
}, timeoutMs);
|
|
143
156
|
timer.unref?.();
|
|
144
157
|
pending.set(id, {
|
|
158
|
+
owner,
|
|
145
159
|
resolve(frame) {
|
|
146
160
|
clearTimeout(timer);
|
|
147
161
|
resolve(frame);
|
|
@@ -156,24 +170,36 @@ export function createRequestBroker({ timeoutMs = 10_000 } = {}) {
|
|
|
156
170
|
|
|
157
171
|
/**
|
|
158
172
|
* @param {{id?: string}} frame
|
|
173
|
+
* @param {{owner?: string|null}} [options]
|
|
159
174
|
* @returns {boolean} True when a tracked request was resolved.
|
|
160
175
|
*/
|
|
161
|
-
settle(frame) {
|
|
176
|
+
settle(frame, { owner = null } = {}) {
|
|
162
177
|
if (typeof frame?.id !== "string") return false;
|
|
163
178
|
const waiter = pending.get(frame.id);
|
|
164
179
|
if (!waiter) return false;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
if (typeof waiter.owner === "string" && waiter.owner !== owner) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return drop(frame.id, (entry) => entry.resolve(frame));
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} owner
|
|
188
|
+
* @param {Error} error
|
|
189
|
+
*/
|
|
190
|
+
rejectByOwner(owner, error) {
|
|
191
|
+
for (const [id, waiter] of [...pending]) {
|
|
192
|
+
if (waiter.owner !== owner) continue;
|
|
193
|
+
drop(id, (entry) => entry.reject(error));
|
|
194
|
+
}
|
|
168
195
|
},
|
|
169
196
|
|
|
170
197
|
/**
|
|
171
198
|
* @param {Error} error
|
|
172
199
|
*/
|
|
173
200
|
rejectAll(error) {
|
|
174
|
-
for (const [id
|
|
175
|
-
|
|
176
|
-
waiter.reject(error);
|
|
201
|
+
for (const [id] of [...pending]) {
|
|
202
|
+
drop(id, (entry) => entry.reject(error));
|
|
177
203
|
}
|
|
178
204
|
},
|
|
179
205
|
};
|
package/src/lib/verify.js
CHANGED
|
@@ -75,6 +75,16 @@ export const RESIDUALS = [
|
|
|
75
75
|
"responsibility; the connector reads secrets from the environment and never " +
|
|
76
76
|
"stores them.",
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: "loopback_shared_bearer",
|
|
80
|
+
status: "managed",
|
|
81
|
+
detail:
|
|
82
|
+
"Loopback MCP_AUTH_TOKEN (inbound mode shared_bearer) still ships. It is " +
|
|
83
|
+
"deprecated as of this release and will be removed in v3.0.0 (#231). " +
|
|
84
|
+
"Network-facing product paths already refuse it. MCP_ALLOW_UNAUTHENTICATED " +
|
|
85
|
+
"is a separate named residual: an explicit trusted-proxy opt-in, not a " +
|
|
86
|
+
"shared credential, and is not retired by #231.",
|
|
87
|
+
},
|
|
78
88
|
];
|
|
79
89
|
|
|
80
90
|
/** Hostname suffixes reserved for documentation and testing (RFC 2606/6761). */
|
package/src/tools/site.js
CHANGED
|
@@ -47,7 +47,7 @@ async function listContentTypes({ site: siteName }) {
|
|
|
47
47
|
* List named sites this principal may address. No backend call and no credentials.
|
|
48
48
|
* `sites` stays a name list for compatibility; `targets` is the authoritative
|
|
49
49
|
* resolved-target record.
|
|
50
|
-
* @returns {Promise<{sites: string[], targets: Array<object>}>}
|
|
50
|
+
* @returns {Promise<{sites: string[], targets: Array<object>, tenants: Array<object>}>}
|
|
51
51
|
*/
|
|
52
52
|
async function listConfiguredSites() {
|
|
53
53
|
const names = listSiteNames();
|