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.
@@ -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
+ }
@@ -78,7 +78,7 @@ export class Backend {
78
78
  * Read the raw `path` field (alias/pid/langcode) and internal id of an entity,
79
79
  * for callers that must round-trip the alias `pid` on an in-place update (the
80
80
  * canonical entity only exposes `path.alias` as `url`). Optional capability:
81
- * the default returns nulls so read-only/path-less backends are safe. See DEV-116.
81
+ * the default returns nulls so read-only/path-less backends are safe. See the 1.5.1 alias fix.
82
82
  * @param {{entityType: string, bundle: string, id: string}} _ref
83
83
  * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
84
84
  */
@@ -137,7 +137,7 @@ function applyFilter(params, { field, op = "eq", value }) {
137
137
  /**
138
138
  * Bind JSON:API `uid` from the grant-stamped identity. Caller uid is overwritten.
139
139
  * User entities are left unchanged. No actor claim (auth.actors not in effect
140
- * for this principal) leaves relationships as-is — the prior, pre-DEV-123 path.
140
+ * for this principal) leaves relationships as-is — the path before actor mapping (#247).
141
141
  * A *present* actor claim that fails UUID validation fails closed (throws)
142
142
  * rather than silently keeping a caller-supplied uid: resolveActor()/
143
143
  * normalizeActors() already validate the shape before stamping identity.actor,
@@ -309,7 +309,7 @@ export class JsonApiBackend extends Backend {
309
309
  * correct in-place alias *update* must round-trip the existing alias's `pid`
310
310
  * (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
311
311
  * — so this method exposes it. Returns nulls for entities/backends without a
312
- * path field. See DEV-116.
312
+ * path field. See the 1.5.1 alias fix.
313
313
  * @param {{entityType: string, bundle: string, id: string}} ref
314
314
  * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
315
315
  */
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;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Sentinel's governed draft-continuation contract (d.o #3621022).
3
+ * Core JSON:API revision selectors support reads, not PATCH requests.
4
+ */
5
+
6
+ /**
7
+ * Validate or continue a draft, using the same payload and revision precondition.
8
+ * No canonical fallback: an absent endpoint or refused precondition stops work.
9
+ * @param {object} backend JSON:API backend.
10
+ * @param {object} input Canonical update input plus draftRevision.
11
+ * @param {boolean} preflight Validate without saving.
12
+ * @returns {Promise<object>} Preflight metadata or the written canonical entity.
13
+ */
14
+ export async function writeDraft(backend, input, preflight = false) {
15
+ const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
16
+ const live = String(draftRevision?.liveVid ?? "");
17
+ const working = String(draftRevision?.workingVid ?? "");
18
+ if (entityType !== "node" || !/^[1-9]\d*$/.test(live)
19
+ || !/^[1-9]\d*$/.test(working) || live === working) {
20
+ throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
21
+ }
22
+ if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
23
+ throw new Error("This backend does not support governed draft continuation.");
24
+ }
25
+ const data = { type: `${entityType}--${bundle}`, id, attributes };
26
+ if (relationships) data.relationships = relationships;
27
+ let result;
28
+ try {
29
+ result = await backend.rawQuery({
30
+ path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
31
+ options: {
32
+ method: "PATCH",
33
+ headers: {
34
+ "If-Match": `"${live}:${working}"`,
35
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
36
+ },
37
+ body: JSON.stringify({ data }),
38
+ },
39
+ });
40
+ } catch (error) {
41
+ if (/Drupal (404|405)\b/.test(String(error?.message))) {
42
+ throw new Error("The site does not provide Sentinel's governed draft endpoint (d.o #3621022). Update the server-side module; the draft was not discarded and no canonical fallback was attempted.", { cause: error });
43
+ }
44
+ throw error;
45
+ }
46
+ if (preflight) {
47
+ if (result?.meta?.draft_preflight !== true
48
+ || String(result.meta.live) !== live || String(result.meta.working) !== working) {
49
+ throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
50
+ }
51
+ return result;
52
+ }
53
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
54
+ throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
55
+ }
56
+ return backend.toCanonical(result.data);
57
+ }