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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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 numeric ids under drupal_internal__* attributes;
21
- // these are dropped from canonical `fields` (the canonical id is the UUID).
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
- * Read/write Backend adapter backed by Drupal core JSON:API.
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; drupal_internal__* and base fields are
198
- * stripped from `fields`; relationships are normalized to canonical refs.
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 === "drupal_internal__vid"
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
- if (relationships) payload.data.relationships = relationships;
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
- if (relationships) payload.data.relationships = relationships;
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
  // ---------------------------------------------------------------------------
@@ -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]
@@ -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, or scope fields are
11
- * hints. They never become authority. Empty inbound scopes are no grants,
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
 
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Relay northbound edge (#232) — the DEV-294 AC4 slice.
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 an outbound tenant-agent channel. The edge proposes; the tenant-side
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 { resolveGrantedSites } from "../principal.js";
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 SHA-256 digests — never a raw
114
- * channel token. A missing or unreadable file denies every lookup (a
115
- * credential table that cannot be read authorizes nobody), and the file is
116
- * re-read when its mtime changes so a revoke needs no restart.
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.map(String)]);
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
- let session = null;
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
- if (!record) {
310
- writeFrame(socket, { type: "denied", reason: "unauthenticated" });
311
- socket.end();
312
- return;
313
- }
314
- if (record.revoked) {
315
- writeFrame(socket, { type: "denied", reason: "revoked" });
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
- if (session && session.socket !== socket) session.socket.destroy();
320
- session = { socket, token: frame.token, agentId: record.agentId };
321
- writeFrame(socket, { type: "hello-ok", agent: { agentId: record.agentId } });
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
- broker.settle(frame);
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
- if (session?.socket === socket) {
333
- session = null;
334
- broker.rejectAll(new Error("Relay agent channel closed."));
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, body?.params?.arguments ?? {});
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
- if (!session) {
365
- jsonResponse(res, 503, { error: "no_agent" });
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 wrote = writeFrame(session.socket, {
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 Boolean(session);
776
+ return sessions.size > 0;
450
777
  },
451
778
  get agentId() {
452
- return session?.agentId ?? null;
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
- session?.socket.destroy();
458
- session = null;
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);
@@ -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 {{size: number, track: (id: string) => Promise<object>, settle: (frame: {id?: string}) => boolean, rejectAll: (error: Error) => void}}
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
- pending.delete(frame.id);
166
- waiter.resolve(frame);
167
- return true;
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, waiter] of pending) {
175
- pending.delete(id);
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();