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.
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +3 -3
- package/CHANGELOG.md +66 -12
- package/bin/drupal-mcp-anchor.js +52 -0
- package/bin/drupal-mcp-edge.js +17 -1
- package/config/config.example.json +6 -0
- package/package.json +3 -2
- package/src/lib/anchor.js +338 -0
- package/src/lib/backends/backend-interface.js +1 -1
- package/src/lib/backends/jsonapi.js +2 -2
- package/src/lib/config.js +39 -0
- package/src/lib/contracts/approval.js +5 -0
- package/src/lib/draft-write.js +57 -0
- package/src/lib/evidence.js +607 -0
- package/src/lib/http-auth.js +65 -3
- package/src/lib/patch-preflight.js +18 -7
- package/src/lib/policy-enforcement.js +1 -1
- package/src/lib/policy-promotion.js +1 -1
- package/src/lib/relay/agent.js +1 -1
- package/src/lib/relay/edge.js +559 -15
- package/src/lib/relay/frames.js +1 -1
- package/src/lib/usage.js +1 -2
- package/src/tools/bulk.js +2 -1
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +10 -10
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
* (
|
|
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([
|
|
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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|