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.
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +3 -3
- package/CHANGELOG.md +52 -12
- package/bin/drupal-mcp-edge.js +55 -2
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +1 -1
- package/src/lib/backends/jsonapi.js +2 -2
- package/src/lib/config.js +24 -0
- package/src/lib/draft-write.js +57 -0
- package/src/lib/patch-preflight.js +18 -7
- package/src/lib/policy-enforcement.js +1 -1
- package/src/lib/policy-promotion.js +1 -1
- package/src/lib/relay/agent.js +1 -1
- package/src/lib/relay/edge.js +315 -32
- package/src/lib/relay/frames.js +1 -1
- package/src/lib/usage.js +610 -0
- package/src/tools/bulk.js +2 -1
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +10 -10
package/src/lib/usage.js
ADDED
|
@@ -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 });
|
package/src/tools/entities.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
},
|