drupal-mcp-connector 2.13.0 → 2.14.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.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — DEV-294
3
- * AC4, DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping,
4
- * DEV-125 policy digest and W&L-operated bundle promotion, DEV-126
5
- * attributable usage, quotas, and abuse signals.
2
+ * Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — the
3
+ * hosted-edge slice (#232), tenant isolation (#242), tenant routing (#244),
4
+ * actor mapping (#247), policy digest (#250) and W&L-operated bundle
5
+ * promotion (#253), attributable usage, quotas, and abuse signals (#256).
6
6
  *
7
7
  * Terminates northbound MCP over the OAuth resource server and fans requests
8
8
  * down outbound tenant-agent channels. The edge proposes; the tenant-side
@@ -23,7 +23,7 @@
23
23
  * `agentId`; channel records may bind `sites`. A second unscoped agent
24
24
  * or overlapping site claim is denied at hello. Fan-down selects the
25
25
  * unique bound agent from server-owned grants. Single unscoped agent
26
- * remains the DEV-294 compatibility path.
26
+ * remains the #232 single-agent compatibility path.
27
27
  * - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
28
28
  * no `Mcp-Session-Id` crosses in either direction.
29
29
  * - Revocation is per-request with no grace window, for both credential
@@ -33,15 +33,40 @@
33
33
  * the validated principal; with `auth.quotas` a tenant or principal
34
34
  * without a row, an exhausted window, or a locked principal is refused
35
35
  * with zero frames. `GET /usage` serves one tenant partition only.
36
+ * - Independent evidence is optional and fail-closed. With
37
+ * `auth.evidenceAnchor` a settled receipt is reconciled by stable
38
+ * identifiers and submitted to an Ed25519 notary whose key the edge
39
+ * never holds. `GET /assessor` serves one tenant-scoped, data-minimized
40
+ * export bound to the live policy digest. Omit to keep the prior path
41
+ * (404 on /assessor).
42
+ * - Optional `auth.approvalRequiredTools` issues a one-use approval
43
+ * before fan-down for named tools. Omit keeps the prior path.
44
+ * - `offboardTenant` exports, then destroys, one grant-resolved tenant.
45
+ * Omit to keep the prior path. Laboratory offboard, not hosted admission.
36
46
  */
37
47
 
38
48
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
39
- import { readFileSync, statSync } from "node:fs";
49
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
40
50
  import { createServer as createHttpServer } from "node:http";
41
51
  import { createServer as createHttpsServer } from "node:https";
42
52
  import { createServer as createNetServer } from "node:net";
43
53
  import { createServer as createTlsServer } from "node:tls";
54
+ import { createAnchorClient } from "../anchor.js";
55
+ import { createMemoryApproval } from "../contracts/approval.js";
44
56
  import { createLocalRelay } from "../contracts/relay.js";
57
+ import { digestPayload } from "../contracts/types.js";
58
+ import {
59
+ ABSENT,
60
+ approvalId,
61
+ createEvidenceLedger,
62
+ createExecutionChain,
63
+ delegationId,
64
+ digestExecution,
65
+ exportAssessor,
66
+ identityId,
67
+ normalizeEvidenceAnchor,
68
+ obligationId,
69
+ } from "../evidence.js";
45
70
  import {
46
71
  createInboundHttpsAuth,
47
72
  formatWwwAuthenticate,
@@ -74,7 +99,7 @@ import {
74
99
  export const EDGE_MCP_PROTOCOL = "2026-07-28";
75
100
 
76
101
  /**
77
- * Revocation bound restated from the DEV-293 lab for both credential kinds.
102
+ * Revocation bound restated from the outbound-relay lab for both credential kinds.
78
103
  * The next request after revoke is denied; an in-flight request may finish.
79
104
  */
80
105
  export const EDGE_REVOCATION_BOUND = Object.freeze({
@@ -122,7 +147,7 @@ const SHARED_SCOPES = new Set(["tenant", "config"]);
122
147
 
123
148
  /**
124
149
  * Normalize a channel-record `sites` list. Empty / missing means unscoped
125
- * (legal only as the sole connected agent — the DEV-294 compatibility path).
150
+ * (legal only as the sole connected agent — the #232 compatibility path).
126
151
  *
127
152
  * @param {unknown} sites
128
153
  * @returns {string[]}
@@ -228,7 +253,7 @@ export function selectTenantSession({
228
253
  * Resolve tenant and target from server-owned grants. Caller `tenant` is a
229
254
  * confirming hint inside the grant, never authority. When `tenantGrants` is
230
255
  * omitted, tenant is derived from the unique agent covering the site grant
231
- * (DEV-122 / DEV-294 compatibility).
256
+ * (#242 / #232 compatibility).
232
257
  *
233
258
  * @param {object} params
234
259
  * @param {object|null} params.identity
@@ -306,7 +331,37 @@ function callerTenantHint(args = {}) {
306
331
  return typeof value === "string" && value.trim() ? value.trim() : null;
307
332
  }
308
333
 
309
- const CALLER_HINT_KEYS = Object.freeze(["tenant", "actor", "delegator", "policy", "digest"]);
334
+ const CALLER_HINT_KEYS = Object.freeze([
335
+ "tenant", "actor", "delegator", "policy", "digest", "approvalId",
336
+ ]);
337
+
338
+ function callerApprovalHint(args = {}) {
339
+ const value = new Map(Object.entries(args ?? {})).get("approvalId");
340
+ return typeof value === "string" && value.trim() ? value.trim() : null;
341
+ }
342
+
343
+ /**
344
+ * Optional tool names that require a one-use edge approval before fan-down.
345
+ * Omit / comment-only keeps the prior path. Anything else unreadable is
346
+ * invalid and refuses startup.
347
+ *
348
+ * @param {unknown} raw
349
+ * @returns {string[]|{invalid: true, reason: string}}
350
+ */
351
+ export function normalizeApprovalRequiredTools(raw) {
352
+ if (raw === null || raw === undefined) return [];
353
+ if (Array.isArray(raw)) {
354
+ return [...new Set(
355
+ raw.map((name) => (typeof name === "string" ? name.trim() : ""))
356
+ .filter((name) => name && !name.startsWith("_")),
357
+ )];
358
+ }
359
+ if (typeof raw === "object") {
360
+ const entries = Object.entries(raw).filter(([key]) => key && !key.startsWith("_"));
361
+ if (!entries.length) return [];
362
+ }
363
+ return { invalid: true, reason: "approvalRequiredTools" };
364
+ }
310
365
 
311
366
  function stripCallerHints(body) {
312
367
  const args = body?.params?.arguments;
@@ -385,11 +440,15 @@ export function fanDownHeaders(headers = {}) {
385
440
  *
386
441
  * @param {object} options
387
442
  * @param {string} options.filePath
388
- * @returns {{lookup: (token: string) => {agentId: string, revoked: boolean, sites: string[]|null}|null}}
443
+ * @returns {{
444
+ * lookup: (token: string) => {agentId: string, revoked: boolean, sites: string[]|null}|null,
445
+ * destroy: (agentId: string) => {ok: boolean, reason?: string},
446
+ * }}
389
447
  */
390
448
  export function createChannelCredentialStore({
391
449
  filePath,
392
450
  readFile = readFileSync,
451
+ writeFile = writeFileSync,
393
452
  stat = statSync,
394
453
  } = {}) {
395
454
  let cache = { mtimeMs: Number.NaN, agents: [], denyAll: true };
@@ -440,6 +499,47 @@ export function createChannelCredentialStore({
440
499
  }
441
500
  return null;
442
501
  },
502
+
503
+ /**
504
+ * Overwrite-then-remove one agent. The digest is replaced before the
505
+ * row is deleted so a leftover file cannot still match the token.
506
+ *
507
+ * @param {string} agentId
508
+ * @returns {{ok: true}|{ok: false, reason: string}}
509
+ */
510
+ destroy(agentId) {
511
+ const id = typeof agentId === "string" ? agentId.trim() : "";
512
+ if (!filePath) {
513
+ return { ok: false, reason: "unreadable" };
514
+ }
515
+ if (!id || id.startsWith("_")) {
516
+ return { ok: false, reason: "missing_agent" };
517
+ }
518
+ let raw;
519
+ try {
520
+ raw = JSON.parse(readFile(filePath, "utf8"));
521
+ } catch {
522
+ return { ok: false, reason: "unreadable" };
523
+ }
524
+ if (!raw.agents || typeof raw.agents !== "object" || Array.isArray(raw.agents)) {
525
+ return { ok: false, reason: "unreadable" };
526
+ }
527
+ const entry = raw.agents[id];
528
+ if (!entry || typeof entry !== "object") {
529
+ return { ok: false, reason: "missing_agent" };
530
+ }
531
+ try {
532
+ raw.agents[id] = { ...entry, tokenSha256: "0".repeat(64), revoked: true };
533
+ writeFile(filePath, JSON.stringify(raw));
534
+ delete raw.agents[id];
535
+ writeFile(filePath, JSON.stringify(raw));
536
+ } catch {
537
+ return { ok: false, reason: "unreadable" };
538
+ }
539
+ cache = { mtimeMs: Number.NaN, agents: [], denyAll: true };
540
+ load();
541
+ return { ok: true };
542
+ },
443
543
  };
444
544
  }
445
545
 
@@ -528,6 +628,20 @@ function byteLength(value) {
528
628
  * @param {?object} [options.usage] Optional usage ledger (usage.js). When
529
629
  * present, every decision and receipt is recorded and `GET /usage` serves
530
630
  * the caller's tenant partition. Omit to record nothing (404 on /usage).
631
+ * @param {object|null} [options.evidenceAnchor] Optional `auth.evidenceAnchor`
632
+ * (`url` + pinned `publicKey`, or a test `submit`). When present, every
633
+ * decision/receipt is reconciled and submitted to the notary; `GET /assessor`
634
+ * serves the caller's tenant pack. A table the edge cannot pin refuses
635
+ * startup. Omit to keep the prior path (404 on /assessor).
636
+ * @param {?object} [options.evidence] Optional evidence ledger (evidence.js).
637
+ * Injected by tests; created automatically when `evidenceAnchor` is set.
638
+ * @param {string[]|null} [options.approvalRequiredTools] Optional
639
+ * `auth.approvalRequiredTools`. Named tools receive a one-use
640
+ * `require_approval` challenge before fan-down. Omit keeps the prior path.
641
+ * An unreadable table refuses startup.
642
+ * @param {?object} [options.approvals] Optional approval ledger
643
+ * (`createMemoryApproval`). Injected by tests; created automatically when
644
+ * the tool list is non-empty.
531
645
  * @param {() => number} [options.now] Clock for quotas and cost signals.
532
646
  * @param {?object} [options.rateLimiter] Optional rate limiter (rate-limit.js).
533
647
  * @param {number} [options.fanDownTimeoutMs]
@@ -553,6 +667,10 @@ export async function startEdge({
553
667
  allowHttpLoopback = false,
554
668
  quotas = null,
555
669
  usage = null,
670
+ evidenceAnchor = null,
671
+ evidence = null,
672
+ approvalRequiredTools = null,
673
+ approvals = null,
556
674
  now = () => Date.now(),
557
675
  rateLimiter = null,
558
676
  fanDownTimeoutMs = DEFAULT_FAN_DOWN_TIMEOUT_MS,
@@ -603,6 +721,62 @@ export async function startEdge({
603
721
  + "(see createUsageLedger in usage.js), or be omitted.",
604
722
  );
605
723
  }
724
+ const anchorCfg = normalizeEvidenceAnchor(evidenceAnchor);
725
+ if (anchorCfg?.invalid) {
726
+ throw new EdgeStartupError(
727
+ `Relay edge refuses to start: auth.evidenceAnchor is not readable at "${anchorCfg.reason}". `
728
+ + "An evidence anchor the edge cannot pin is not independently verifiable; "
729
+ + "fix the row or remove the table.",
730
+ );
731
+ }
732
+ let evidenceLedger = evidence === null || evidence === undefined ? null : evidence;
733
+ if (evidenceLedger !== null && (
734
+ typeof evidenceLedger !== "object"
735
+ || typeof evidenceLedger.record !== "function"
736
+ || typeof evidenceLedger.query !== "function"
737
+ )) {
738
+ throw new EdgeStartupError(
739
+ "Relay edge evidence ledger must expose record() and query() "
740
+ + "(see createEvidenceLedger in evidence.js), or be omitted.",
741
+ );
742
+ }
743
+ let anchorClient = null;
744
+ if (anchorCfg) {
745
+ try {
746
+ anchorClient = createAnchorClient(anchorCfg);
747
+ } catch {
748
+ throw new EdgeStartupError(
749
+ "Relay edge refuses to start: auth.evidenceAnchor.publicKey is not a pinned Ed25519 key.",
750
+ );
751
+ }
752
+ evidenceLedger = evidenceLedger ?? createEvidenceLedger();
753
+ } else if (evidenceLedger) {
754
+ throw new EdgeStartupError(
755
+ "Relay edge refuses to start with an evidence ledger and no auth.evidenceAnchor. "
756
+ + "Unanchored evidence is not independently verifiable; omit both to keep the prior path.",
757
+ );
758
+ }
759
+ const requiredTools = normalizeApprovalRequiredTools(approvalRequiredTools);
760
+ if (requiredTools?.invalid) {
761
+ throw new EdgeStartupError(
762
+ `Relay edge refuses to start: auth.approvalRequiredTools is not readable at "${requiredTools.reason}". `
763
+ + "A gate the edge cannot name authorizes nobody; fix the list or remove it.",
764
+ );
765
+ }
766
+ const approvalRequired = new Set(requiredTools);
767
+ if (approvals !== null && approvals !== undefined && (
768
+ typeof approvals !== "object"
769
+ || typeof approvals.issue !== "function"
770
+ || typeof approvals.consume !== "function"
771
+ )) {
772
+ throw new EdgeStartupError(
773
+ "Relay edge approvals ledger must expose issue() and consume() "
774
+ + "(see createMemoryApproval), or be omitted.",
775
+ );
776
+ }
777
+ const approvalLedger = approvalRequired.size
778
+ ? (approvals ?? createMemoryApproval())
779
+ : null;
606
780
 
607
781
  /**
608
782
  * Record without ever changing the verdict: a metering failure is logged
@@ -617,6 +791,60 @@ export async function startEdge({
617
791
  return null;
618
792
  }
619
793
  }
794
+
795
+ /**
796
+ * Reconcile and (when the notary answers) independently anchor.
797
+ *
798
+ * @param {object} fields
799
+ * @returns {Promise<object|null>}
800
+ */
801
+ async function rememberNow(fields) {
802
+ if (!evidenceLedger) return null;
803
+ const decisionId = fields.decisionId || randomUUID();
804
+ const receiptId = fields.receiptId || decisionId;
805
+ const built = createExecutionChain({
806
+ tenant: fields.tenant,
807
+ identityId: identityId({
808
+ iss: auth.issuer,
809
+ sub: fields.sub,
810
+ clientId: fields.clientId,
811
+ }),
812
+ delegationId: delegationId(fields.delegator),
813
+ decisionId,
814
+ obligationId: obligationId(fields.obligations),
815
+ approvalId: approvalId(fields.approvalId),
816
+ localExecutionId: fields.requestId || ABSENT.localExecution,
817
+ targetRevision: fields.targetRevision,
818
+ receiptId,
819
+ policyDigest: fields.policyDigest,
820
+ outcome: fields.outcome,
821
+ requestId: fields.requestId,
822
+ receiptDecisionId: decisionId,
823
+ receiptTenant: fields.tenant,
824
+ });
825
+ const digest = digestExecution(built);
826
+ let inclusion = null;
827
+ if (anchorClient) {
828
+ const result = await anchorClient.submit(digest);
829
+ inclusion = result.ok ? result.inclusion : null;
830
+ }
831
+ return evidenceLedger.record(built, inclusion, anchorClient?.publicPin ?? null);
832
+ }
833
+
834
+ /**
835
+ * Fire-and-forget remember. Failures never change a northbound verdict.
836
+ *
837
+ * @param {object} fields
838
+ * @returns {void}
839
+ */
840
+ function remember(fields) {
841
+ void rememberNow(fields).catch(() => {
842
+ console.error("[drupal-mcp-edge] evidence record failed; the decision stands.");
843
+ });
844
+ }
845
+
846
+ /** clientId → { tenant, at }. Former principals see `offboarded`, not never-existed. */
847
+ const tombstones = new Map();
620
848
  if (typeof channelCredentials?.lookup !== "function") {
621
849
  throw new EdgeStartupError(
622
850
  "Relay edge requires an agent channel credential store; without one no "
@@ -777,7 +1005,7 @@ export async function startEdge({
777
1005
  if (ABUSE_SIGNAL_ERRORS.has(error) && !SHARED_SCOPES.has(scope)) {
778
1006
  quotaGate.noteDenial(principalKey);
779
1007
  }
780
- meter({
1008
+ const denied = meter({
781
1009
  phase: "decision",
782
1010
  decision: "deny",
783
1011
  reason: error,
@@ -792,6 +1020,17 @@ export async function startEdge({
792
1020
  units: 1,
793
1021
  bytesIn,
794
1022
  });
1023
+ remember({
1024
+ tenant: usageTenant,
1025
+ sub: identity.sub,
1026
+ clientId: identity.clientId,
1027
+ delegator: null,
1028
+ decisionId: denied?.decisionId ?? null,
1029
+ receiptId: denied?.decisionId ?? null,
1030
+ requestId: null,
1031
+ policyDigest,
1032
+ outcome: "denied",
1033
+ });
795
1034
  jsonResponse(
796
1035
  res,
797
1036
  status,
@@ -800,6 +1039,11 @@ export async function startEdge({
800
1039
  );
801
1040
  }
802
1041
 
1042
+ if (tombstones.has(identity.clientId)) {
1043
+ refuse(403, "offboarded");
1044
+ return;
1045
+ }
1046
+
803
1047
  // Entitlement at the seam, before anything about the tenant is revealed:
804
1048
  // an unlisted client learns nothing, not even whether an agent exists.
805
1049
  const granted = resolveGrantedSites(identity, catalog, grantTable);
@@ -898,6 +1142,61 @@ export async function startEdge({
898
1142
  return;
899
1143
  }
900
1144
 
1145
+ const callerApproval = callerApprovalHint(args);
1146
+ let consumedApprovalId = null;
1147
+ if (approvalLedger && isCall && toolName && approvalRequired.has(toolName)) {
1148
+ const manifest = {
1149
+ digest: digestPayload({
1150
+ tool: toolName,
1151
+ tenant: selected.tenant,
1152
+ policyDigest: policyDigest || "",
1153
+ }),
1154
+ };
1155
+ if (!callerApproval) {
1156
+ const issued = approvalLedger.issue(manifest, principalKey);
1157
+ // Usage stays allow/deny (#256). A challenge is not dispatched, so
1158
+ // it meters as deny; evidence still records outcome require_approval.
1159
+ const challenged = meter({
1160
+ phase: "decision",
1161
+ decision: "deny",
1162
+ reason: "require_approval",
1163
+ requestId: null,
1164
+ tenant: selected.tenant,
1165
+ principal,
1166
+ principalKey,
1167
+ method: body?.method ?? null,
1168
+ tool: toolName,
1169
+ policyDigest,
1170
+ units: 1,
1171
+ bytesIn,
1172
+ });
1173
+ remember({
1174
+ tenant: selected.tenant,
1175
+ sub: identity.sub,
1176
+ clientId: identity.clientId,
1177
+ delegator: mapped.delegator,
1178
+ decisionId: challenged?.decisionId ?? null,
1179
+ receiptId: challenged?.decisionId ?? null,
1180
+ requestId: null,
1181
+ policyDigest,
1182
+ approvalId: issued.approvalId,
1183
+ outcome: "require_approval",
1184
+ });
1185
+ jsonResponse(res, 403, {
1186
+ error: "require_approval",
1187
+ approvalId: issued.approvalId,
1188
+ });
1189
+ return;
1190
+ }
1191
+ try {
1192
+ approvalLedger.consume(callerApproval, manifest.digest, principalKey);
1193
+ consumedApprovalId = callerApproval;
1194
+ } catch {
1195
+ refuse(403, "not_entitled");
1196
+ return;
1197
+ }
1198
+ }
1199
+
901
1200
  const id = randomUUID();
902
1201
  const waited = broker.track(id, { owner: selected.session.agentId });
903
1202
  const routedIdentity = identityWithGrant(identity, {
@@ -947,7 +1246,7 @@ export async function startEdge({
947
1246
  });
948
1247
 
949
1248
  function receipt(fields) {
950
- meter({
1249
+ const usageRow = meter({
951
1250
  phase: "receipt",
952
1251
  requestId: id,
953
1252
  decisionId: decisionRecord?.decisionId ?? null,
@@ -956,6 +1255,19 @@ export async function startEdge({
956
1255
  durationMs: Math.max(0, now() - startedAt),
957
1256
  ...fields,
958
1257
  });
1258
+ remember({
1259
+ tenant: selected.tenant,
1260
+ sub: identity.sub,
1261
+ clientId: identity.clientId,
1262
+ delegator: mapped.delegator,
1263
+ decisionId: decisionRecord?.decisionId ?? null,
1264
+ receiptId: usageRow?.receiptId ?? null,
1265
+ requestId: id,
1266
+ targetRevision: fields.revisionId || fields.targetRevision,
1267
+ policyDigest,
1268
+ approvalId: consumedApprovalId,
1269
+ outcome: fields.outcome,
1270
+ });
959
1271
  }
960
1272
 
961
1273
  let result;
@@ -995,7 +1307,16 @@ export async function startEdge({
995
1307
  return;
996
1308
  }
997
1309
  res.end(result.body ?? "");
998
- receipt({ outcome: status >= 500 ? "failed" : "ok", reason: null, status, bytesOut });
1310
+ const revision = typeof result.revisionId === "string" && result.revisionId.trim()
1311
+ ? result.revisionId.trim()
1312
+ : undefined;
1313
+ receipt({
1314
+ outcome: status >= 500 ? "failed" : "ok",
1315
+ reason: null,
1316
+ status,
1317
+ bytesOut,
1318
+ ...(revision ? { revisionId: revision } : {}),
1319
+ });
999
1320
  }
1000
1321
 
1001
1322
  /**
@@ -1036,6 +1357,10 @@ export async function startEdge({
1036
1357
  res.writeHead(auth.status, auth.headers).end(auth.body);
1037
1358
  return;
1038
1359
  }
1360
+ if (tombstones.has(auth.identity.clientId)) {
1361
+ jsonResponse(res, 403, { error: "offboarded" });
1362
+ return;
1363
+ }
1039
1364
  try {
1040
1365
  const query = new URL(String(req.url || "/usage"), "http://edge.invalid").searchParams;
1041
1366
  const read = readUsage({
@@ -1064,6 +1389,85 @@ export async function startEdge({
1064
1389
  }
1065
1390
  }
1066
1391
 
1392
+ /**
1393
+ * `GET /assessor` — the caller's own tenant pack (#261). Authenticated
1394
+ * on the same resource server as `/mcp`; the tenant comes from
1395
+ * `auth.tenantGrants`. Controls cite the live policy digest and
1396
+ * independently anchored evidence ids; they never write `passed`.
1397
+ * 404 without an evidence ledger.
1398
+ */
1399
+ async function serveAssessor(req, res) {
1400
+ if (!evidenceLedger) {
1401
+ res.writeHead(404).end("Not found");
1402
+ return;
1403
+ }
1404
+ if (req.method !== "GET") {
1405
+ res.writeHead(405, { Allow: "GET" }).end("Method Not Allowed");
1406
+ return;
1407
+ }
1408
+ if (rateLimiter) {
1409
+ const verdict = rateLimiter.check(req.socket?.remoteAddress || "unknown");
1410
+ if (!verdict.allowed) {
1411
+ res.writeHead(429, { "Retry-After": String(verdict.retryAfterSec) }).end("Too Many Requests");
1412
+ return;
1413
+ }
1414
+ }
1415
+ let authResult;
1416
+ try {
1417
+ authResult = await inbound.authenticate(req);
1418
+ } catch {
1419
+ res.writeHead(401, {
1420
+ "WWW-Authenticate": formatWwwAuthenticate({
1421
+ error: "invalid_token",
1422
+ errorDescription: "Token validation failed",
1423
+ }),
1424
+ }).end("Unauthorized");
1425
+ return;
1426
+ }
1427
+ if (!authResult.ok) {
1428
+ res.writeHead(authResult.status, authResult.headers).end(authResult.body);
1429
+ return;
1430
+ }
1431
+ if (tombstones.has(authResult.identity.clientId)) {
1432
+ jsonResponse(res, 403, { error: "offboarded" });
1433
+ return;
1434
+ }
1435
+ try {
1436
+ const query = new URL(String(req.url || "/assessor"), "http://edge.invalid").searchParams;
1437
+ const bound = resolvePolicy({ identity: authResult.identity, policies: policyTable });
1438
+ const digest = bound.policy ?? null;
1439
+ let attested = false;
1440
+ if (digest) {
1441
+ for (const session of sessions.values()) {
1442
+ if (session.attestedDigests?.has(digest)) {
1443
+ attested = true;
1444
+ break;
1445
+ }
1446
+ }
1447
+ }
1448
+ const pack = exportAssessor({
1449
+ identity: authResult.identity,
1450
+ tenantGrants: tenantGrantTable,
1451
+ tenant: query.get("tenant"),
1452
+ ledger: evidenceLedger,
1453
+ policyDigest: digest,
1454
+ attested,
1455
+ });
1456
+ if (!pack.ok) {
1457
+ jsonResponse(res, 403, { error: "not_entitled" });
1458
+ return;
1459
+ }
1460
+ jsonResponse(res, 200, pack);
1461
+ } catch {
1462
+ console.error("[drupal-mcp-edge] assessor export failed.");
1463
+ if (res.headersSent) {
1464
+ res.destroy();
1465
+ return;
1466
+ }
1467
+ res.writeHead(500).end("Internal Server Error");
1468
+ }
1469
+ }
1470
+
1067
1471
  const requestHandler = createMcpRequestHandler({
1068
1472
  authenticate: (req) => inbound.authenticate(req),
1069
1473
  protectedResource: inbound.protectedResource,
@@ -1081,10 +1485,15 @@ export async function startEdge({
1081
1485
  });
1082
1486
 
1083
1487
  function northbound(req, res) {
1084
- if (String(req.url || "").split("?")[0] === "/usage") {
1488
+ const path = String(req.url || "").split("?")[0];
1489
+ if (path === "/usage") {
1085
1490
  void serveUsage(req, res);
1086
1491
  return;
1087
1492
  }
1493
+ if (path === "/assessor") {
1494
+ void serveAssessor(req, res);
1495
+ return;
1496
+ }
1088
1497
  void requestHandler(req, res);
1089
1498
  }
1090
1499
  const northServer = hasTls
@@ -1106,6 +1515,140 @@ export async function startEdge({
1106
1515
  });
1107
1516
  }
1108
1517
 
1518
+ /**
1519
+ * Destroy one grant-resolved tenant after a fail-closed export probe.
1520
+ * Revoke and destroy rows are anchored only after teardown succeeds.
1521
+ * Laboratory offboard, not hosted admission.
1522
+ *
1523
+ * @param {object} params
1524
+ * @param {string} [params.tenant] Confirming hint only. Authority is the
1525
+ * unique grant. A hint that does not match, or more than one grant,
1526
+ * is `not_entitled`.
1527
+ * @param {{clientId: string, sub?: string, jti?: string}} params.identity
1528
+ * @returns {Promise<{ok: true, export: object}|{ok: true, already: true}|{ok: false, reason: string}>}
1529
+ */
1530
+ async function offboardTenant({ tenant, identity } = {}) {
1531
+ const clientId = typeof identity?.clientId === "string" ? identity.clientId.trim() : "";
1532
+ if (!clientId) {
1533
+ return { ok: false, reason: "not_entitled" };
1534
+ }
1535
+ if (tombstones.has(clientId)) {
1536
+ return { ok: true, already: true };
1537
+ }
1538
+ if (!evidenceLedger || !anchorClient) {
1539
+ return { ok: false, reason: "export_unavailable" };
1540
+ }
1541
+ const granted = tenantGrantTable
1542
+ ? grantIds(new Map(Object.entries(tenantGrantTable)).get(clientId))
1543
+ : [];
1544
+ if (granted.length !== 1) {
1545
+ return { ok: false, reason: "not_entitled" };
1546
+ }
1547
+ const tenantId = granted[0];
1548
+ const hint = typeof tenant === "string" ? tenant.trim() : "";
1549
+ if (hint && hint !== tenantId) {
1550
+ return { ok: false, reason: "not_entitled" };
1551
+ }
1552
+
1553
+ const bound = resolvePolicy({ identity, policies: policyTable });
1554
+ const digest = bound.policy ?? null;
1555
+ let attested = false;
1556
+ if (digest) {
1557
+ for (const session of sessions.values()) {
1558
+ if (session.attestedDigests?.has(digest)) {
1559
+ attested = true;
1560
+ break;
1561
+ }
1562
+ }
1563
+ }
1564
+
1565
+ try {
1566
+ const probe = exportAssessor({
1567
+ identity,
1568
+ tenantGrants: tenantGrantTable,
1569
+ tenant: tenantId,
1570
+ ledger: evidenceLedger,
1571
+ policyDigest: digest,
1572
+ attested,
1573
+ });
1574
+ if (!probe?.ok) {
1575
+ return { ok: false, reason: "export_unavailable" };
1576
+ }
1577
+ } catch {
1578
+ return { ok: false, reason: "export_unavailable" };
1579
+ }
1580
+
1581
+ if (identity.jti && typeof inbound.revoke === "function") {
1582
+ const revoked = inbound.revoke(identity.jti);
1583
+ if (!revoked?.ok) {
1584
+ return { ok: false, reason: "destroy_failed" };
1585
+ }
1586
+ }
1587
+
1588
+ if (typeof channelCredentials.destroy !== "function") {
1589
+ return { ok: false, reason: "destroy_failed" };
1590
+ }
1591
+ const destroyed = channelCredentials.destroy(tenantId);
1592
+ if (!destroyed.ok && destroyed.reason !== "missing_agent") {
1593
+ return { ok: false, reason: destroyed.reason || "destroy_failed" };
1594
+ }
1595
+
1596
+ const session = sessions.get(tenantId);
1597
+ if (session) {
1598
+ broker.rejectByOwner(tenantId, new Error("Tenant offboarded."));
1599
+ session.socket.destroy();
1600
+ sessions.delete(tenantId);
1601
+ }
1602
+ if (typeof approvalLedger?.purge === "function") {
1603
+ approvalLedger.purge();
1604
+ }
1605
+
1606
+ try {
1607
+ await rememberNow({
1608
+ tenant: tenantId,
1609
+ sub: identity.sub,
1610
+ clientId,
1611
+ decisionId: randomUUID(),
1612
+ requestId: null,
1613
+ policyDigest: digest,
1614
+ outcome: "revoked",
1615
+ });
1616
+ await rememberNow({
1617
+ tenant: tenantId,
1618
+ sub: identity.sub,
1619
+ clientId,
1620
+ decisionId: randomUUID(),
1621
+ requestId: null,
1622
+ policyDigest: digest,
1623
+ outcome: "destroyed",
1624
+ });
1625
+ } catch {
1626
+ return { ok: false, reason: "export_unavailable" };
1627
+ }
1628
+
1629
+ let snapshot;
1630
+ try {
1631
+ snapshot = exportAssessor({
1632
+ identity,
1633
+ tenantGrants: tenantGrantTable,
1634
+ tenant: tenantId,
1635
+ ledger: evidenceLedger,
1636
+ policyDigest: digest,
1637
+ attested,
1638
+ });
1639
+ } catch {
1640
+ return { ok: false, reason: "export_unavailable" };
1641
+ }
1642
+ if (!snapshot?.ok) {
1643
+ return { ok: false, reason: "export_unavailable" };
1644
+ }
1645
+
1646
+ if (tenantGrantTable) delete tenantGrantTable[clientId];
1647
+ delete grantTable[clientId];
1648
+ tombstones.set(clientId, { tenant: tenantId, at: now() });
1649
+ return { ok: true, export: snapshot };
1650
+ }
1651
+
1109
1652
  const scheme = hasTls ? "https" : "http";
1110
1653
  let closed = false;
1111
1654
  return {
@@ -1113,6 +1656,7 @@ export async function startEdge({
1113
1656
  port: northAddr.port,
1114
1657
  agentPort: channelAddr.port,
1115
1658
  resourceMetadataUrl: inbound.resourceMetadataUrl,
1659
+ offboardTenant,
1116
1660
  get hasAgent() {
1117
1661
  return sessions.size > 0;
1118
1662
  },