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