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 +40 -0
- 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/config.js +39 -0
- package/src/lib/contracts/approval.js +5 -0
- package/src/lib/evidence.js +607 -0
- package/src/lib/http-auth.js +65 -3
- package/src/lib/relay/edge.js +551 -7
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);
|
package/bin/drupal-mcp-edge.js
CHANGED
|
@@ -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.
|
|
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;
|