drupal-mcp-connector 2.12.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 +26 -0
- package/bin/drupal-mcp-edge.js +55 -2
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/src/lib/config.js +24 -0
- package/src/lib/relay/edge.js +311 -28
- package/src/lib/usage.js +611 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.13.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Attributable usage, quotas, and abuse signals on the relay edge (#256).**
|
|
14
|
+
Optional usage ledger (`MCP_EDGE_USAGE_MAX_RECORDS` / `relay.usage`)
|
|
15
|
+
records every edge decision (allow or deny) and every fan-down receipt
|
|
16
|
+
against the grant-resolved tenant and the validated principal, carrying
|
|
17
|
+
the frame's `requestId`, a `decisionId`, the bound `policyDigest`, and
|
|
18
|
+
measured cost signals (units, bytes, duration). Optional `auth.quotas`
|
|
19
|
+
(tenant / principal request windows plus an abuse lock) fails closed at
|
|
20
|
+
the edge: an unlisted tenant or principal is `not_entitled`, an exhausted
|
|
21
|
+
window is `429 quota_exceeded` with `Retry-After`, and a principal that
|
|
22
|
+
keeps earning refusals is `429 abuse_locked` — all with zero frames on any
|
|
23
|
+
tunnel. A shared tenant window running out never feeds an individual
|
|
24
|
+
principal's lock; a quota table the edge cannot read refuses startup and
|
|
25
|
+
names the offending path. A response the listener cannot relay is
|
|
26
|
+
`502 fan_down_failed` with a `failed` receipt, and a metering failure
|
|
27
|
+
never changes a verdict. `GET /usage` serves the caller's own tenant
|
|
28
|
+
partition (tenant from `auth.tenantGrants`; any other tenant is
|
|
29
|
+
`not_entitled` with no records) with a reconciliation naming `missing`,
|
|
30
|
+
`duplicate`, and `uncertain` chains; unmatched response frames are
|
|
31
|
+
recorded against the sending tunnel. Omitting both keeps the 2.12.0 path.
|
|
32
|
+
Lab/loopback only — measured usage, not pricing, billing, a hosted
|
|
33
|
+
metering sink, or a hosted-service claim.
|
|
34
|
+
|
|
10
35
|
## [2.12.0] - 2026-09-02
|
|
11
36
|
|
|
12
37
|
### Security
|
|
@@ -1393,6 +1418,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1393
1418
|
- User tools gained explicit PII-access assertions.
|
|
1394
1419
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1395
1420
|
|
|
1421
|
+
[2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
|
|
1396
1422
|
[2.12.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.12.0
|
|
1397
1423
|
[2.11.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.11.0
|
|
1398
1424
|
[2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
|
package/bin/drupal-mcp-edge.js
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* MCP_RATE_LIMIT / MCP_RATE_WINDOW_SEC
|
|
33
33
|
* Northbound /mcp rate limit (same defaults as the
|
|
34
34
|
* primary entry point).
|
|
35
|
+
* MCP_EDGE_USAGE_MAX_RECORDS
|
|
36
|
+
* Positive integer enables the in-process usage ledger
|
|
37
|
+
* (or config relay.usage.maxRecords). Every decision and
|
|
38
|
+
* receipt is recorded per tenant and principal, and
|
|
39
|
+
* GET /usage serves the caller's own tenant partition.
|
|
40
|
+
* Unset: nothing is recorded and /usage is 404. Set but
|
|
41
|
+
* unreadable: fatal.
|
|
35
42
|
*
|
|
36
43
|
* Config: auth.grants (client id -> [site names]) is mandatory; the edge
|
|
37
44
|
* refuses to start without it. Optional auth.tenantGrants (client id ->
|
|
@@ -41,14 +48,27 @@
|
|
|
41
48
|
* (sub / azp -> SHA-256 digest) is the expected signed policy on the edge.
|
|
42
49
|
* Optional auth.promotions (digest -> sealed document + two operator ids)
|
|
43
50
|
* is the W&L-operated dual-control ledger; the edge fans eligible bundles
|
|
44
|
-
* to the tenant agent and requires a matching local attestation.
|
|
51
|
+
* to the tenant agent and requires a matching local attestation. Optional
|
|
52
|
+
* auth.quotas (tenant / principal request windows plus an abuse lock)
|
|
53
|
+
* fails closed at the edge with zero frames on any refusal; a table the
|
|
54
|
+
* edge cannot read refuses startup.
|
|
45
55
|
*/
|
|
46
56
|
|
|
47
57
|
import { readFileSync } from "node:fs";
|
|
48
58
|
import process from "node:process";
|
|
49
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
getInboundActors,
|
|
61
|
+
getInboundGrants,
|
|
62
|
+
getInboundPolicies,
|
|
63
|
+
getInboundPromotions,
|
|
64
|
+
getInboundQuotas,
|
|
65
|
+
getInboundTenantGrants,
|
|
66
|
+
getTlsConfig,
|
|
67
|
+
loadConfig,
|
|
68
|
+
} from "../src/lib/config.js";
|
|
50
69
|
import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
|
|
51
70
|
import { createRateLimiter } from "../src/lib/rate-limit.js";
|
|
71
|
+
import { createUsageLedger } from "../src/lib/usage.js";
|
|
52
72
|
import {
|
|
53
73
|
createChannelCredentialStore,
|
|
54
74
|
startEdge,
|
|
@@ -126,6 +146,29 @@ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
|
|
|
126
146
|
? rateLimitDefault
|
|
127
147
|
: Number(rateLimitEnv);
|
|
128
148
|
|
|
149
|
+
// Usage ledger (#256): opt-in, in-process, bounded. A restart clears it.
|
|
150
|
+
// A value that is set but unreadable is fatal: the operator asked for
|
|
151
|
+
// metering, and silently running without it would be a lie.
|
|
152
|
+
const usageEnv = process.env.MCP_EDGE_USAGE_MAX_RECORDS;
|
|
153
|
+
const usageRaw = usageEnv !== undefined && usageEnv !== ""
|
|
154
|
+
? usageEnv
|
|
155
|
+
: config.relay?.usage?.maxRecords;
|
|
156
|
+
let usageMaxRecords = 0;
|
|
157
|
+
if (usageRaw !== undefined && usageRaw !== null) {
|
|
158
|
+
const parsed = typeof usageRaw === "string" && /^[0-9]+$/.test(usageRaw.trim())
|
|
159
|
+
? Number(usageRaw.trim())
|
|
160
|
+
: usageRaw;
|
|
161
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
162
|
+
fatal(
|
|
163
|
+
"MCP_EDGE_USAGE_MAX_RECORDS (or relay.usage.maxRecords) must be a positive "
|
|
164
|
+
+ `integer; got ${JSON.stringify(usageRaw)}. Unset it to run without the usage ledger.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
usageMaxRecords = parsed;
|
|
168
|
+
}
|
|
169
|
+
const usage = usageMaxRecords > 0 ? createUsageLedger({ maxRecords: usageMaxRecords }) : null;
|
|
170
|
+
const quotas = getInboundQuotas();
|
|
171
|
+
|
|
129
172
|
let edge;
|
|
130
173
|
try {
|
|
131
174
|
edge = await startEdge({
|
|
@@ -135,6 +178,8 @@ try {
|
|
|
135
178
|
actors: getInboundActors(),
|
|
136
179
|
policies: getInboundPolicies(),
|
|
137
180
|
promotions: getInboundPromotions(),
|
|
181
|
+
quotas,
|
|
182
|
+
usage,
|
|
138
183
|
sites,
|
|
139
184
|
defaultSite: config.defaultSite,
|
|
140
185
|
channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
|
|
@@ -162,3 +207,11 @@ if (rateLimit > 0) {
|
|
|
162
207
|
`[drupal-mcp-edge] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`,
|
|
163
208
|
);
|
|
164
209
|
}
|
|
210
|
+
if (usage) {
|
|
211
|
+
console.error(
|
|
212
|
+
`[drupal-mcp-edge] Usage ledger: in-process, ${usageMaxRecords} records max; GET /usage serves one tenant partition.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (quotas) {
|
|
216
|
+
console.error("[drupal-mcp-edge] Quotas: auth.quotas in force; unlisted tenants / principals are refused.");
|
|
217
|
+
}
|
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
"promotions": {
|
|
45
45
|
"_comment": "Optional W&L-operated dual-control ledger. Map a SHA-256 digest to { document, approvals } where document is the sealed portable Sentinel bundle (claims + digest + hmac-sha256 seal) and approvals is two distinct operator ids. When present, the edge fans eligible documents to the tenant agent; non-diagnostic tools/call require a matching local attestation. The edge never mints a seal. Omit to keep the digest-only path. Tenant self-service is not this table."
|
|
46
46
|
},
|
|
47
|
+
"quotas": {
|
|
48
|
+
"_comment": "Optional, drupal-mcp-edge only. { tenants: { \"<agent id>\": { requests, windowSec } }, principals: { \"<sub or client_id>\": { requests, windowSec } }, abuse: { denials, windowSec, lockSec } }. When a tenants or principals table names any id, an id without a row is not_entitled; an exhausted window is 429 quota_exceeded with Retry-After; a principal that earns `denials` refusals inside windowSec is 429 abuse_locked for lockSec. Every refusal is zero frames on any tunnel. A shared tenant window running out never feeds an individual principal's abuse lock. A table the edge cannot read refuses startup and names the offending path. Omit to keep the prior path (no quota at the edge). Counting is measured usage, not pricing."
|
|
49
|
+
},
|
|
47
50
|
"revocationFile": "",
|
|
48
51
|
"introspectionUrl": "",
|
|
49
52
|
"introspectionClientIdEnv": "",
|
package/package.json
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -308,6 +308,30 @@ export function getInboundPromotions() {
|
|
|
308
308
|
return entries.length ? Object.fromEntries(entries) : null;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Tenant / principal quota table plus abuse lock (`auth.quotas`).
|
|
313
|
+
* When present, the relay edge fails closed: a tenant or principal without
|
|
314
|
+
* a row is refused, exhausted windows and locked principals are refused,
|
|
315
|
+
* and a table the edge cannot read refuses startup. Validation lives in
|
|
316
|
+
* usage.js (`normalizeQuotas`).
|
|
317
|
+
* @returns {object|null|unknown} Null when omitted or comment-only; the
|
|
318
|
+
* comment-stripped table when it is an object; otherwise the configured
|
|
319
|
+
* value unchanged (a string, array, number, ...) so `startEdge()` refuses
|
|
320
|
+
* to start on it instead of running unmetered.
|
|
321
|
+
*/
|
|
322
|
+
export function getInboundQuotas() {
|
|
323
|
+
const quotas = loadConfig().auth?.quotas;
|
|
324
|
+
if (quotas === undefined || quotas === null) return null;
|
|
325
|
+
// A present value that is not an object is passed through unchanged so
|
|
326
|
+
// the edge refuses to start on it (usage.js normalizeQuotas names it);
|
|
327
|
+
// returning null here would silently run without quotas.
|
|
328
|
+
if (typeof quotas !== "object" || Array.isArray(quotas)) return quotas;
|
|
329
|
+
const entries = Object.entries(quotas)
|
|
330
|
+
.map(([key, value]) => [key.trim(), value])
|
|
331
|
+
.filter(([key]) => key && !key.startsWith("_"));
|
|
332
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
311
335
|
// ---------------------------------------------------------------------------
|
|
312
336
|
// Auth headers — never logged, never exposed in tool responses
|
|
313
337
|
// ---------------------------------------------------------------------------
|
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232, #242, #244, #247, #250, #253) — DEV-294
|
|
3
|
-
* DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping,
|
|
4
|
-
* policy digest and W&L-operated bundle promotion
|
|
2
|
+
* Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — DEV-294
|
|
3
|
+
* AC4, DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping,
|
|
4
|
+
* DEV-125 policy digest and W&L-operated bundle promotion, DEV-126
|
|
5
|
+
* attributable usage, quotas, and abuse signals.
|
|
5
6
|
*
|
|
6
7
|
* Terminates northbound MCP over the OAuth resource server and fans requests
|
|
7
8
|
* down outbound tenant-agent channels. The edge proposes; the tenant-side
|
|
@@ -27,6 +28,11 @@
|
|
|
27
28
|
* no `Mcp-Session-Id` crosses in either direction.
|
|
28
29
|
* - Revocation is per-request with no grace window, for both credential
|
|
29
30
|
* kinds (northbound principal, agent channel).
|
|
31
|
+
* - Metering is optional and fail-closed. With a `usage` ledger every
|
|
32
|
+
* decision and receipt is recorded against the grant-resolved tenant and
|
|
33
|
+
* the validated principal; with `auth.quotas` a tenant or principal
|
|
34
|
+
* without a row, an exhausted window, or a locked principal is refused
|
|
35
|
+
* with zero frames. `GET /usage` serves one tenant partition only.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -36,7 +42,11 @@ import { createServer as createHttpsServer } from "node:https";
|
|
|
36
42
|
import { createServer as createNetServer } from "node:net";
|
|
37
43
|
import { createServer as createTlsServer } from "node:tls";
|
|
38
44
|
import { createLocalRelay } from "../contracts/relay.js";
|
|
39
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
createInboundHttpsAuth,
|
|
47
|
+
formatWwwAuthenticate,
|
|
48
|
+
SPOOFABLE_IDENTITY_HEADERS,
|
|
49
|
+
} from "../http-auth.js";
|
|
40
50
|
import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
|
|
41
51
|
import { isWriteLikeCall } from "../operations.js";
|
|
42
52
|
import {
|
|
@@ -46,6 +56,13 @@ import {
|
|
|
46
56
|
resolveEligiblePromotion,
|
|
47
57
|
} from "../policy-promotion.js";
|
|
48
58
|
import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
|
|
59
|
+
import {
|
|
60
|
+
attributedTenant,
|
|
61
|
+
createQuotaGate,
|
|
62
|
+
readUsage,
|
|
63
|
+
reconcileUsage,
|
|
64
|
+
usagePrincipalKey,
|
|
65
|
+
} from "../usage.js";
|
|
49
66
|
import {
|
|
50
67
|
attachFramer,
|
|
51
68
|
createRequestBroker,
|
|
@@ -93,6 +110,16 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
|
|
|
93
110
|
|
|
94
111
|
const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
|
|
95
112
|
|
|
113
|
+
/** Post-authentication refusals that count toward a principal's abuse lock. */
|
|
114
|
+
const ABUSE_SIGNAL_ERRORS = new Set(["not_entitled", "quota_exceeded"]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Refusal scopes that describe something other than the caller's own
|
|
118
|
+
* behaviour — a shared tenant window, or a table the operator misconfigured.
|
|
119
|
+
* They never feed a principal's abuse lock.
|
|
120
|
+
*/
|
|
121
|
+
const SHARED_SCOPES = new Set(["tenant", "config"]);
|
|
122
|
+
|
|
96
123
|
/**
|
|
97
124
|
* Normalize a channel-record `sites` list. Empty / missing means unscoped
|
|
98
125
|
* (legal only as the sole connected agent — the DEV-294 compatibility path).
|
|
@@ -458,11 +485,16 @@ function assertBindAllowed(role, host, hasTls) {
|
|
|
458
485
|
}
|
|
459
486
|
}
|
|
460
487
|
|
|
461
|
-
function jsonResponse(res, status, body) {
|
|
462
|
-
res.writeHead(status, { "content-type": "application/json" })
|
|
488
|
+
function jsonResponse(res, status, body, headers = {}) {
|
|
489
|
+
res.writeHead(status, { ...headers, "content-type": "application/json" })
|
|
463
490
|
.end(JSON.stringify(body));
|
|
464
491
|
}
|
|
465
492
|
|
|
493
|
+
function byteLength(value) {
|
|
494
|
+
if (value === null || value === undefined) return 0;
|
|
495
|
+
return Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
|
|
496
|
+
}
|
|
497
|
+
|
|
466
498
|
/**
|
|
467
499
|
* Start the relay edge: an authenticated northbound MCP listener and the
|
|
468
500
|
* agent channel listener the tenant dials into.
|
|
@@ -489,6 +521,14 @@ function jsonResponse(res, status, body) {
|
|
|
489
521
|
* @param {number} [options.agentPort] Agent channel port (0 = ephemeral).
|
|
490
522
|
* @param {{cert: string|Buffer, key: string|Buffer}} [options.tls]
|
|
491
523
|
* @param {boolean} [options.allowHttpLoopback] Permit plain listeners on loopback.
|
|
524
|
+
* @param {object|null} [options.quotas] Optional `auth.quotas` (tenant /
|
|
525
|
+
* principal windows plus an abuse lock). When present, a tenant or
|
|
526
|
+
* principal without a row is refused; exhausted windows and locked
|
|
527
|
+
* principals are refused with `Retry-After`. Zero frames on any refusal.
|
|
528
|
+
* @param {?object} [options.usage] Optional usage ledger (usage.js). When
|
|
529
|
+
* present, every decision and receipt is recorded and `GET /usage` serves
|
|
530
|
+
* the caller's tenant partition. Omit to record nothing (404 on /usage).
|
|
531
|
+
* @param {() => number} [options.now] Clock for quotas and cost signals.
|
|
492
532
|
* @param {?object} [options.rateLimiter] Optional rate limiter (rate-limit.js).
|
|
493
533
|
* @param {number} [options.fanDownTimeoutMs]
|
|
494
534
|
* @param {typeof fetch} [options.fetchFn] Issuer discovery/JWKS fetch.
|
|
@@ -511,6 +551,9 @@ export async function startEdge({
|
|
|
511
551
|
agentPort = 0,
|
|
512
552
|
tls = null,
|
|
513
553
|
allowHttpLoopback = false,
|
|
554
|
+
quotas = null,
|
|
555
|
+
usage = null,
|
|
556
|
+
now = () => Date.now(),
|
|
514
557
|
rateLimiter = null,
|
|
515
558
|
fanDownTimeoutMs = DEFAULT_FAN_DOWN_TIMEOUT_MS,
|
|
516
559
|
fetchFn = fetch,
|
|
@@ -541,6 +584,39 @@ export async function startEdge({
|
|
|
541
584
|
? promotions
|
|
542
585
|
: null;
|
|
543
586
|
const promoRequired = promotionsRequired(promotionTable);
|
|
587
|
+
const quotaGate = createQuotaGate({ quotas, now });
|
|
588
|
+
if (quotaGate.invalid) {
|
|
589
|
+
throw new EdgeStartupError(
|
|
590
|
+
`Relay edge refuses to start: auth.quotas is not readable at "${quotaGate.reason}". `
|
|
591
|
+
+ "A quota table that cannot be read authorizes nobody; fix the row or remove the table.",
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
const usageLedger = usage === null || usage === undefined ? null : usage;
|
|
595
|
+
if (usageLedger !== null && (
|
|
596
|
+
typeof usageLedger !== "object"
|
|
597
|
+
|| typeof usageLedger.record !== "function"
|
|
598
|
+
|| typeof usageLedger.query !== "function"
|
|
599
|
+
|| typeof usageLedger.stats !== "function"
|
|
600
|
+
)) {
|
|
601
|
+
throw new EdgeStartupError(
|
|
602
|
+
"Relay edge usage ledger must expose record(), query(), and stats() "
|
|
603
|
+
+ "(see createUsageLedger in usage.js), or be omitted.",
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Record without ever changing the verdict: a metering failure is logged
|
|
609
|
+
* (without the error detail) and the request proceeds as decided.
|
|
610
|
+
*/
|
|
611
|
+
function meter(entry) {
|
|
612
|
+
if (!usageLedger) return null;
|
|
613
|
+
try {
|
|
614
|
+
return usageLedger.record(entry) ?? null;
|
|
615
|
+
} catch {
|
|
616
|
+
console.error("[drupal-mcp-edge] usage record failed; the decision stands and is unmetered.");
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
544
620
|
if (typeof channelCredentials?.lookup !== "function") {
|
|
545
621
|
throw new EdgeStartupError(
|
|
546
622
|
"Relay edge requires an agent channel credential store; without one no "
|
|
@@ -637,7 +713,24 @@ export async function startEdge({
|
|
|
637
713
|
return;
|
|
638
714
|
}
|
|
639
715
|
if (frame.type === "mcp-response") {
|
|
640
|
-
broker.settle(frame, { owner: agentId });
|
|
716
|
+
const settled = broker.settle(frame, { owner: agentId });
|
|
717
|
+
if (!settled && usageLedger) {
|
|
718
|
+
// A response nobody is waiting for: late, repeated, fabricated, or
|
|
719
|
+
// injected from another tenant's tunnel. Recorded against the
|
|
720
|
+
// sending tunnel so reconciliation and abuse signals can see it.
|
|
721
|
+
meter({
|
|
722
|
+
phase: "receipt",
|
|
723
|
+
requestId: typeof frame.id === "string" && frame.id ? frame.id : null,
|
|
724
|
+
decisionId: null,
|
|
725
|
+
tenant: agentId,
|
|
726
|
+
principalKey: null,
|
|
727
|
+
outcome: "unknown",
|
|
728
|
+
reason: "unmatched_receipt",
|
|
729
|
+
status: Number.isInteger(frame.status) ? frame.status : null,
|
|
730
|
+
bytesOut: byteLength(frame.body),
|
|
731
|
+
durationMs: null,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
641
734
|
return;
|
|
642
735
|
}
|
|
643
736
|
// Any other frame type on the agent channel is a protocol violation.
|
|
@@ -661,11 +754,57 @@ export async function startEdge({
|
|
|
661
754
|
return;
|
|
662
755
|
}
|
|
663
756
|
|
|
757
|
+
// Attribution context (#256): who this is, which tenant the grant names,
|
|
758
|
+
// and what it cost. Stamped on every decision, allow or deny. Caller
|
|
759
|
+
// fields never reach it — the identity object and grant tables do.
|
|
760
|
+
const startedAt = now();
|
|
761
|
+
const isCall = body?.method === "tools/call";
|
|
762
|
+
const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
|
|
763
|
+
? body.params.name.trim()
|
|
764
|
+
: null;
|
|
765
|
+
const principalKey = usagePrincipalKey(identity);
|
|
766
|
+
const principal = Object.freeze({
|
|
767
|
+
clientId: identity.clientId ?? null,
|
|
768
|
+
sub: identity.sub ?? null,
|
|
769
|
+
});
|
|
770
|
+
const bytesIn = byteLength(body);
|
|
771
|
+
let usageTenant = attributedTenant(identity, tenantGrantTable);
|
|
772
|
+
let policyDigest = null;
|
|
773
|
+
|
|
774
|
+
function refuse(status, error, {
|
|
775
|
+
scope = null, exposeScope = true, retryAfterSec = 0, extra = {},
|
|
776
|
+
} = {}) {
|
|
777
|
+
if (ABUSE_SIGNAL_ERRORS.has(error) && !SHARED_SCOPES.has(scope)) {
|
|
778
|
+
quotaGate.noteDenial(principalKey);
|
|
779
|
+
}
|
|
780
|
+
meter({
|
|
781
|
+
phase: "decision",
|
|
782
|
+
decision: "deny",
|
|
783
|
+
reason: error,
|
|
784
|
+
scope,
|
|
785
|
+
requestId: null,
|
|
786
|
+
tenant: usageTenant,
|
|
787
|
+
principal,
|
|
788
|
+
principalKey,
|
|
789
|
+
method: body?.method ?? null,
|
|
790
|
+
tool: toolName,
|
|
791
|
+
policyDigest,
|
|
792
|
+
units: 1,
|
|
793
|
+
bytesIn,
|
|
794
|
+
});
|
|
795
|
+
jsonResponse(
|
|
796
|
+
res,
|
|
797
|
+
status,
|
|
798
|
+
{ error, ...(scope && exposeScope ? { scope } : {}), ...extra },
|
|
799
|
+
retryAfterSec > 0 ? { "Retry-After": String(retryAfterSec) } : {},
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
664
803
|
// Entitlement at the seam, before anything about the tenant is revealed:
|
|
665
804
|
// an unlisted client learns nothing, not even whether an agent exists.
|
|
666
805
|
const granted = resolveGrantedSites(identity, catalog, grantTable);
|
|
667
806
|
if (!granted.length) {
|
|
668
|
-
|
|
807
|
+
refuse(403, "not_entitled");
|
|
669
808
|
return;
|
|
670
809
|
}
|
|
671
810
|
const args = body?.params?.arguments ?? {};
|
|
@@ -673,34 +812,31 @@ export async function startEdge({
|
|
|
673
812
|
const siteArgs = { ...args };
|
|
674
813
|
delete siteArgs.tenant;
|
|
675
814
|
let targetName = null;
|
|
676
|
-
if (
|
|
815
|
+
if (isCall) {
|
|
677
816
|
try {
|
|
678
817
|
targetName = targetRelay.resolve(identity, siteArgs).name;
|
|
679
818
|
} catch {
|
|
680
|
-
|
|
819
|
+
refuse(403, "not_entitled");
|
|
681
820
|
return;
|
|
682
821
|
}
|
|
683
822
|
}
|
|
684
823
|
|
|
685
824
|
const mapped = resolveActor({ identity, actors: actorTable });
|
|
686
825
|
const boundPolicy = resolvePolicy({ identity, policies: policyTable });
|
|
687
|
-
|
|
688
|
-
const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
|
|
689
|
-
? body.params.name.trim()
|
|
690
|
-
: null;
|
|
826
|
+
policyDigest = boundPolicy.policy ?? null;
|
|
691
827
|
if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
|
|
692
|
-
|
|
828
|
+
refuse(403, "not_entitled");
|
|
693
829
|
return;
|
|
694
830
|
}
|
|
695
831
|
const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
|
|
696
832
|
if (
|
|
697
833
|
boundPolicy.required && boundPolicy.reason && policyCall
|
|
698
834
|
) {
|
|
699
|
-
|
|
835
|
+
refuse(403, "not_entitled");
|
|
700
836
|
return;
|
|
701
837
|
}
|
|
702
838
|
if (promoRequired && policyCall && !boundPolicy.policy) {
|
|
703
|
-
|
|
839
|
+
refuse(403, "not_entitled");
|
|
704
840
|
return;
|
|
705
841
|
}
|
|
706
842
|
|
|
@@ -712,6 +848,30 @@ export async function startEdge({
|
|
|
712
848
|
targetName,
|
|
713
849
|
sessions: [...sessions.values()],
|
|
714
850
|
});
|
|
851
|
+
if (selected.tenant) usageTenant = selected.tenant;
|
|
852
|
+
if (!selected.session && selected.reason === "not_entitled") {
|
|
853
|
+
refuse(403, "not_entitled");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!selected.session && !selected.tenant) {
|
|
857
|
+
// Site-derived path with no agent: there is no tenant to meter against,
|
|
858
|
+
// so this is an outage answer, not a quota or abuse signal.
|
|
859
|
+
refuse(503, "no_agent");
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
// Quota boundary (#256): the tenant is now grant-resolved. Every request
|
|
863
|
+
// reaching this line counts; a tenant or principal without a row, an
|
|
864
|
+
// exhausted window, or a locked principal is refused with zero frames.
|
|
865
|
+
const verdict = quotaGate.check({ tenant: selected.tenant, principalKey });
|
|
866
|
+
if (!verdict.allowed) {
|
|
867
|
+
const unassigned = verdict.reason === "not_entitled";
|
|
868
|
+
refuse(unassigned ? 403 : 429, verdict.reason, {
|
|
869
|
+
scope: verdict.scope,
|
|
870
|
+
exposeScope: !unassigned,
|
|
871
|
+
retryAfterSec: verdict.retryAfterSec,
|
|
872
|
+
});
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
715
875
|
if (promoRequired && policyCall) {
|
|
716
876
|
const promo = resolveEligiblePromotion({
|
|
717
877
|
digest: boundPolicy.policy,
|
|
@@ -719,25 +879,22 @@ export async function startEdge({
|
|
|
719
879
|
});
|
|
720
880
|
const attested = selected.session?.attestedDigests;
|
|
721
881
|
if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
|
|
722
|
-
|
|
882
|
+
refuse(403, "not_entitled");
|
|
723
883
|
return;
|
|
724
884
|
}
|
|
725
885
|
}
|
|
726
886
|
if (!selected.session) {
|
|
727
|
-
|
|
728
|
-
jsonResponse(res, entitled ? 403 : 503, {
|
|
729
|
-
error: entitled ? "not_entitled" : "no_agent",
|
|
730
|
-
});
|
|
887
|
+
refuse(503, "no_agent");
|
|
731
888
|
return;
|
|
732
889
|
}
|
|
733
890
|
const record = channelCredentials.lookup(selected.session.token);
|
|
734
891
|
if (!record || record.revoked) {
|
|
735
|
-
|
|
892
|
+
refuse(403, "revoked", { extra: { bound: EDGE_REVOCATION_BOUND.name } });
|
|
736
893
|
return;
|
|
737
894
|
}
|
|
738
895
|
if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
|
|
739
896
|
selected.session.socket.destroy();
|
|
740
|
-
|
|
897
|
+
refuse(503, "no_agent");
|
|
741
898
|
return;
|
|
742
899
|
}
|
|
743
900
|
|
|
@@ -769,14 +926,55 @@ export async function startEdge({
|
|
|
769
926
|
});
|
|
770
927
|
if (!wrote) {
|
|
771
928
|
broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
|
|
772
|
-
|
|
929
|
+
refuse(503, "no_agent");
|
|
773
930
|
return;
|
|
774
931
|
}
|
|
932
|
+
const decisionRecord = meter({
|
|
933
|
+
phase: "decision",
|
|
934
|
+
decision: "allow",
|
|
935
|
+
reason: null,
|
|
936
|
+
requestId: id,
|
|
937
|
+
tenant: selected.tenant,
|
|
938
|
+
principal,
|
|
939
|
+
principalKey,
|
|
940
|
+
method: body?.method ?? null,
|
|
941
|
+
tool: toolName,
|
|
942
|
+
target: selected.target ?? null,
|
|
943
|
+
actor: mapped.actor ?? null,
|
|
944
|
+
policyDigest,
|
|
945
|
+
units: 1,
|
|
946
|
+
bytesIn,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
function receipt(fields) {
|
|
950
|
+
meter({
|
|
951
|
+
phase: "receipt",
|
|
952
|
+
requestId: id,
|
|
953
|
+
decisionId: decisionRecord?.decisionId ?? null,
|
|
954
|
+
tenant: selected.tenant,
|
|
955
|
+
principalKey,
|
|
956
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
957
|
+
...fields,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
775
960
|
|
|
776
961
|
let result;
|
|
777
962
|
try {
|
|
778
963
|
result = await waited;
|
|
779
964
|
} catch {
|
|
965
|
+
// The frame crossed; no settled response came back. The tenant may
|
|
966
|
+
// have executed it — reconciliation names this chain uncertain.
|
|
967
|
+
receipt({ outcome: "unknown", reason: "fan_down_failed", status: null, bytesOut: 0 });
|
|
968
|
+
jsonResponse(res, 502, { error: "fan_down_failed" });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
// The agent frame is unvalidated input. A status the northbound
|
|
972
|
+
// listener cannot emit, or a header it refuses, is a failed receipt and
|
|
973
|
+
// a 502 — never an "ok" receipt for a response nobody received.
|
|
974
|
+
const status = result.status;
|
|
975
|
+
const bytesOut = byteLength(result.body);
|
|
976
|
+
if (!Number.isInteger(status) || status < 100 || status > 999) {
|
|
977
|
+
receipt({ outcome: "failed", reason: "invalid_status", status: null, bytesOut });
|
|
780
978
|
jsonResponse(res, 502, { error: "fan_down_failed" });
|
|
781
979
|
return;
|
|
782
980
|
}
|
|
@@ -784,8 +982,86 @@ export async function startEdge({
|
|
|
784
982
|
Object.entries(forwardHeaders(result.headers ?? {}))
|
|
785
983
|
.filter(([name]) => String(name).toLowerCase() !== "mcp-session-id"),
|
|
786
984
|
);
|
|
787
|
-
|
|
985
|
+
try {
|
|
986
|
+
res.writeHead(status, headers);
|
|
987
|
+
} catch {
|
|
988
|
+
receipt({ outcome: "failed", reason: "relay_write_failed", status, bytesOut });
|
|
989
|
+
if (!res.headersSent) {
|
|
990
|
+
for (const name of res.getHeaderNames()) res.removeHeader(name);
|
|
991
|
+
jsonResponse(res, 502, { error: "fan_down_failed" });
|
|
992
|
+
} else {
|
|
993
|
+
res.destroy();
|
|
994
|
+
}
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
788
997
|
res.end(result.body ?? "");
|
|
998
|
+
receipt({ outcome: status >= 500 ? "failed" : "ok", reason: null, status, bytesOut });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* `GET /usage` — the caller's own tenant partition (#256). Authenticated
|
|
1003
|
+
* on the same resource server as `/mcp`; the tenant comes from
|
|
1004
|
+
* `auth.tenantGrants`, a `tenant` query value is a confirming hint, and
|
|
1005
|
+
* any other tenant is `not_entitled` with no records. 404 without a ledger.
|
|
1006
|
+
*/
|
|
1007
|
+
async function serveUsage(req, res) {
|
|
1008
|
+
if (!usageLedger) {
|
|
1009
|
+
res.writeHead(404).end("Not found");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (req.method !== "GET") {
|
|
1013
|
+
res.writeHead(405, { Allow: "GET" }).end("Method Not Allowed");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (rateLimiter) {
|
|
1017
|
+
const verdict = rateLimiter.check(req.socket?.remoteAddress || "unknown");
|
|
1018
|
+
if (!verdict.allowed) {
|
|
1019
|
+
res.writeHead(429, { "Retry-After": String(verdict.retryAfterSec) }).end("Too Many Requests");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
let auth;
|
|
1024
|
+
try {
|
|
1025
|
+
auth = await inbound.authenticate(req);
|
|
1026
|
+
} catch {
|
|
1027
|
+
res.writeHead(401, {
|
|
1028
|
+
"WWW-Authenticate": formatWwwAuthenticate({
|
|
1029
|
+
error: "invalid_token",
|
|
1030
|
+
errorDescription: "Token validation failed",
|
|
1031
|
+
}),
|
|
1032
|
+
}).end("Unauthorized");
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (!auth.ok) {
|
|
1036
|
+
res.writeHead(auth.status, auth.headers).end(auth.body);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
const query = new URL(String(req.url || "/usage"), "http://edge.invalid").searchParams;
|
|
1041
|
+
const read = readUsage({
|
|
1042
|
+
identity: auth.identity,
|
|
1043
|
+
tenantGrants: tenantGrantTable,
|
|
1044
|
+
tenant: query.get("tenant"),
|
|
1045
|
+
principalKey: query.get("principal"),
|
|
1046
|
+
ledger: usageLedger,
|
|
1047
|
+
});
|
|
1048
|
+
if (!read.ok) {
|
|
1049
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
jsonResponse(res, 200, {
|
|
1053
|
+
tenant: read.tenant,
|
|
1054
|
+
records: read.records,
|
|
1055
|
+
reconciliation: reconcileUsage(read.records, { dropped: usageLedger.stats().dropped }),
|
|
1056
|
+
});
|
|
1057
|
+
} catch {
|
|
1058
|
+
console.error("[drupal-mcp-edge] usage read failed.");
|
|
1059
|
+
if (res.headersSent) {
|
|
1060
|
+
res.destroy();
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
res.writeHead(500).end("Internal Server Error");
|
|
1064
|
+
}
|
|
789
1065
|
}
|
|
790
1066
|
|
|
791
1067
|
const requestHandler = createMcpRequestHandler({
|
|
@@ -804,9 +1080,16 @@ export async function startEdge({
|
|
|
804
1080
|
rateLimiter,
|
|
805
1081
|
});
|
|
806
1082
|
|
|
1083
|
+
function northbound(req, res) {
|
|
1084
|
+
if (String(req.url || "").split("?")[0] === "/usage") {
|
|
1085
|
+
void serveUsage(req, res);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
void requestHandler(req, res);
|
|
1089
|
+
}
|
|
807
1090
|
const northServer = hasTls
|
|
808
|
-
? createHttpsServer(tls,
|
|
809
|
-
: createHttpServer(
|
|
1091
|
+
? createHttpsServer(tls, northbound)
|
|
1092
|
+
: createHttpServer(northbound);
|
|
810
1093
|
|
|
811
1094
|
const channelAddr = await listen(channelServer, agentBindHost, agentPort, "edge-agent-channel");
|
|
812
1095
|
const northAddr = await listen(northServer, bindHost, port, "edge-northbound");
|
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
|
+
}
|