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 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
@@ -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 { getInboundActors, getInboundGrants, getInboundPolicies, getInboundPromotions, getInboundTenantGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
59
+ import {
60
+ getInboundActors,
61
+ getInboundGrants,
62
+ getInboundPolicies,
63
+ getInboundPromotions,
64
+ getInboundQuotas,
65
+ getInboundTenantGrants,
66
+ getTlsConfig,
67
+ loadConfig,
68
+ } from "../src/lib/config.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/lib/config.js CHANGED
@@ -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
  // ---------------------------------------------------------------------------
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Relay northbound edge (#232, #242, #244, #247, #250, #253) — DEV-294 AC4,
3
- * DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125
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 { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
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
- jsonResponse(res, 403, { error: "not_entitled" });
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 (body?.method === "tools/call") {
815
+ if (isCall) {
677
816
  try {
678
817
  targetName = targetRelay.resolve(identity, siteArgs).name;
679
818
  } catch {
680
- jsonResponse(res, 403, { error: "not_entitled" });
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
- const isCall = body?.method === "tools/call";
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
- jsonResponse(res, 403, { error: "not_entitled" });
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
- jsonResponse(res, 403, { error: "not_entitled" });
835
+ refuse(403, "not_entitled");
700
836
  return;
701
837
  }
702
838
  if (promoRequired && policyCall && !boundPolicy.policy) {
703
- jsonResponse(res, 403, { error: "not_entitled" });
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
- jsonResponse(res, 403, { error: "not_entitled" });
882
+ refuse(403, "not_entitled");
723
883
  return;
724
884
  }
725
885
  }
726
886
  if (!selected.session) {
727
- const entitled = selected.reason === "not_entitled";
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
- jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
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
- jsonResponse(res, 503, { error: "no_agent" });
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
- jsonResponse(res, 503, { error: "no_agent" });
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
- res.writeHead(result.status || 200, headers);
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, (req, res) => { void requestHandler(req, res); })
809
- : createHttpServer((req, res) => { void requestHandler(req, res); });
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");
@@ -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
+ }