drupal-mcp-connector 2.13.1 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.14.0] - 2026-09-06
11
+
12
+ ### Added
13
+ - **Laboratory tenant offboarding on the relay edge (#267).**
14
+ `offboardTenant` fail-closes unless a tenant-scoped assessor snapshot
15
+ can be taken, then overwrite-removes the channel digest, drops the
16
+ grant and session, and tombstones the principal. Revoke and destroy
17
+ rows are independently anchored only after that teardown succeeds.
18
+ `GET /assessor` cites P9.9 only when those anchored rows match the
19
+ digest frozen at export. The held pack stays independently verifiable
20
+ after access is removed. A former principal sees `offboarded`; a
21
+ stranger stays `not_entitled`. The pack never writes `passed`.
22
+ Lab/loopback only — not a hosted-service or design-partner admission
23
+ claim.
24
+ - **Laboratory tenant onboarding on the relay edge (#265).** Optional
25
+ `auth.approvalRequiredTools` issues a one-use approval before fan-down
26
+ for named tools; a later `tools/call` with `arguments.approvalId`
27
+ consumes it (stripped before the tenant sees it). `GET /assessor` cites
28
+ P9.8 when one tenant pack holds anchored allow, deny, and
29
+ approval-gated executions against the live policy digest. Omit the
30
+ list to keep the prior path. A table the edge cannot read refuses
31
+ startup. Lab/loopback only — not a hosted-service or design-partner
32
+ admission claim.
33
+
34
+ ### Security
35
+ - **Independently verifiable evidence on the relay edge (#261).** Optional
36
+ `auth.evidenceAnchor` (`url` + pinned Ed25519 `publicKey`) submits a
37
+ minimized receipt digest to a notary process (`drupal-mcp-anchor`) whose
38
+ private key the edge never holds. One governed execution reconciles by
39
+ stable identifiers (identity, delegation, decision, obligations,
40
+ approval, local execution, target revision, receipt — explicit `none:*`
41
+ absences count). `GET /assessor` serves the caller's own tenant pack:
42
+ data-minimized, bound to the live policy digest, controls cite evidence
43
+ ids or stay `residual`, never `passed`. A table the edge cannot pin, or
44
+ a ledger without a pin, refuses startup. Omitting the table keeps the
45
+ 2.13.0 path (`/assessor` is 404). Lab/loopback only — not a hosted-service
46
+ or design-partner admission claim. Audit Chain NDJSON remains the
47
+ off-system stream, not this anchor.
48
+
10
49
  ## [2.13.1] - 2026-09-05
11
50
 
12
51
  ### Fixed
@@ -1431,6 +1470,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1431
1470
  - User tools gained explicit PII-access assertions.
1432
1471
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1433
1472
 
1473
+ [2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
1434
1474
  [2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
1435
1475
  [2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
1436
1476
  [2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drupal-mcp-anchor — independent Ed25519 notary (#261).
4
+ *
5
+ * Separate process from the relay edge. Holds the private key and never
6
+ * shares it. Prints the public pin the edge must configure in
7
+ * `auth.evidenceAnchor.publicKey`. Loopback only. Not a hosted-service
8
+ * or design-partner admission claim. Audit Chain NDJSON is the off-system
9
+ * stream, not this process.
10
+ *
11
+ * Environment variables:
12
+ * MCP_ANCHOR_PORT Listen port (default 0 = ephemeral; printed).
13
+ * MCP_ANCHOR_BIND Bind host (default 127.0.0.1). Non-loopback refused.
14
+ */
15
+
16
+ import process from "node:process";
17
+ import { createNotary, startAnchorServer } from "../src/lib/anchor.js";
18
+
19
+ function fatal(message) {
20
+ console.error(`[drupal-mcp-anchor] FATAL: ${message}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const bindHost = process.env.MCP_ANCHOR_BIND || "127.0.0.1";
25
+ const loopback = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
26
+ if (!loopback) {
27
+ fatal(
28
+ "The notary binds loopback only. Set MCP_ANCHOR_BIND to 127.0.0.1 "
29
+ + "(or omit it). A separately administered production host is not chosen here.",
30
+ );
31
+ }
32
+
33
+ const portRaw = process.env.MCP_ANCHOR_PORT;
34
+ const port = portRaw === undefined || portRaw === "" ? 0 : Number(portRaw);
35
+ if (!Number.isInteger(port) || port < 0) {
36
+ fatal("MCP_ANCHOR_PORT must be a non-negative integer (0 = ephemeral).");
37
+ }
38
+
39
+ const notary = createNotary();
40
+ const server = await startAnchorServer({ notary, bindHost, port });
41
+
42
+ console.error(
43
+ `[drupal-mcp-anchor] Listening ${server.url} · keyId ${notary.keyId}`,
44
+ );
45
+ console.error("[drupal-mcp-anchor] Pin this public key on the edge (auth.evidenceAnchor.publicKey):");
46
+ console.error(notary.publicPin);
47
+
48
+ function shutdown() {
49
+ server.close().then(() => process.exit(0), () => process.exit(1));
50
+ }
51
+ process.on("SIGINT", shutdown);
52
+ process.on("SIGTERM", shutdown);
@@ -51,13 +51,21 @@
51
51
  * to the tenant agent and requires a matching local attestation. Optional
52
52
  * auth.quotas (tenant / principal request windows plus an abuse lock)
53
53
  * fails closed at the edge with zero frames on any refusal; a table the
54
- * edge cannot read refuses startup.
54
+ * edge cannot read refuses startup. Optional auth.evidenceAnchor
55
+ * ({ url, publicKey }) pins an independent Ed25519 notary; GET /assessor
56
+ * then serves one tenant-scoped pack bound to the live policy digest. A
57
+ * pin the edge cannot read refuses startup. Omit to keep the prior path
58
+ * (404 on /assessor). Optional auth.approvalRequiredTools issues a
59
+ * one-use approval before fan-down for named tools. Lab/loopback only —
60
+ * not a hosted-service claim.
55
61
  */
56
62
 
57
63
  import { readFileSync } from "node:fs";
58
64
  import process from "node:process";
59
65
  import {
60
66
  getInboundActors,
67
+ getInboundApprovalRequiredTools,
68
+ getInboundEvidenceAnchor,
61
69
  getInboundGrants,
62
70
  getInboundPolicies,
63
71
  getInboundPromotions,
@@ -168,6 +176,7 @@ if (usageRaw !== undefined && usageRaw !== null) {
168
176
  }
169
177
  const usage = usageMaxRecords > 0 ? createUsageLedger({ maxRecords: usageMaxRecords }) : null;
170
178
  const quotas = getInboundQuotas();
179
+ const evidenceAnchor = getInboundEvidenceAnchor();
171
180
 
172
181
  let edge;
173
182
  try {
@@ -180,6 +189,8 @@ try {
180
189
  promotions: getInboundPromotions(),
181
190
  quotas,
182
191
  usage,
192
+ evidenceAnchor,
193
+ approvalRequiredTools: getInboundApprovalRequiredTools(),
183
194
  sites,
184
195
  defaultSite: config.defaultSite,
185
196
  channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
@@ -215,3 +226,8 @@ if (usage) {
215
226
  if (quotas) {
216
227
  console.error("[drupal-mcp-edge] Quotas: auth.quotas in force; unlisted tenants / principals are refused.");
217
228
  }
229
+ if (evidenceAnchor) {
230
+ console.error(
231
+ "[drupal-mcp-edge] Evidence: independent notary pin in force; GET /assessor serves one tenant pack.",
232
+ );
233
+ }
@@ -47,6 +47,12 @@
47
47
  "quotas": {
48
48
  "_comment": "Optional, drupal-mcp-edge only. { tenants: { \"<agent id>\": { requests, windowSec } }, principals: { \"<sub or client_id>\": { requests, windowSec } }, abuse: { denials, windowSec, lockSec } }. When a tenants or principals table names any id, an id without a row is not_entitled; an exhausted window is 429 quota_exceeded with Retry-After; a principal that earns `denials` refusals inside windowSec is 429 abuse_locked for lockSec. Every refusal is zero frames on any tunnel. A shared tenant window running out never feeds an individual principal's abuse lock. A table the edge cannot read refuses startup and names the offending path. Omit to keep the prior path (no quota at the edge). Counting is measured usage, not pricing."
49
49
  },
50
+ "evidenceAnchor": {
51
+ "_comment": "Optional, drupal-mcp-edge only. Independent Ed25519 notary the edge never holds the private key for: { url, publicKey } (publicKey is SPKI DER, standard base64; url is https, or http on loopback). When present, every settled receipt is reconciled by stable identifiers and submitted to that notary; GET /assessor serves one tenant-scoped, data-minimized pack bound to the live policy digest. A table the edge cannot pin refuses startup. Omit to keep the prior path (404 on /assessor). Lab/loopback only — not a hosted-service or design-partner admission claim. Run `drupal-mcp-anchor` to mint a pin."
52
+ },
53
+ "approvalRequiredTools": {
54
+ "_comment": "Optional, drupal-mcp-edge only. Array of tool names that require a one-use approval the edge issues before fan-down, e.g. [\"drupal_create_node\"]. A later tools/call with arguments.approvalId consumes that ticket (stripped before the tenant sees it). Omit to keep the prior path (no edge approval gate). A table the edge cannot read refuses startup. Lab/loopback only — not a hosted-service or design-partner admission claim."
55
+ },
50
56
  "revocationFile": "",
51
57
  "introspectionUrl": "",
52
58
  "introspectionClientIdEnv": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.13.1",
3
+ "version": "2.14.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",
@@ -8,7 +8,8 @@
8
8
  "drupal-mcp-connector": "src/index.js",
9
9
  "drupal-mcp-verify": "bin/drupal-mcp-verify.js",
10
10
  "drupal-mcp-edge": "bin/drupal-mcp-edge.js",
11
- "drupal-mcp-agent": "bin/drupal-mcp-agent.js"
11
+ "drupal-mcp-agent": "bin/drupal-mcp-agent.js",
12
+ "drupal-mcp-anchor": "bin/drupal-mcp-anchor.js"
12
13
  },
13
14
  "files": [
14
15
  "src/",
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Independent evidence notary (#261).
3
+ *
4
+ * Ed25519 inclusions over a receipt digest. The private key never lives on
5
+ * the relay edge; verification needs only the pinned public key and the
6
+ * inclusion. This is not Audit Chain, not a Drupal table, and not a
7
+ * hosted-service claim. A shared-host lab process is a named residual —
8
+ * production placement on a separately administered host is not chosen here.
9
+ */
10
+
11
+ import { createHash, createPublicKey, generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
12
+ import { createServer as createHttpServer } from "node:http";
13
+
14
+ const DIGEST = /^[0-9a-f]{64}$/i;
15
+ const SCHEMA = "sentinel-anchor-v1";
16
+ const ALGORITHM = "Ed25519";
17
+
18
+ /**
19
+ * @param {import("node:crypto").KeyObject} publicKey
20
+ * @returns {string} SPKI DER, standard base64.
21
+ */
22
+ export function pinPublicKey(publicKey) {
23
+ return publicKey.export({ type: "spki", format: "der" }).toString("base64");
24
+ }
25
+
26
+ /**
27
+ * @param {string} pin
28
+ * @returns {import("node:crypto").KeyObject}
29
+ */
30
+ export function loadPinnedPublicKey(pin) {
31
+ if (typeof pin !== "string" || !pin.trim()) {
32
+ throw new TypeError("Pinned public key is required.");
33
+ }
34
+ const key = createPublicKey({
35
+ key: Buffer.from(pin.trim(), "base64"),
36
+ type: "spki",
37
+ format: "der",
38
+ });
39
+ if (key.asymmetricKeyType !== "ed25519") {
40
+ throw new TypeError("Pinned public key must be Ed25519.");
41
+ }
42
+ return key;
43
+ }
44
+
45
+ /**
46
+ * Listen URL for a bound server. IPv6 literals are bracketed so
47
+ * `MCP_ANCHOR_BIND=::1` prints a usable URL.
48
+ *
49
+ * @param {{address?: string, port?: number, family?: string|number}} address
50
+ * @param {string} [scheme]
51
+ * @returns {string}
52
+ */
53
+ export function formatBoundUrl(address, scheme = "http") {
54
+ const host = typeof address?.address === "string" ? address.address : "";
55
+ const port = address?.port;
56
+ const family = address?.family;
57
+ const ipv6 = family === "IPv6" || family === 6 || host.includes(":");
58
+ const hostname = ipv6 ? `[${host}]` : host;
59
+ return `${scheme}://${hostname}:${port}`;
60
+ }
61
+
62
+ /**
63
+ * @param {import("node:crypto").KeyObject} publicKey
64
+ * @returns {string}
65
+ */
66
+ export function keyIdOf(publicKey) {
67
+ return createHash("sha256").update(pinPublicKey(publicKey)).digest("hex").slice(0, 16);
68
+ }
69
+
70
+ /**
71
+ * Mint a notary keypair. The private key is for the notary process only.
72
+ * @returns {{publicKey: import("node:crypto").KeyObject, privateKey: import("node:crypto").KeyObject, keyId: string, publicPin: string}}
73
+ */
74
+ export function generateNotaryKeys() {
75
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
76
+ return {
77
+ publicKey,
78
+ privateKey,
79
+ keyId: keyIdOf(publicKey),
80
+ publicPin: pinPublicKey(publicKey),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Canonical bytes the signature covers. Signature itself is excluded.
86
+ * @param {{anchorId: string, receiptDigest: string, signedAt: string, keyId: string}} inclusion
87
+ * @returns {Buffer}
88
+ */
89
+ export function canonicalInclusion(inclusion) {
90
+ return Buffer.from(
91
+ `v1\n${inclusion.anchorId}\n${inclusion.receiptDigest}\n${inclusion.signedAt}\n${inclusion.keyId}`,
92
+ "utf8",
93
+ );
94
+ }
95
+
96
+ /**
97
+ * @param {import("node:crypto").KeyObject|string} publicKeyOrPin
98
+ * @param {object} inclusion
99
+ * @returns {{ok: true}|{ok: false, reason: string}}
100
+ */
101
+ export function verifyInclusion(publicKeyOrPin, inclusion) {
102
+ if (!inclusion || typeof inclusion !== "object" || Array.isArray(inclusion)) {
103
+ return { ok: false, reason: "malformed_inclusion" };
104
+ }
105
+ if (inclusion.schema !== SCHEMA || inclusion.algorithm !== ALGORITHM) {
106
+ return { ok: false, reason: "unsupported_inclusion" };
107
+ }
108
+ if (typeof inclusion.anchorId !== "string" || !inclusion.anchorId) {
109
+ return { ok: false, reason: "malformed_inclusion" };
110
+ }
111
+ if (typeof inclusion.receiptDigest !== "string" || !DIGEST.test(inclusion.receiptDigest)) {
112
+ return { ok: false, reason: "malformed_inclusion" };
113
+ }
114
+ if (typeof inclusion.signedAt !== "string" || !inclusion.signedAt) {
115
+ return { ok: false, reason: "malformed_inclusion" };
116
+ }
117
+ if (typeof inclusion.keyId !== "string" || !inclusion.keyId) {
118
+ return { ok: false, reason: "malformed_inclusion" };
119
+ }
120
+ if (typeof inclusion.signature !== "string" || !inclusion.signature) {
121
+ return { ok: false, reason: "malformed_inclusion" };
122
+ }
123
+ let key;
124
+ try {
125
+ key = typeof publicKeyOrPin === "string" ? loadPinnedPublicKey(publicKeyOrPin) : publicKeyOrPin;
126
+ } catch {
127
+ return { ok: false, reason: "unpinned_key" };
128
+ }
129
+ if (keyIdOf(key) !== inclusion.keyId) {
130
+ return { ok: false, reason: "key_mismatch" };
131
+ }
132
+ let signature;
133
+ try {
134
+ signature = Buffer.from(inclusion.signature, "base64");
135
+ } catch {
136
+ return { ok: false, reason: "malformed_inclusion" };
137
+ }
138
+ try {
139
+ if (!verify(null, canonicalInclusion(inclusion), key, signature)) {
140
+ return { ok: false, reason: "bad_signature" };
141
+ }
142
+ } catch {
143
+ return { ok: false, reason: "bad_signature" };
144
+ }
145
+ return { ok: true };
146
+ }
147
+
148
+ /**
149
+ * In-process notary. Holds the private key. The edge must never receive it.
150
+ *
151
+ * @param {object} [options]
152
+ * @param {import("node:crypto").KeyObject} [options.privateKey]
153
+ * @param {import("node:crypto").KeyObject} [options.publicKey]
154
+ * @param {string} [options.keyId]
155
+ * @param {() => Date} [options.now]
156
+ * @returns {object}
157
+ */
158
+ export function createNotary({
159
+ privateKey,
160
+ publicKey,
161
+ keyId,
162
+ now = () => new Date(),
163
+ } = generateNotaryKeys()) {
164
+ if (!privateKey || !publicKey) {
165
+ throw new TypeError("createNotary requires an Ed25519 keypair.");
166
+ }
167
+ const id = keyId || keyIdOf(publicKey);
168
+ const publicPin = pinPublicKey(publicKey);
169
+ const records = [];
170
+
171
+ return Object.freeze({
172
+ keyId: id,
173
+ publicPin,
174
+ /**
175
+ * @param {string} digest sha256 hex of the minimized execution.
176
+ * @returns {object}
177
+ */
178
+ include(digest) {
179
+ const receiptDigest = typeof digest === "string" ? digest.trim().toLowerCase() : "";
180
+ if (!DIGEST.test(receiptDigest)) {
181
+ throw new TypeError("Notary include() requires a SHA-256 hex digest.");
182
+ }
183
+ const unsigned = {
184
+ schema: SCHEMA,
185
+ anchorId: randomUUID(),
186
+ receiptDigest,
187
+ signedAt: now().toISOString(),
188
+ keyId: id,
189
+ algorithm: ALGORITHM,
190
+ };
191
+ const signature = sign(null, canonicalInclusion(unsigned), privateKey).toString("base64");
192
+ const inclusion = Object.freeze({ ...unsigned, signature });
193
+ records.push(inclusion);
194
+ return inclusion;
195
+ },
196
+ /** @returns {object[]} */
197
+ records() {
198
+ return records.slice();
199
+ },
200
+ });
201
+ }
202
+
203
+ function jsonResponse(res, status, body) {
204
+ const payload = JSON.stringify(body);
205
+ res.writeHead(status, {
206
+ "content-type": "application/json",
207
+ "content-length": Buffer.byteLength(payload),
208
+ }).end(payload);
209
+ }
210
+
211
+ /**
212
+ * Loopback HTTP notary. POST /anchor {digest} → inclusion. GET /keys → pin.
213
+ *
214
+ * @param {object} options
215
+ * @param {ReturnType<typeof createNotary>} options.notary
216
+ * @param {string} [options.bindHost]
217
+ * @param {number} [options.port]
218
+ * @returns {Promise<{url: string, port: number, close: Function}>}
219
+ */
220
+ export function startAnchorServer({ notary, bindHost = "127.0.0.1", port = 0 }) {
221
+ if (!notary || typeof notary.include !== "function") {
222
+ throw new TypeError("startAnchorServer requires a notary.");
223
+ }
224
+ const server = createHttpServer((req, res) => {
225
+ const path = String(req.url || "/").split("?")[0];
226
+ if (req.method === "GET" && path === "/keys") {
227
+ jsonResponse(res, 200, {
228
+ algorithm: ALGORITHM,
229
+ keyId: notary.keyId,
230
+ publicKey: notary.publicPin,
231
+ });
232
+ return;
233
+ }
234
+ if (req.method === "POST" && path === "/anchor") {
235
+ let raw = "";
236
+ req.on("data", (chunk) => { raw += chunk; });
237
+ req.on("end", () => {
238
+ let body;
239
+ try {
240
+ body = raw ? JSON.parse(raw) : {};
241
+ } catch {
242
+ jsonResponse(res, 400, { error: "malformed" });
243
+ return;
244
+ }
245
+ try {
246
+ jsonResponse(res, 200, notary.include(body?.digest));
247
+ } catch {
248
+ jsonResponse(res, 400, { error: "invalid_digest" });
249
+ }
250
+ });
251
+ return;
252
+ }
253
+ res.writeHead(404).end("Not found");
254
+ });
255
+ return new Promise((resolve, reject) => {
256
+ server.once("error", reject);
257
+ server.listen(port, bindHost, () => {
258
+ const address = server.address();
259
+ resolve({
260
+ url: formatBoundUrl(address),
261
+ port: address.port,
262
+ close: () => new Promise((done) => server.close(() => done())),
263
+ });
264
+ });
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Edge-side client. Verifies every inclusion against the pinned public key.
270
+ * Never learns or holds the notary private key.
271
+ *
272
+ * @param {object} options
273
+ * @param {string} [options.url]
274
+ * @param {string} options.publicKey Pinned SPKI base64.
275
+ * @param {(digest: string) => object|Promise<object>} [options.submit]
276
+ * @param {typeof fetch} [options.fetchFn]
277
+ * @param {number} [options.timeoutMs]
278
+ * @returns {{submit: Function, publicPin: string}}
279
+ */
280
+ export function createAnchorClient({
281
+ url = "",
282
+ publicKey,
283
+ submit = null,
284
+ fetchFn = fetch,
285
+ timeoutMs = 2000,
286
+ } = {}) {
287
+ const key = loadPinnedPublicKey(publicKey);
288
+ const publicPin = pinPublicKey(key);
289
+ const timeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 2000;
290
+
291
+ async function post(digest) {
292
+ if (typeof submit === "function") {
293
+ return submit(digest);
294
+ }
295
+ const base = String(url || "").replace(/\/+$/, "");
296
+ if (!base) {
297
+ return { ok: false, reason: "anchor_unavailable" };
298
+ }
299
+ const controller = new AbortController();
300
+ const timer = setTimeout(() => controller.abort(), timeout);
301
+ try {
302
+ const response = await fetchFn(`${base}/anchor`, {
303
+ method: "POST",
304
+ headers: { "content-type": "application/json" },
305
+ body: JSON.stringify({ digest }),
306
+ signal: controller.signal,
307
+ });
308
+ if (!response.ok) return { ok: false, reason: "anchor_unavailable" };
309
+ return await response.json();
310
+ } catch {
311
+ return { ok: false, reason: "anchor_unavailable" };
312
+ } finally {
313
+ clearTimeout(timer);
314
+ }
315
+ }
316
+
317
+ return Object.freeze({
318
+ publicPin,
319
+ /**
320
+ * @param {string} digest
321
+ * @returns {Promise<{ok: true, inclusion: object}|{ok: false, reason: string}>}
322
+ */
323
+ async submit(digest) {
324
+ const receiptDigest = typeof digest === "string" ? digest.trim().toLowerCase() : "";
325
+ if (!DIGEST.test(receiptDigest)) {
326
+ return { ok: false, reason: "invalid_digest" };
327
+ }
328
+ const raw = await post(receiptDigest);
329
+ if (raw && raw.ok === false && raw.reason) return raw;
330
+ const checked = verifyInclusion(key, raw);
331
+ if (!checked.ok) return { ok: false, reason: checked.reason };
332
+ if (raw.receiptDigest !== receiptDigest) {
333
+ return { ok: false, reason: "digest_mismatch" };
334
+ }
335
+ return { ok: true, inclusion: raw };
336
+ },
337
+ });
338
+ }
package/src/lib/config.js CHANGED
@@ -332,6 +332,45 @@ export function getInboundQuotas() {
332
332
  return entries.length ? Object.fromEntries(entries) : null;
333
333
  }
334
334
 
335
+ /**
336
+ * Independent evidence notary pin (`auth.evidenceAnchor`).
337
+ * When present, the relay edge fails closed: a table it cannot pin refuses
338
+ * startup. Validation lives in evidence.js (`normalizeEvidenceAnchor`).
339
+ * @returns {object|null|unknown} Null when omitted or comment-only; the
340
+ * comment-stripped table when it is an object; otherwise the configured
341
+ * value unchanged so `startEdge()` refuses to start on it instead of
342
+ * running without an independent anchor.
343
+ */
344
+ export function getInboundEvidenceAnchor() {
345
+ const raw = loadConfig().auth?.evidenceAnchor;
346
+ if (raw === undefined || raw === null) return null;
347
+ if (typeof raw !== "object" || Array.isArray(raw)) return raw;
348
+ const entries = Object.entries(raw)
349
+ .map(([key, value]) => [key.trim(), value])
350
+ .filter(([key]) => key && !key.startsWith("_"));
351
+ return entries.length ? Object.fromEntries(entries) : null;
352
+ }
353
+
354
+ /**
355
+ * Tool names that require a one-use edge approval before fan-down
356
+ * (`auth.approvalRequiredTools`). When present, the relay edge fails
357
+ * closed: a table it cannot read refuses startup. Validation lives in
358
+ * edge.js (`normalizeApprovalRequiredTools`).
359
+ * @returns {string[]|null|unknown}
360
+ */
361
+ export function getInboundApprovalRequiredTools() {
362
+ const raw = loadConfig().auth?.approvalRequiredTools;
363
+ if (raw === undefined || raw === null) return null;
364
+ if (Array.isArray(raw)) return raw;
365
+ if (typeof raw === "object") {
366
+ const entries = Object.entries(raw)
367
+ .map(([key, value]) => [key.trim(), value])
368
+ .filter(([key]) => key && !key.startsWith("_"));
369
+ return entries.length ? raw : null;
370
+ }
371
+ return raw;
372
+ }
373
+
335
374
  // ---------------------------------------------------------------------------
336
375
  // Auth headers — never logged, never exposed in tool responses
337
376
  // ---------------------------------------------------------------------------
@@ -64,6 +64,11 @@ export function createMemoryApproval() {
64
64
  return { approvalId, digest };
65
65
  },
66
66
 
67
+ /** Drop every unused and consumed ticket. Used by laboratory offboard. */
68
+ purge() {
69
+ store.clear();
70
+ },
71
+
67
72
  /** @returns {number} */
68
73
  size() {
69
74
  return store.size;