drupal-mcp-connector 2.12.0 → 2.13.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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Attributable usage, quotas, and abuse signals on the relay edge (#256).
3
+ *
4
+ * Metering at the seam, lab bounds. Every edge decision (allow or deny) and
5
+ * every fan-down receipt is a record keyed by the grant-resolved tenant and
6
+ * the validated principal, carrying the request / decision / receipt ids the
7
+ * frame already stamps. Quotas fail closed at this boundary: a tenant or
8
+ * principal without a row is refused, an exhausted window is refused, and a
9
+ * principal that keeps earning denials is locked. Cost signals are measured
10
+ * (units, bytes, duration), never priced — pricing and invoicing are not this
11
+ * module.
12
+ *
13
+ * Nothing here is a hosted metering sink. The ledger is in-process and
14
+ * bounded; a restart clears it, and `reconcileUsage` says so when it dropped
15
+ * rows. Caller-supplied tenant / principal fields are never authority: the
16
+ * edge attributes from its own identity object and grant tables.
17
+ */
18
+
19
+ import { randomUUID } from "node:crypto";
20
+ import { createRateLimiter } from "./rate-limit.js";
21
+
22
+ /** Record phases. A denied decision never has a receipt. */
23
+ export const USAGE_PHASES = Object.freeze(["decision", "receipt"]);
24
+
25
+ /** Decision vocabulary at the edge. */
26
+ export const USAGE_DECISIONS = Object.freeze(["allow", "deny"]);
27
+
28
+ /**
29
+ * Receipt outcomes. `unknown` means the frame crossed but no settled
30
+ * response came back — the tenant may have executed the request.
31
+ */
32
+ export const RECEIPT_OUTCOMES = Object.freeze(["ok", "failed", "unknown"]);
33
+
34
+ /** Reconciliation states over one request / decision / receipt chain. */
35
+ export const RECONCILE_STATES = Object.freeze([
36
+ "settled",
37
+ "denied",
38
+ "missing",
39
+ "duplicate",
40
+ "uncertain",
41
+ ]);
42
+
43
+ const PHASE_SET = new Set(USAGE_PHASES);
44
+ const DECISION_SET = new Set(USAGE_DECISIONS);
45
+ const OUTCOME_SET = new Set(RECEIPT_OUTCOMES);
46
+
47
+ const DEFAULT_WINDOW_SEC = 60;
48
+ const DEFAULT_LOCK_SEC = 300;
49
+ const DEFAULT_MAX_RECORDS = 10_000;
50
+ const DEFAULT_MAX_KEYS = 10_000;
51
+
52
+ function cleanKey(value) {
53
+ const key = typeof value === "string" ? value.trim() : "";
54
+ return key && !key.startsWith("_") ? key : "";
55
+ }
56
+
57
+ function positiveInt(value) {
58
+ return Number.isInteger(value) && value > 0 ? value : null;
59
+ }
60
+
61
+ function isRecord(value) {
62
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ function grantIds(values) {
66
+ if (!Array.isArray(values)) return [];
67
+ return [...new Set(values.map((value) => cleanKey(value)).filter(Boolean))];
68
+ }
69
+
70
+ function identityValue(value) {
71
+ return typeof value === "string" && value ? value : null;
72
+ }
73
+
74
+ /**
75
+ * Principal partition key: inbound `sub`, then `azp` / client id, exactly
76
+ * as the issuer minted them. Matches the precedence and the raw-value
77
+ * convention of `auth.actors` / `auth.policies` / `resolveTenantRoute`:
78
+ * config keys are trimmed, identity values are not, so a padded claim never
79
+ * matches a row and fails closed. Never a caller field.
80
+ *
81
+ * @param {object|null} identity
82
+ * @returns {string|null}
83
+ */
84
+ export function usagePrincipalKey(identity) {
85
+ if (!isRecord(identity)) return null;
86
+ return identityValue(identity.sub) ?? identityValue(identity.clientId);
87
+ }
88
+
89
+ /**
90
+ * Tenant a principal's usage is attributed to when the edge has not yet
91
+ * routed (early denials): the unique tenant its grant names, else null.
92
+ * Attribution only — routing authority stays with `resolveTenantRoute`.
93
+ *
94
+ * @param {object|null} identity
95
+ * @param {object|null} tenantGrants
96
+ * @returns {string|null}
97
+ */
98
+ export function attributedTenant(identity, tenantGrants) {
99
+ if (!isRecord(identity) || !isRecord(tenantGrants)) return null;
100
+ const clientId = typeof identity.clientId === "string" ? identity.clientId : "";
101
+ if (!clientId) return null;
102
+ const granted = grantIds(new Map(Object.entries(tenantGrants)).get(clientId));
103
+ return granted.length === 1 ? granted[0] : null;
104
+ }
105
+
106
+ const QUOTA_KEYS = new Set(["tenants", "principals", "abuse"]);
107
+
108
+ function invalid(reason) {
109
+ return Object.freeze({ invalid: true, reason });
110
+ }
111
+
112
+ /**
113
+ * @returns {{rows: object, required: boolean}|{invalid: true, reason: string}}
114
+ */
115
+ function quotaRows(raw, name) {
116
+ if (raw === undefined || raw === null) return { rows: Object.freeze({}), required: false };
117
+ if (!isRecord(raw)) return invalid(name);
118
+ const entries = [];
119
+ for (const [rawKey, value] of Object.entries(raw)) {
120
+ const key = cleanKey(rawKey);
121
+ if (!key) continue;
122
+ if (!isRecord(value)) return invalid(`${name}.${key}`);
123
+ const requests = positiveInt(value.requests);
124
+ if (requests === null) return invalid(`${name}.${key}.requests`);
125
+ const windowSec = value.windowSec === undefined
126
+ ? DEFAULT_WINDOW_SEC
127
+ : positiveInt(value.windowSec);
128
+ if (windowSec === null) return invalid(`${name}.${key}.windowSec`);
129
+ entries.push([key, Object.freeze({ requests, windowSec })]);
130
+ }
131
+ return { rows: Object.freeze(Object.fromEntries(entries)), required: entries.length > 0 };
132
+ }
133
+
134
+ function abuseBlock(raw) {
135
+ if (raw === undefined || raw === null) return null;
136
+ if (!isRecord(raw)) return invalid("abuse");
137
+ const denials = positiveInt(raw.denials);
138
+ if (denials === null) return invalid("abuse.denials");
139
+ const windowSec = raw.windowSec === undefined ? DEFAULT_WINDOW_SEC : positiveInt(raw.windowSec);
140
+ if (windowSec === null) return invalid("abuse.windowSec");
141
+ const lockSec = raw.lockSec === undefined ? DEFAULT_LOCK_SEC : positiveInt(raw.lockSec);
142
+ if (lockSec === null) return invalid("abuse.lockSec");
143
+ return Object.freeze({ denials, windowSec, lockSec });
144
+ }
145
+
146
+ /**
147
+ * Normalize `auth.quotas`.
148
+ *
149
+ * Shape: `{ tenants: { "<agentId>": { requests, windowSec? } },
150
+ * principals: { "<sub|azp>": { requests, windowSec? } },
151
+ * abuse: { denials, windowSec?, lockSec? } }`. Comment keys are ignored.
152
+ * Anything else that is not exactly that shape — a table that is not an
153
+ * object, an unknown key, a sub-table that is not an object, a row with a
154
+ * non-positive-integer `requests` / `windowSec`, a malformed `abuse` block
155
+ * — makes the whole table **invalid**, never "omitted": the gate then
156
+ * refuses everything and the edge refuses to start. Only `null` /
157
+ * `undefined` (absent) and a comment-only object mean "omitted". A sub-table that names any id is *required*,
158
+ * so an id without a row fails closed.
159
+ *
160
+ * @param {object|null} raw
161
+ * @returns {object|null} Null when the table is omitted or comment-only;
162
+ * `{ invalid: true, reason }` naming the offending path; else the table.
163
+ */
164
+ export function normalizeQuotas(raw) {
165
+ if (raw === null || raw === undefined) return null;
166
+ if (!isRecord(raw)) return invalid("quotas");
167
+ for (const rawKey of Object.keys(raw)) {
168
+ const key = cleanKey(rawKey);
169
+ if (key && !QUOTA_KEYS.has(key)) return invalid(key);
170
+ }
171
+ const bag = new Map(Object.entries(raw));
172
+ const tenants = quotaRows(bag.get("tenants"), "tenants");
173
+ if (tenants.invalid) return tenants;
174
+ const principals = quotaRows(bag.get("principals"), "principals");
175
+ if (principals.invalid) return principals;
176
+ const abuse = abuseBlock(bag.get("abuse"));
177
+ if (abuse?.invalid) return abuse;
178
+ if (!tenants.required && !principals.required && abuse === null) return null;
179
+ return Object.freeze({
180
+ tenants: tenants.rows,
181
+ tenantsRequired: tenants.required,
182
+ principals: principals.rows,
183
+ principalsRequired: principals.required,
184
+ abuse,
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Whether a quota table is in force (fail-closed even when every row is
190
+ * malformed).
191
+ *
192
+ * @param {object|null} raw
193
+ * @returns {boolean}
194
+ */
195
+ export function quotasRequired(raw) {
196
+ return normalizeQuotas(raw) !== null;
197
+ }
198
+
199
+ /**
200
+ * Quota and abuse gate. Fixed windows per tenant and per principal, plus a
201
+ * denial counter that locks a principal. Every request reaching the window
202
+ * checks counts whether or not it is then allowed, so a flood of refused
203
+ * requests cannot probe for free; a request refused by the abuse lock is
204
+ * refused before the windows and does not drain the tenant's shared one.
205
+ *
206
+ * @param {object} [options]
207
+ * @param {object|null} [options.quotas] Raw `auth.quotas`.
208
+ * @param {() => number} [options.now]
209
+ * @param {number} [options.maxKeys] Soft cap on tracked principals in the
210
+ * abuse table; expired entries are pruned first, then the oldest.
211
+ * @returns {{enabled: boolean, check: Function, noteDenial: Function, state: Function, stats: Function}}
212
+ */
213
+ export function createQuotaGate({
214
+ quotas = null,
215
+ now = () => Date.now(),
216
+ maxKeys = DEFAULT_MAX_KEYS,
217
+ } = {}) {
218
+ const table = normalizeQuotas(quotas);
219
+ if (!table) {
220
+ return Object.freeze({
221
+ enabled: false,
222
+ invalid: false,
223
+ reason: null,
224
+ check: () => ({ allowed: true }),
225
+ noteDenial: () => ({ locked: false }),
226
+ state: () => ({ locked: false, retryAfterSec: 0, denials: 0 }),
227
+ stats: () => ({ trackedPrincipals: 0, maxKeys: 0 }),
228
+ });
229
+ }
230
+ if (table.invalid) {
231
+ // A quota table that cannot be read authorizes nobody. The edge refuses
232
+ // to start on this; the gate refuses every request as defense in depth.
233
+ return Object.freeze({
234
+ enabled: true,
235
+ invalid: true,
236
+ reason: table.reason,
237
+ check: () => ({ allowed: false, reason: "not_entitled", scope: "config", retryAfterSec: 0 }),
238
+ noteDenial: () => ({ locked: false }),
239
+ state: () => ({ locked: false, retryAfterSec: 0, denials: 0 }),
240
+ stats: () => ({ trackedPrincipals: 0, maxKeys: 0 }),
241
+ });
242
+ }
243
+
244
+ function limiters(rows) {
245
+ return new Map(Object.entries(rows).map(([id, row]) => [
246
+ id,
247
+ createRateLimiter({ limit: row.requests, windowMs: row.windowSec * 1000, now }),
248
+ ]));
249
+ }
250
+ const tenantLimiters = limiters(table.tenants);
251
+ const principalLimiters = limiters(table.principals);
252
+ const abuse = table.abuse;
253
+ const keyBound = positiveInt(maxKeys) ?? DEFAULT_MAX_KEYS;
254
+ /** @type {Map<string, {times: number[], lockedUntil: number}>} */
255
+ const denials = new Map();
256
+
257
+ function live(entry, t) {
258
+ if (entry.lockedUntil > t) return true;
259
+ if (entry.lockedUntil) return false;
260
+ return entry.times.some((ts) => t - ts < abuse.windowSec * 1000);
261
+ }
262
+
263
+ /** Drop expired principals; if still at the bound, drop the oldest. */
264
+ function prune(t) {
265
+ for (const [key, entry] of denials) {
266
+ if (!live(entry, t)) denials.delete(key);
267
+ }
268
+ while (denials.size >= keyBound) {
269
+ const oldest = denials.keys().next().value;
270
+ if (oldest === undefined) break;
271
+ denials.delete(oldest);
272
+ }
273
+ }
274
+
275
+ function deny(reason, scope, retryAfterSec = 0) {
276
+ return { allowed: false, reason, scope, retryAfterSec };
277
+ }
278
+
279
+ function lockState(key) {
280
+ if (!abuse || !key) return { locked: false, retryAfterSec: 0, denials: 0 };
281
+ const entry = denials.get(key);
282
+ if (!entry) return { locked: false, retryAfterSec: 0, denials: 0 };
283
+ const t = now();
284
+ if (entry.lockedUntil > t) {
285
+ return {
286
+ locked: true,
287
+ retryAfterSec: Math.ceil((entry.lockedUntil - t) / 1000),
288
+ denials: entry.times.length,
289
+ };
290
+ }
291
+ if (entry.lockedUntil) {
292
+ denials.delete(key);
293
+ return { locked: false, retryAfterSec: 0, denials: 0 };
294
+ }
295
+ entry.times = entry.times.filter((ts) => t - ts < abuse.windowSec * 1000);
296
+ if (!entry.times.length) denials.delete(key);
297
+ return { locked: false, retryAfterSec: 0, denials: entry.times.length };
298
+ }
299
+
300
+ return Object.freeze({
301
+ enabled: true,
302
+ invalid: false,
303
+ reason: null,
304
+
305
+ /**
306
+ * The abuse lock is checked first: a locked principal is refused before
307
+ * the windows and does not consume the tenant's shared window. Every
308
+ * request that reaches the window checks counts, allowed or not.
309
+ *
310
+ * @param {{tenant?: string|null, principalKey?: string|null}} params
311
+ * @returns {{allowed: true}|{allowed: false, reason: string, scope: string, retryAfterSec: number}}
312
+ */
313
+ check({ tenant = null, principalKey = null } = {}) {
314
+ const key = identityValue(principalKey);
315
+ const lock = lockState(key);
316
+ if (lock.locked) return deny("abuse_locked", "abuse", lock.retryAfterSec);
317
+ if (table.tenantsRequired) {
318
+ const tenantId = identityValue(tenant);
319
+ const limiter = tenantId ? tenantLimiters.get(tenantId) : undefined;
320
+ if (!limiter) return deny("not_entitled", "tenant");
321
+ const verdict = limiter.check(tenantId);
322
+ if (!verdict.allowed) return deny("quota_exceeded", "tenant", verdict.retryAfterSec);
323
+ }
324
+ if (table.principalsRequired) {
325
+ const limiter = key ? principalLimiters.get(key) : undefined;
326
+ if (!limiter) return deny("not_entitled", "principal");
327
+ const verdict = limiter.check(key);
328
+ if (!verdict.allowed) return deny("quota_exceeded", "principal", verdict.retryAfterSec);
329
+ }
330
+ return { allowed: true };
331
+ },
332
+
333
+ /**
334
+ * Count one post-authentication denial against a principal.
335
+ * @param {string|null} principalKey
336
+ * @returns {{locked: boolean, retryAfterSec?: number}}
337
+ */
338
+ noteDenial(principalKey) {
339
+ if (!abuse) return { locked: false };
340
+ const key = identityValue(principalKey);
341
+ if (!key) return { locked: false };
342
+ const current = lockState(key);
343
+ if (current.locked) return { locked: true, retryAfterSec: current.retryAfterSec };
344
+ const entry = denials.get(key) ?? { times: [], lockedUntil: 0 };
345
+ if (!denials.has(key) && denials.size >= keyBound) prune(now());
346
+ entry.times.push(now());
347
+ denials.set(key, entry);
348
+ if (entry.times.length >= abuse.denials) {
349
+ entry.lockedUntil = now() + abuse.lockSec * 1000;
350
+ return { locked: true, retryAfterSec: abuse.lockSec };
351
+ }
352
+ return { locked: false };
353
+ },
354
+
355
+ /**
356
+ * @param {string|null} principalKey
357
+ * @returns {{locked: boolean, retryAfterSec: number, denials: number}}
358
+ */
359
+ state(principalKey) {
360
+ return lockState(identityValue(principalKey));
361
+ },
362
+
363
+ /** @returns {{trackedPrincipals: number, maxKeys: number}} */
364
+ stats() {
365
+ return { trackedPrincipals: denials.size, maxKeys: keyBound };
366
+ },
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Bounded in-process usage ledger.
372
+ *
373
+ * Decision records carry `decision` (`allow` / `deny`), `reason`,
374
+ * `requestId` (the frame id, only when dispatched), and a `decisionId`.
375
+ * Receipt records carry `outcome`, `status`, cost signals, the same
376
+ * `requestId`, and the `decisionId` they settle. Records are frozen.
377
+ *
378
+ * @param {object} [options]
379
+ * @param {() => number} [options.now]
380
+ * @param {number} [options.maxRecords] Oldest rows are dropped beyond this.
381
+ * @returns {object}
382
+ */
383
+ export function createUsageLedger({
384
+ now = () => Date.now(),
385
+ maxRecords = DEFAULT_MAX_RECORDS,
386
+ } = {}) {
387
+ const bound = positiveInt(maxRecords);
388
+ if (bound === null) {
389
+ throw new TypeError("createUsageLedger requires maxRecords to be a positive integer.");
390
+ }
391
+ const rows = [];
392
+ let seq = 0;
393
+ let dropped = 0;
394
+
395
+ return {
396
+ get size() {
397
+ return rows.length;
398
+ },
399
+
400
+ /**
401
+ * @param {object} entry
402
+ * @returns {object} The frozen, stamped record.
403
+ */
404
+ record(entry) {
405
+ if (!isRecord(entry)) throw new TypeError("A usage record must be an object.");
406
+ const bag = new Map(Object.entries(entry));
407
+ const phase = bag.get("phase");
408
+ if (!PHASE_SET.has(phase)) throw new TypeError(`Unknown usage phase: ${String(phase)}`);
409
+ if (phase === "decision" && !DECISION_SET.has(bag.get("decision"))) {
410
+ throw new TypeError(`Unknown usage decision: ${String(bag.get("decision"))}`);
411
+ }
412
+ if (phase === "receipt" && !OUTCOME_SET.has(bag.get("outcome"))) {
413
+ throw new TypeError(`Unknown receipt outcome: ${String(bag.get("outcome"))}`);
414
+ }
415
+ seq += 1;
416
+ const existingDecision = bag.get("decisionId");
417
+ const stamped = {
418
+ ...entry,
419
+ seq,
420
+ at: new Date(now()).toISOString(),
421
+ decisionId: typeof existingDecision === "string" && existingDecision
422
+ ? existingDecision
423
+ : (phase === "decision" ? randomUUID() : null),
424
+ };
425
+ if (phase === "receipt") {
426
+ const existingReceipt = bag.get("receiptId");
427
+ stamped.receiptId = typeof existingReceipt === "string" && existingReceipt
428
+ ? existingReceipt
429
+ : randomUUID();
430
+ }
431
+ const frozen = Object.freeze(stamped);
432
+ rows.push(frozen);
433
+ if (rows.length > bound) {
434
+ rows.shift();
435
+ dropped += 1;
436
+ }
437
+ return frozen;
438
+ },
439
+
440
+ /** @returns {object[]} Every retained record, oldest first. */
441
+ records() {
442
+ return rows.slice();
443
+ },
444
+
445
+ /**
446
+ * One tenant partition, optionally narrowed to one principal. A missing
447
+ * tenant selects nothing — there is no all-tenants read on this surface.
448
+ *
449
+ * @param {{tenant?: string|null, principalKey?: string|null}} [params]
450
+ * @returns {object[]}
451
+ */
452
+ query({ tenant = null, principalKey = null } = {}) {
453
+ const tenantId = typeof tenant === "string" ? tenant.trim() : "";
454
+ if (!tenantId) return [];
455
+ const key = typeof principalKey === "string" && principalKey.trim()
456
+ ? principalKey.trim()
457
+ : null;
458
+ return rows.filter((row) => row.tenant === tenantId
459
+ && (key === null || row.principalKey === key));
460
+ },
461
+
462
+ /** @returns {{size: number, dropped: number, maxRecords: number}} */
463
+ stats() {
464
+ return { size: rows.length, dropped, maxRecords: bound };
465
+ },
466
+ };
467
+ }
468
+
469
+ const READ_DENIED = Object.freeze({ ok: false, reason: "not_entitled" });
470
+
471
+ /**
472
+ * Tenant-scoped usage read. The tenant is resolved from the caller's
473
+ * `auth.tenantGrants` row; a `tenant` argument is a confirming hint inside
474
+ * that grant, never authority. Without a grant table, without a ledger, for
475
+ * a principal with no grant, for a hint outside the grant, or for a
476
+ * multi-tenant grant with no hint, the read is `not_entitled` with no
477
+ * records.
478
+ *
479
+ * @param {object} params
480
+ * @param {object|null} [params.identity]
481
+ * @param {object|null} [params.tenantGrants]
482
+ * @param {string|null} [params.tenant]
483
+ * @param {string|null} [params.principalKey]
484
+ * @param {object|null} [params.ledger]
485
+ * @returns {{ok: true, tenant: string, records: object[]}|{ok: false, reason: "not_entitled"}}
486
+ */
487
+ export function readUsage({
488
+ identity = null,
489
+ tenantGrants = null,
490
+ tenant = null,
491
+ principalKey = null,
492
+ ledger = null,
493
+ } = {}) {
494
+ if (!ledger || typeof ledger.query !== "function") return READ_DENIED;
495
+ if (!isRecord(identity) || typeof identity.clientId !== "string" || !identity.clientId) {
496
+ return READ_DENIED;
497
+ }
498
+ if (!isRecord(tenantGrants)) return READ_DENIED;
499
+ const granted = grantIds(new Map(Object.entries(tenantGrants)).get(identity.clientId));
500
+ if (!granted.length) return READ_DENIED;
501
+ const hint = typeof tenant === "string" && tenant.trim() ? tenant.trim() : null;
502
+ if (hint && !granted.includes(hint)) return READ_DENIED;
503
+ if (!hint && granted.length > 1) return READ_DENIED;
504
+ const resolved = hint ?? granted[0];
505
+ const key = typeof principalKey === "string" && principalKey.trim() ? principalKey.trim() : null;
506
+ return { ok: true, tenant: resolved, records: ledger.query({ tenant: resolved, principalKey: key }) };
507
+ }
508
+
509
+ /**
510
+ * Reconcile a set of records into request / decision / receipt chains.
511
+ *
512
+ * - `settled`: one allow decision, one receipt, matching tenant and
513
+ * decision id, outcome known.
514
+ * - `denied`: a deny decision (never dispatched; no receipt expected).
515
+ * - `missing`: a dispatch with no receipt, or a receipt with no dispatch.
516
+ * - `duplicate`: more than one decision or more than one receipt for a
517
+ * request id.
518
+ * - `uncertain`: the receipt outcome is `unknown`, or the receipt disagrees
519
+ * with its decision (tenant or decision id).
520
+ *
521
+ * `truncated` is true when the ledger dropped rows; findings may then be
522
+ * incomplete and must not be read as a clean bill.
523
+ *
524
+ * @param {object[]} records
525
+ * @param {{dropped?: number}} [options]
526
+ * @returns {{findings: object[], summary: object}}
527
+ */
528
+ export function reconcileUsage(records, { dropped = 0 } = {}) {
529
+ const list = Array.isArray(records) ? records : [];
530
+ const order = [];
531
+ const groups = new Map();
532
+
533
+ for (const row of list) {
534
+ if (!isRecord(row) || !PHASE_SET.has(row.phase)) continue;
535
+ if (row.phase === "decision" && row.decision === "deny") {
536
+ const key = `deny:${row.decisionId ?? order.length}`;
537
+ order.push(key);
538
+ groups.set(key, { deny: row });
539
+ continue;
540
+ }
541
+ const requestId = typeof row.requestId === "string" && row.requestId ? row.requestId : null;
542
+ const key = requestId
543
+ ? `req:${requestId}`
544
+ : `orphan:${row.decisionId ?? row.receiptId ?? order.length}`;
545
+ if (!groups.has(key)) {
546
+ order.push(key);
547
+ groups.set(key, { requestId, decisions: [], receipts: [] });
548
+ }
549
+ const group = groups.get(key);
550
+ if (row.phase === "decision") group.decisions.push(row);
551
+ else group.receipts.push(row);
552
+ }
553
+
554
+ const findings = [];
555
+ const summary = {
556
+ total: 0, settled: 0, denied: 0, missing: 0, duplicate: 0, uncertain: 0, truncated: dropped > 0,
557
+ };
558
+ const tally = new Map(Object.entries(summary));
559
+
560
+ for (const key of order) {
561
+ const group = groups.get(key);
562
+ let finding;
563
+ if (group.deny) {
564
+ finding = {
565
+ requestId: group.deny.requestId ?? null,
566
+ decisionId: group.deny.decisionId ?? null,
567
+ state: "denied",
568
+ reason: group.deny.reason ?? null,
569
+ };
570
+ } else {
571
+ const decisionId = group.decisions[0]?.decisionId ?? group.receipts[0]?.decisionId ?? null;
572
+ let state;
573
+ let reason;
574
+ if (group.decisions.length > 1) {
575
+ state = "duplicate";
576
+ reason = "duplicate_decision";
577
+ } else if (group.receipts.length > 1) {
578
+ state = "duplicate";
579
+ reason = "duplicate_receipt";
580
+ } else if (group.decisions.length === 1 && group.receipts.length === 0) {
581
+ state = "missing";
582
+ reason = "receipt_missing";
583
+ } else if (group.decisions.length === 0) {
584
+ state = "missing";
585
+ reason = "decision_missing";
586
+ } else {
587
+ const decision = group.decisions[0];
588
+ const receipt = group.receipts[0];
589
+ if (receipt.outcome === "unknown") {
590
+ state = "uncertain";
591
+ reason = typeof receipt.reason === "string" && receipt.reason
592
+ ? receipt.reason
593
+ : "outcome_unknown";
594
+ } else if (receipt.tenant !== decision.tenant || receipt.decisionId !== decision.decisionId) {
595
+ state = "uncertain";
596
+ reason = "chain_mismatch";
597
+ } else {
598
+ state = "settled";
599
+ reason = null;
600
+ }
601
+ }
602
+ finding = { requestId: group.requestId, decisionId, state, reason };
603
+ }
604
+ findings.push(Object.freeze(finding));
605
+ tally.set(finding.state, tally.get(finding.state) + 1);
606
+ tally.set("total", tally.get("total") + 1);
607
+ }
608
+
609
+ return { findings, summary: Object.fromEntries(tally) };
610
+ }
package/src/tools/bulk.js CHANGED
@@ -119,13 +119,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
119
119
  assertPublishAllowed(sec, attributes);
120
120
  const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
121
121
  const patchTarget = await prepareGuardedPatch(backend, {
122
- entityType, bundle, id: item.id, existing, attributes,
122
+ entityType, bundle, id: item.id, existing, attributes, relationships: resolvedRelationships,
123
123
  });
124
124
  const entity = await updateEntityGuarded(backend, {
125
125
  entityType, bundle, id: item.id,
126
126
  attributes,
127
127
  relationships: resolvedRelationships,
128
128
  ...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
129
+ ...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
129
130
  });
130
131
  updated += 1;
131
132
  results.push({ index, success: true, id: entity?.id ?? item.id });
@@ -118,7 +118,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
118
118
  assertPublishAllowed(sec, safeAttributes);
119
119
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
120
120
  const patchTarget = await prepareGuardedPatch(backend, {
121
- entityType, bundle, id, existing, attributes: safeAttributes,
121
+ entityType, bundle, id, existing, attributes: safeAttributes, relationships: resolvedRelationships,
122
122
  });
123
123
  if (dryRun) {
124
124
  return {
@@ -129,6 +129,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
129
129
  const result = await updateEntityGuarded(backend, {
130
130
  entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
131
131
  ...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
132
+ ...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
132
133
  });
133
134
  const written = await readWrittenRevision({
134
135
  backend, entityType, bundle, id,
@@ -280,7 +281,7 @@ export const definitions = [
280
281
  },
281
282
  {
282
283
  name: "drupal_entity_update",
283
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets an id-mismatch PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
284
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
284
285
  inputSchema: {
285
286
  type: "object", required: ["entityType", "bundle", "id"],
286
287
  properties: {
@@ -290,7 +291,7 @@ export const definitions = [
290
291
  id: { type: "string" },
291
292
  attributes: { type: "object" },
292
293
  relationships: { type: "object" },
293
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun." },
294
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
294
295
  returning: RETURNING_SCHEMA,
295
296
  },
296
297
  },