drupal-mcp-connector 2.11.0 → 2.13.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,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.13.0] - 2026-09-02
11
+
12
+ ### Security
13
+ - **Attributable usage, quotas, and abuse signals on the relay edge (#256).**
14
+ Optional usage ledger (`MCP_EDGE_USAGE_MAX_RECORDS` / `relay.usage`)
15
+ records every edge decision (allow or deny) and every fan-down receipt
16
+ against the grant-resolved tenant and the validated principal, carrying
17
+ the frame's `requestId`, a `decisionId`, the bound `policyDigest`, and
18
+ measured cost signals (units, bytes, duration). Optional `auth.quotas`
19
+ (tenant / principal request windows plus an abuse lock) fails closed at
20
+ the edge: an unlisted tenant or principal is `not_entitled`, an exhausted
21
+ window is `429 quota_exceeded` with `Retry-After`, and a principal that
22
+ keeps earning refusals is `429 abuse_locked` — all with zero frames on any
23
+ tunnel. A shared tenant window running out never feeds an individual
24
+ principal's lock; a quota table the edge cannot read refuses startup and
25
+ names the offending path. A response the listener cannot relay is
26
+ `502 fan_down_failed` with a `failed` receipt, and a metering failure
27
+ never changes a verdict. `GET /usage` serves the caller's own tenant
28
+ partition (tenant from `auth.tenantGrants`; any other tenant is
29
+ `not_entitled` with no records) with a reconciliation naming `missing`,
30
+ `duplicate`, and `uncertain` chains; unmatched response frames are
31
+ recorded against the sending tunnel. Omitting both keeps the 2.12.0 path.
32
+ Lab/loopback only — measured usage, not pricing, billing, a hosted
33
+ metering sink, or a hosted-service claim.
34
+
35
+ ## [2.12.0] - 2026-09-02
36
+
37
+ ### Security
38
+ - **W&L-operated policy-bundle promotion on the relay edge (#253).** Optional
39
+ `auth.promotions` maps a SHA-256 digest to a sealed portable document plus
40
+ two distinct operator ids. Eligible documents fan down the tenant-agent
41
+ channel after hello; the agent presents them to local enforcement
42
+ (verify / activate / attest). When the table is present, non-diagnostic
43
+ `tools/call` requires the bound `auth.policies` digest **and** a matching
44
+ agent attestation. The edge never mints a Sentinel HMAC key. Omitting
45
+ `auth.promotions` keeps the digest-only path. Lab/loopback only — not
46
+ tenant self-service or a hosted-service claim.
47
+
10
48
  ## [2.11.0] - 2026-09-02
11
49
 
12
50
  ### Security
@@ -1380,6 +1418,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1380
1418
  - User tools gained explicit PII-access assertions.
1381
1419
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1382
1420
 
1421
+ [2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
1422
+ [2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
1383
1423
  [2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
1384
1424
  [2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
1385
1425
  [1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
@@ -32,6 +32,13 @@
32
32
  * MCP_RATE_LIMIT / MCP_RATE_WINDOW_SEC
33
33
  * Northbound /mcp rate limit (same defaults as the
34
34
  * primary entry point).
35
+ * MCP_EDGE_USAGE_MAX_RECORDS
36
+ * Positive integer enables the in-process usage ledger
37
+ * (or config relay.usage.maxRecords). Every decision and
38
+ * receipt is recorded per tenant and principal, and
39
+ * GET /usage serves the caller's own tenant partition.
40
+ * Unset: nothing is recorded and /usage is 404. Set but
41
+ * unreadable: fatal.
35
42
  *
36
43
  * Config: auth.grants (client id -> [site names]) is mandatory; the edge
37
44
  * refuses to start without it. Optional auth.tenantGrants (client id ->
@@ -39,13 +46,29 @@
39
46
  * auth.actors (sub / azp -> Drupal user UUID) maps the inbound principal
40
47
  * to a Drupal actor for write-like tools. Optional auth.policies
41
48
  * (sub / azp -> SHA-256 digest) is the expected signed policy on the edge.
49
+ * Optional auth.promotions (digest -> sealed document + two operator ids)
50
+ * is the W&L-operated dual-control ledger; the edge fans eligible bundles
51
+ * to the tenant agent and requires a matching local attestation. Optional
52
+ * auth.quotas (tenant / principal request windows plus an abuse lock)
53
+ * fails closed at the edge with zero frames on any refusal; a table the
54
+ * edge cannot read refuses startup.
42
55
  */
43
56
 
44
57
  import { readFileSync } from "node:fs";
45
58
  import process from "node:process";
46
- import { getInboundActors, getInboundGrants, getInboundPolicies, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
59
+ import {
60
+ getInboundActors,
61
+ getInboundGrants,
62
+ getInboundPolicies,
63
+ getInboundPromotions,
64
+ getInboundQuotas,
65
+ getInboundTenantGrants,
66
+ getTlsConfig,
67
+ loadConfig,
68
+ } from "../src/lib/config.js";
47
69
  import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
48
70
  import { createRateLimiter } from "../src/lib/rate-limit.js";
71
+ import { createUsageLedger } from "../src/lib/usage.js";
49
72
  import {
50
73
  createChannelCredentialStore,
51
74
  startEdge,
@@ -123,6 +146,29 @@ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
123
146
  ? rateLimitDefault
124
147
  : Number(rateLimitEnv);
125
148
 
149
+ // Usage ledger (#256): opt-in, in-process, bounded. A restart clears it.
150
+ // A value that is set but unreadable is fatal: the operator asked for
151
+ // metering, and silently running without it would be a lie.
152
+ const usageEnv = process.env.MCP_EDGE_USAGE_MAX_RECORDS;
153
+ const usageRaw = usageEnv !== undefined && usageEnv !== ""
154
+ ? usageEnv
155
+ : config.relay?.usage?.maxRecords;
156
+ let usageMaxRecords = 0;
157
+ if (usageRaw !== undefined && usageRaw !== null) {
158
+ const parsed = typeof usageRaw === "string" && /^[0-9]+$/.test(usageRaw.trim())
159
+ ? Number(usageRaw.trim())
160
+ : usageRaw;
161
+ if (!Number.isInteger(parsed) || parsed <= 0) {
162
+ fatal(
163
+ "MCP_EDGE_USAGE_MAX_RECORDS (or relay.usage.maxRecords) must be a positive "
164
+ + `integer; got ${JSON.stringify(usageRaw)}. Unset it to run without the usage ledger.`,
165
+ );
166
+ }
167
+ usageMaxRecords = parsed;
168
+ }
169
+ const usage = usageMaxRecords > 0 ? createUsageLedger({ maxRecords: usageMaxRecords }) : null;
170
+ const quotas = getInboundQuotas();
171
+
126
172
  let edge;
127
173
  try {
128
174
  edge = await startEdge({
@@ -131,6 +177,9 @@ try {
131
177
  tenantGrants: getInboundTenantGrants(),
132
178
  actors: getInboundActors(),
133
179
  policies: getInboundPolicies(),
180
+ promotions: getInboundPromotions(),
181
+ quotas,
182
+ usage,
134
183
  sites,
135
184
  defaultSite: config.defaultSite,
136
185
  channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
@@ -158,3 +207,11 @@ if (rateLimit > 0) {
158
207
  `[drupal-mcp-edge] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`,
159
208
  );
160
209
  }
210
+ if (usage) {
211
+ console.error(
212
+ `[drupal-mcp-edge] Usage ledger: in-process, ${usageMaxRecords} records max; GET /usage serves one tenant partition.`,
213
+ );
214
+ }
215
+ if (quotas) {
216
+ console.error("[drupal-mcp-edge] Quotas: auth.quotas in force; unlisted tenants / principals are refused.");
217
+ }
@@ -41,6 +41,12 @@
41
41
  "policies": {
42
42
  "_comment": "Optional. Map inbound sub or client_id (azp) to a SHA-256 digest of the expected signed Sentinel policy bundle, e.g. \"content-agent\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\". When present, non-diagnostic tools/call without a mapping are not_entitled. Caller policy/digest arguments are never authority. Omit to keep the prior path (no digest required at the edge). Local verify/activate stays on mcp_sentinel."
43
43
  },
44
+ "promotions": {
45
+ "_comment": "Optional W&L-operated dual-control ledger. Map a SHA-256 digest to { document, approvals } where document is the sealed portable Sentinel bundle (claims + digest + hmac-sha256 seal) and approvals is two distinct operator ids. When present, the edge fans eligible documents to the tenant agent; non-diagnostic tools/call require a matching local attestation. The edge never mints a seal. Omit to keep the digest-only path. Tenant self-service is not this table."
46
+ },
47
+ "quotas": {
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
+ },
44
50
  "revocationFile": "",
45
51
  "introspectionUrl": "",
46
52
  "introspectionClientIdEnv": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.11.0",
3
+ "version": "2.13.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",
package/src/lib/config.js CHANGED
@@ -292,6 +292,46 @@ export function getInboundPolicies() {
292
292
  return entries.length ? Object.fromEntries(entries) : null;
293
293
  }
294
294
 
295
+ /**
296
+ * W&L-operated dual-control promotion ledger keyed by SHA-256 digest.
297
+ * When present, the edge fans eligible sealed documents to the tenant agent.
298
+ * @returns {object|null}
299
+ */
300
+ export function getInboundPromotions() {
301
+ const promotions = loadConfig().auth?.promotions;
302
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
303
+ return null;
304
+ }
305
+ const entries = Object.entries(promotions)
306
+ .map(([key, value]) => [key.trim(), value])
307
+ .filter(([key]) => key && !key.startsWith("_"));
308
+ return entries.length ? Object.fromEntries(entries) : null;
309
+ }
310
+
311
+ /**
312
+ * Tenant / principal quota table plus abuse lock (`auth.quotas`).
313
+ * When present, the relay edge fails closed: a tenant or principal without
314
+ * a row is refused, exhausted windows and locked principals are refused,
315
+ * and a table the edge cannot read refuses startup. Validation lives in
316
+ * usage.js (`normalizeQuotas`).
317
+ * @returns {object|null|unknown} Null when omitted or comment-only; the
318
+ * comment-stripped table when it is an object; otherwise the configured
319
+ * value unchanged (a string, array, number, ...) so `startEdge()` refuses
320
+ * to start on it instead of running unmetered.
321
+ */
322
+ export function getInboundQuotas() {
323
+ const quotas = loadConfig().auth?.quotas;
324
+ if (quotas === undefined || quotas === null) return null;
325
+ // A present value that is not an object is passed through unchanged so
326
+ // the edge refuses to start on it (usage.js normalizeQuotas names it);
327
+ // returning null here would silently run without quotas.
328
+ if (typeof quotas !== "object" || Array.isArray(quotas)) return quotas;
329
+ const entries = Object.entries(quotas)
330
+ .map(([key, value]) => [key.trim(), value])
331
+ .filter(([key]) => key && !key.startsWith("_"));
332
+ return entries.length ? Object.fromEntries(entries) : null;
333
+ }
334
+
295
335
  // ---------------------------------------------------------------------------
296
336
  // Auth headers — never logged, never exposed in tool responses
297
337
  // ---------------------------------------------------------------------------
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Tenant-side local policy enforcement (#253 / DEV-125).
3
+ *
4
+ * Loopback stand-in for mcp_sentinel `McpPolicyBundleRegistry`: mint / verify
5
+ * / activate / simulate / revoke / rollback / emergency deny. The relay edge
6
+ * never receives the signing key and never calls mint. A missing key cannot
7
+ * mint, verify, or activate — emergency deny still arms a deny floor without
8
+ * minting new authority.
9
+ */
10
+
11
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
12
+ import { SEAL_PREFIX } from "./policy-promotion.js";
13
+
14
+ export const POLICY_BUNDLE_VERSION = 1;
15
+
16
+ export const DEFAULT_BUNDLE_TTL = 86400 * 30;
17
+
18
+ export const EMERGENCY_DENY = "*";
19
+
20
+ export const EMERGENCY_DIGEST = "emergency-deny";
21
+
22
+ function normalize(value) {
23
+ if (value === null || typeof value === "string" || typeof value === "number"
24
+ || typeof value === "boolean") {
25
+ return value;
26
+ }
27
+ if (Array.isArray(value)) return value.map(normalize);
28
+ if (value && typeof value === "object") {
29
+ const source = new Map(Object.entries(value));
30
+ const bag = new Map();
31
+ for (const key of [...source.keys()].sort()) {
32
+ bag.set(key, normalize(source.get(key)));
33
+ }
34
+ return Object.fromEntries(bag);
35
+ }
36
+ throw new TypeError("Policy bundle claims may only contain scalars, lists and maps.");
37
+ }
38
+
39
+ /**
40
+ * Canonical JSON of claims (HMAC / digest input). Maps are key-sorted;
41
+ * lists keep caller order. Matches mcp_sentinel `McpPolicyBundle::canonicalJson`.
42
+ *
43
+ * @param {object} claims
44
+ * @returns {string}
45
+ */
46
+ export function canonicalJson(claims) {
47
+ return JSON.stringify(normalize(claims));
48
+ }
49
+
50
+ /**
51
+ * Hex SHA-256 of the canonical claims.
52
+ *
53
+ * @param {object} claims
54
+ * @returns {string}
55
+ */
56
+ export function digestOf(claims) {
57
+ return createHash("sha256").update(canonicalJson(claims)).digest("hex");
58
+ }
59
+
60
+ function safeEqual(left, right) {
61
+ if (typeof left !== "string" || typeof right !== "string") return false;
62
+ const a = Buffer.from(left);
63
+ const b = Buffer.from(right);
64
+ if (a.length !== b.length) return false;
65
+ return timingSafeEqual(a, b);
66
+ }
67
+
68
+ function deniedOperationsOf(claims) {
69
+ const denials = new Map(Object.entries(claims ?? {})).get("denials");
70
+ if (!denials || typeof denials !== "object" || Array.isArray(denials)) return [];
71
+ const ops = new Map(Object.entries(denials)).get("operations");
72
+ if (!Array.isArray(ops)) return [];
73
+ return ops.map(String);
74
+ }
75
+
76
+ function bundleFrom(claims, digest, seal) {
77
+ return Object.freeze({
78
+ claims,
79
+ digest,
80
+ seal,
81
+ version: () => Number(new Map(Object.entries(claims)).get("v") ?? 0),
82
+ expires: () => Number(new Map(Object.entries(claims)).get("expires") ?? 0),
83
+ deniedOperations: () => deniedOperationsOf(claims),
84
+ denies: (operation) => deniedOperationsOf(claims).includes(operation),
85
+ isExpired: (now) => {
86
+ const expires = Number(new Map(Object.entries(claims)).get("expires") ?? 0);
87
+ return expires > 0 && now >= expires;
88
+ },
89
+ toArray: () => ({ ...claims, digest, seal }),
90
+ });
91
+ }
92
+
93
+ /**
94
+ * In-process local enforcement used by the tenant agent in the loopback lab.
95
+ *
96
+ * @param {object} [options]
97
+ * @param {string|null} [options.signingKey] HMAC material. Null = disconnected.
98
+ * @param {() => number} [options.now] Unix seconds.
99
+ * @returns {object}
100
+ */
101
+ export function createLocalPolicyEnforcement({
102
+ signingKey = null,
103
+ now = () => Math.floor(Date.now() / 1000),
104
+ } = {}) {
105
+ let active = null;
106
+ let lastGood = null;
107
+ const revoked = new Map();
108
+
109
+ function canSeal() {
110
+ return typeof signingKey === "string" && signingKey.length > 0;
111
+ }
112
+
113
+ function isRevoked(digest) {
114
+ return revoked.has(digest);
115
+ }
116
+
117
+ function mint(deniedOperations = [], ttl = DEFAULT_BUNDLE_TTL) {
118
+ if (!canSeal()) return null;
119
+ const issued = now();
120
+ const unique = [...new Set((Array.isArray(deniedOperations) ? deniedOperations : [])
121
+ .map(String))];
122
+ const claims = {
123
+ denials: { operations: unique },
124
+ expires: issued + (ttl ?? DEFAULT_BUNDLE_TTL),
125
+ id: randomUUID(),
126
+ issued,
127
+ v: POLICY_BUNDLE_VERSION,
128
+ };
129
+ const digest = digestOf(claims);
130
+ const seal = SEAL_PREFIX + createHmac("sha256", signingKey).update(digest).digest("hex");
131
+ return bundleFrom(claims, digest, seal);
132
+ }
133
+
134
+ function verify(document) {
135
+ if (!canSeal()) return null;
136
+ if (!document || typeof document !== "object" || Array.isArray(document)) return null;
137
+ const bag = new Map(Object.entries(document));
138
+ const seal = bag.get("seal");
139
+ const claimedDigest = bag.get("digest");
140
+ bag.delete("seal");
141
+ bag.delete("digest");
142
+ const claims = Object.fromEntries(bag);
143
+ if (typeof seal !== "string" || !seal.startsWith(SEAL_PREFIX)) return null;
144
+ if (Number(new Map(Object.entries(claims)).get("v") ?? 0) !== POLICY_BUNDLE_VERSION) {
145
+ return null;
146
+ }
147
+ const digest = digestOf(claims);
148
+ if (!safeEqual(digest, typeof claimedDigest === "string" ? claimedDigest : "")) return null;
149
+ const expected = SEAL_PREFIX
150
+ + createHmac("sha256", signingKey).update(digest).digest("hex");
151
+ if (!safeEqual(expected, seal)) return null;
152
+ const bundle = bundleFrom(claims, digest, seal);
153
+ if (bundle.isExpired(now())) return null;
154
+ if (isRevoked(digest)) return null;
155
+ return bundle;
156
+ }
157
+
158
+ function attestation() {
159
+ return active;
160
+ }
161
+
162
+ function activeDigest() {
163
+ const digest = active && typeof active.digest === "string" ? active.digest : null;
164
+ return digest || null;
165
+ }
166
+
167
+ function emergencyDeny() {
168
+ if (active && typeof active.digest === "string") lastGood = active;
169
+ active = {
170
+ digest: EMERGENCY_DIGEST,
171
+ activated_at: now(),
172
+ previous: active && typeof active.digest === "string" ? active.digest : null,
173
+ emergency: true,
174
+ bundle: {
175
+ v: POLICY_BUNDLE_VERSION,
176
+ denials: { operations: [EMERGENCY_DENY] },
177
+ expires: 0,
178
+ id: EMERGENCY_DIGEST,
179
+ issued: now(),
180
+ },
181
+ };
182
+ }
183
+
184
+ function activateBundle(bundle) {
185
+ if (!canSeal() || !bundle) return null;
186
+ if (active && typeof active.digest === "string") lastGood = active;
187
+ const previous = active && typeof active.digest === "string" ? active.digest : null;
188
+ active = {
189
+ digest: bundle.digest,
190
+ activated_at: now(),
191
+ previous,
192
+ bundle: bundle.toArray(),
193
+ };
194
+ return {
195
+ digest: bundle.digest,
196
+ activated_at: active.activated_at,
197
+ previous,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Verify then activate a portable document. This is the agent hook.
203
+ *
204
+ * @param {object} document
205
+ * @returns {{ok: boolean, digest?: string, attested?: boolean, reason?: string}}
206
+ */
207
+ function activate(document) {
208
+ const bundle = verify(document);
209
+ if (!bundle) return { ok: false, reason: "unverified" };
210
+ const result = activateBundle(bundle);
211
+ if (!result) return { ok: false, reason: "cannot_activate" };
212
+ return { ok: true, digest: result.digest, attested: true };
213
+ }
214
+
215
+ function simulate(operation, localDenies, candidate = null) {
216
+ const digest = candidate?.digest ?? activeDigest();
217
+ if (localDenies) {
218
+ return { allow: false, reason: "local_deny", digest };
219
+ }
220
+ if (candidate === null && active && active.emergency) {
221
+ return {
222
+ allow: false,
223
+ reason: "emergency_deny",
224
+ digest: digest ?? EMERGENCY_DIGEST,
225
+ };
226
+ }
227
+ let bundle = candidate;
228
+ if (bundle === null) {
229
+ const document = active?.bundle ?? null;
230
+ bundle = document ? verify(document) : null;
231
+ if (bundle === null && digest) {
232
+ return { allow: false, reason: "bundle_unverified", digest };
233
+ }
234
+ }
235
+ if (bundle && (bundle.denies(operation) || bundle.denies(EMERGENCY_DENY))) {
236
+ return { allow: false, reason: "bundle_deny", digest: bundle.digest };
237
+ }
238
+ return { allow: true, reason: "allow", digest };
239
+ }
240
+
241
+ function revoke(digest) {
242
+ if (typeof digest !== "string" || !digest) return;
243
+ revoked.set(digest, now());
244
+ if (activeDigest() === digest) emergencyDeny();
245
+ }
246
+
247
+ function rollback() {
248
+ if (!lastGood || typeof lastGood.digest !== "string") return null;
249
+ if (isRevoked(lastGood.digest)) return null;
250
+ active = lastGood;
251
+ return lastGood;
252
+ }
253
+
254
+ return {
255
+ canSeal,
256
+ mint,
257
+ verify,
258
+ activate,
259
+ activateBundle,
260
+ attestation,
261
+ activeDigest,
262
+ simulate,
263
+ revoke,
264
+ rollback,
265
+ emergencyDeny,
266
+ isRevoked,
267
+ };
268
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * W&L-operated dual-control promotion ledger (#253 / DEV-125).
3
+ *
4
+ * The edge never mints a Sentinel HMAC seal. A promotion is an already-sealed
5
+ * portable document plus two distinct operator ids. Eligibility is the gate
6
+ * before the tenant agent may receive the artifact. Caller-supplied documents
7
+ * are never read here.
8
+ */
9
+
10
+ export const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
11
+
12
+ export const SEAL_PREFIX = "hmac-sha256:";
13
+
14
+ function tableHasKeys(table) {
15
+ return Object.keys(table).some((key) => {
16
+ const id = key.trim();
17
+ return id && !id.startsWith("_");
18
+ });
19
+ }
20
+
21
+ function uniqueApprovals(raw) {
22
+ if (!Array.isArray(raw)) return [];
23
+ const seen = new Set();
24
+ const out = [];
25
+ for (const value of raw) {
26
+ const id = String(value).trim();
27
+ if (!id || id.startsWith("_") || seen.has(id)) continue;
28
+ seen.add(id);
29
+ out.push(id);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function portableDocument(raw) {
35
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
36
+ const bag = new Map(Object.entries(raw));
37
+ const digest = typeof bag.get("digest") === "string"
38
+ ? bag.get("digest").trim().toLowerCase()
39
+ : "";
40
+ const seal = typeof bag.get("seal") === "string" ? bag.get("seal").trim() : "";
41
+ if (!POLICY_DIGEST.test(digest)) return null;
42
+ if (!seal.startsWith(SEAL_PREFIX) || seal.length <= SEAL_PREFIX.length) return null;
43
+ bag.set("digest", digest);
44
+ bag.set("seal", seal);
45
+ return Object.freeze(Object.fromEntries(bag));
46
+ }
47
+
48
+ /**
49
+ * Normalize `auth.promotions` (digest → sealed document + approvals).
50
+ * Comment keys and malformed rows are dropped. Duplicate operator ids in
51
+ * `approvals` count once. A row is eligible only with two distinct operators
52
+ * and a document whose claimed digest matches the map key.
53
+ *
54
+ * @param {object|null} promotions
55
+ * @returns {object|null}
56
+ */
57
+ export function normalizePromotions(promotions) {
58
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
59
+ return null;
60
+ }
61
+ const entries = [];
62
+ for (const [rawKey, value] of Object.entries(promotions)) {
63
+ const key = rawKey.trim().toLowerCase();
64
+ if (!key || key.startsWith("_") || !POLICY_DIGEST.test(key)) continue;
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
66
+ const bag = new Map(Object.entries(value));
67
+ const document = portableDocument(bag.get("document"));
68
+ if (!document || document.digest !== key) continue;
69
+ const approvals = uniqueApprovals(bag.get("approvals"));
70
+ entries.push([key, Object.freeze({
71
+ digest: key,
72
+ document,
73
+ approvals: Object.freeze(approvals),
74
+ eligible: approvals.length >= 2,
75
+ })]);
76
+ }
77
+ return entries.length ? Object.fromEntries(entries) : null;
78
+ }
79
+
80
+ /**
81
+ * Whether a promotions table is in force (fail-closed even when every row
82
+ * is ineligible). Comment-only objects are not in force.
83
+ *
84
+ * @param {object|null} promotions
85
+ * @returns {boolean}
86
+ */
87
+ export function promotionsRequired(promotions) {
88
+ if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
89
+ return false;
90
+ }
91
+ return tableHasKeys(promotions);
92
+ }
93
+
94
+ /**
95
+ * Eligible sealed documents ready to fan down.
96
+ *
97
+ * @param {object|null} promotions
98
+ * @returns {Array<{digest: string, document: object, approvals: string[]}>}
99
+ */
100
+ export function eligiblePromotions(promotions) {
101
+ const table = normalizePromotions(promotions);
102
+ if (!table) return [];
103
+ return Object.values(table).filter((row) => row.eligible);
104
+ }
105
+
106
+ /**
107
+ * Look up one digest in the ledger.
108
+ *
109
+ * @param {object} params
110
+ * @param {string|null} [params.digest]
111
+ * @param {object|null} [params.promotions]
112
+ * @returns {{document: object|null, eligible: boolean, reason: "not_entitled"|null}}
113
+ */
114
+ export function resolveEligiblePromotion({ digest = null, promotions = null } = {}) {
115
+ const required = promotionsRequired(promotions);
116
+ const table = normalizePromotions(promotions);
117
+ if (!table) {
118
+ return {
119
+ document: null,
120
+ eligible: false,
121
+ reason: required ? "not_entitled" : null,
122
+ };
123
+ }
124
+ const key = typeof digest === "string" ? digest.trim().toLowerCase() : "";
125
+ const record = key ? new Map(Object.entries(table)).get(key) : null;
126
+ if (!record || !record.eligible) {
127
+ return { document: null, eligible: false, reason: "not_entitled" };
128
+ }
129
+ return { document: record.document, eligible: true, reason: null };
130
+ }