drupal-mcp-connector 2.13.1 → 2.14.1

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,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.14.1] - 2026-09-09
11
+
12
+ ### Changed
13
+ - README, CONTRIBUTING, and the package.json author URL now link
14
+ Jeremy Michael Cerda to the drupal.org profile
15
+ (`https://www.drupal.org/u/jmcerda`).
16
+ - README and docs now report 120 tools and 125 prompts (one per tool
17
+ plus 5 workflow prompts), matching the shipped surface. The tools
18
+ reference documents `drupal_create_redirect`,
19
+ `drupal_update_redirect`, and `drupal_update_menu_link`.
20
+
21
+ ## [2.14.0] - 2026-09-06
22
+
23
+ ### Added
24
+ - **Laboratory tenant offboarding on the relay edge (#267).**
25
+ `offboardTenant` fail-closes unless a tenant-scoped assessor snapshot
26
+ can be taken, then overwrite-removes the channel digest, drops the
27
+ grant and session, and tombstones the principal. Revoke and destroy
28
+ rows are independently anchored only after that teardown succeeds.
29
+ `GET /assessor` cites P9.9 only when those anchored rows match the
30
+ digest frozen at export. The held pack stays independently verifiable
31
+ after access is removed. A former principal sees `offboarded`; a
32
+ stranger stays `not_entitled`. The pack never writes `passed`.
33
+ Lab/loopback only — not a hosted-service or design-partner admission
34
+ claim.
35
+ - **Laboratory tenant onboarding on the relay edge (#265).** Optional
36
+ `auth.approvalRequiredTools` issues a one-use approval before fan-down
37
+ for named tools; a later `tools/call` with `arguments.approvalId`
38
+ consumes it (stripped before the tenant sees it). `GET /assessor` cites
39
+ P9.8 when one tenant pack holds anchored allow, deny, and
40
+ approval-gated executions against the live policy digest. Omit the
41
+ list to keep the prior path. A table the edge cannot read refuses
42
+ startup. Lab/loopback only — not a hosted-service or design-partner
43
+ admission claim.
44
+
45
+ ### Security
46
+ - **Independently verifiable evidence on the relay edge (#261).** Optional
47
+ `auth.evidenceAnchor` (`url` + pinned Ed25519 `publicKey`) submits a
48
+ minimized receipt digest to a notary process (`drupal-mcp-anchor`) whose
49
+ private key the edge never holds. One governed execution reconciles by
50
+ stable identifiers (identity, delegation, decision, obligations,
51
+ approval, local execution, target revision, receipt — explicit `none:*`
52
+ absences count). `GET /assessor` serves the caller's own tenant pack:
53
+ data-minimized, bound to the live policy digest, controls cite evidence
54
+ ids or stay `residual`, never `passed`. A table the edge cannot pin, or
55
+ a ledger without a pin, refuses startup. Omitting the table keeps the
56
+ 2.13.0 path (`/assessor` is 404). Lab/loopback only — not a hosted-service
57
+ or design-partner admission claim. Audit Chain NDJSON remains the
58
+ off-system stream, not this anchor.
59
+
10
60
  ## [2.13.1] - 2026-09-05
11
61
 
12
62
  ### Fixed
@@ -1431,6 +1481,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1431
1481
  - User tools gained explicit PII-access assertions.
1432
1482
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1433
1483
 
1484
+ [2.14.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.1
1485
+ [2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
1434
1486
  [2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
1435
1487
  [2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
1436
1488
  [2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![Drupal](https://img.shields.io/badge/drupal-10%20%7C%2011-blue)](https://drupal.org)
8
8
  [![MCP](https://img.shields.io/badge/MCP-2026--07--28-purple)](https://modelcontextprotocol.io)
9
9
 
10
- Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
10
+ Built by **[Jeremy Michael Cerda](https://www.drupal.org/u/jmcerda)** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
12
  **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
@@ -63,7 +63,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
63
63
 
64
64
  ## Features
65
65
 
66
- ### 119 Tools Across 26 Modules
66
+ ### 120 Tools Across 26 Modules
67
67
 
68
68
  | Module | Tools |
69
69
  |--------|-------|
@@ -301,7 +301,7 @@ an operator channel: keep the agent's credentials off it, and pin
301
301
  | [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
302
302
  | [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
303
303
  | [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
304
- | [Tools Reference](docs/tools-reference.md) | Full reference for all 119 tools |
304
+ | [Tools Reference](docs/tools-reference.md) | Full reference for all 120 tools |
305
305
  | [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
306
306
  | [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
307
307
  | [Verification](docs/verification.md) | Prove a secure install: static + live checks, negative probes, evidence artefact, managed residuals |
@@ -324,4 +324,4 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md). Please do not open a publ
324
324
 
325
325
  ## License
326
326
 
327
- [MIT](LICENSE) © 2026 Jeremy Michael Cerda and [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty)
327
+ [MIT](LICENSE) © 2026 [Jeremy Michael Cerda](https://www.drupal.org/u/jmcerda) and [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty)
@@ -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.1",
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/",
@@ -41,7 +42,7 @@
41
42
  "url": "https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues"
42
43
  },
43
44
  "license": "MIT",
44
- "author": "Jeremy Michael Cerda <opensource@wilkesliberty.com> (https://wilkesliberty.com)",
45
+ "author": "Jeremy Michael Cerda <opensource@wilkesliberty.com> (https://www.drupal.org/u/jmcerda)",
45
46
  "contributors": [
46
47
  "Wilkes & Liberty, LLC <opensource@wilkesliberty.com> (https://wilkesliberty.com)"
47
48
  ],
@@ -78,7 +79,7 @@
78
79
  "eslint-plugin-n": "^18.1.0",
79
80
  "eslint-plugin-security": "^4.0.0",
80
81
  "globals": "^17.6.0",
81
- "vitest": "^4.1.8"
82
+ "vitest": "^5.0.0"
82
83
  },
83
84
  "overrides": {
84
85
  "@hono/node-server": "^2.0.5",
@@ -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;