paygate-mcp 2.7.0 → 2.9.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.
Files changed (46) hide show
  1. package/README.md +132 -0
  2. package/dist/gate.d.ts +29 -9
  3. package/dist/gate.d.ts.map +1 -1
  4. package/dist/gate.js +275 -14
  5. package/dist/gate.js.map +1 -1
  6. package/dist/http-proxy.d.ts +6 -1
  7. package/dist/http-proxy.d.ts.map +1 -1
  8. package/dist/http-proxy.js +59 -0
  9. package/dist/http-proxy.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/meter.d.ts +2 -2
  14. package/dist/meter.d.ts.map +1 -1
  15. package/dist/meter.js +11 -6
  16. package/dist/meter.js.map +1 -1
  17. package/dist/proxy.d.ts +6 -1
  18. package/dist/proxy.d.ts.map +1 -1
  19. package/dist/proxy.js +88 -2
  20. package/dist/proxy.js.map +1 -1
  21. package/dist/quota.d.ts +9 -0
  22. package/dist/quota.d.ts.map +1 -1
  23. package/dist/quota.js +49 -0
  24. package/dist/quota.js.map +1 -1
  25. package/dist/rate-limiter.d.ts +5 -0
  26. package/dist/rate-limiter.d.ts.map +1 -1
  27. package/dist/rate-limiter.js +12 -0
  28. package/dist/rate-limiter.js.map +1 -1
  29. package/dist/redis-sync.d.ts.map +1 -1
  30. package/dist/redis-sync.js +2 -0
  31. package/dist/redis-sync.js.map +1 -1
  32. package/dist/router.d.ts +6 -1
  33. package/dist/router.d.ts.map +1 -1
  34. package/dist/router.js +87 -0
  35. package/dist/router.js.map +1 -1
  36. package/dist/server.d.ts +4 -2
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +92 -7
  39. package/dist/server.js.map +1 -1
  40. package/dist/store.d.ts +20 -2
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js +43 -2
  43. package/dist/store.js.map +1 -1
  44. package/dist/types.d.ts +22 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -57,6 +57,8 @@ Agent → PayGate (auth + billing) → Your MCP Server (stdio or HTTP)
57
57
  - **Webhook Retry Queue** — Exponential backoff retry (1s, 2s, 4s...) with dead letter queue for permanently failed deliveries, admin API for monitoring and clearing
58
58
  - **Health Check + Graceful Shutdown** — `GET /health` public endpoint with status, uptime, version, in-flight requests, Redis & webhook stats; `gracefulStop()` drains in-flight requests before teardown
59
59
  - **Config Validation + Dry Run** — `paygate-mcp validate --config paygate.json` catches misconfigurations before starting; `--dry-run` discovers tools, prints pricing table, then exits
60
+ - **Batch Tool Calls** — `tools/call_batch` method for calling multiple tools in one request with all-or-nothing billing, aggregate credit checks, and parallel execution
61
+ - **Multi-Tenant Namespaces** — Isolate API keys and usage data by tenant with namespace-filtered admin endpoints, analytics, and usage export
60
62
  - **Refund on Failure** — Automatically refund credits when downstream tool calls fail
61
63
  - **Webhook Events** — POST batched usage events to any URL for external billing/alerting
62
64
  - **Config File Mode** — Load all settings from a JSON file (`--config`)
@@ -303,6 +305,7 @@ A real-time admin UI for managing keys, viewing usage, and monitoring tool calls
303
305
  | `/teams/assign` | POST | `X-Admin-Key` | Assign an API key to a team |
304
306
  | `/teams/remove` | POST | `X-Admin-Key` | Remove an API key from a team |
305
307
  | `/teams/usage` | GET | `X-Admin-Key` | Team usage summary with member breakdown |
308
+ | `/namespaces` | GET | `X-Admin-Key` | List all namespaces with key/credit/spending stats |
306
309
  | `/audit` | GET | `X-Admin-Key` | Query audit log (filter by type, actor, time) |
307
310
  | `/audit/export` | GET | `X-Admin-Key` | Export full audit log (JSON or CSV) |
308
311
  | `/audit/stats` | GET | `X-Admin-Key` | Audit log statistics |
@@ -314,6 +317,8 @@ A real-time admin UI for managing keys, viewing usage, and monitoring tool calls
314
317
  These MCP methods pass through without auth or billing:
315
318
  `initialize`, `initialized`, `ping`, `tools/list`, `resources/list`, `prompts/list`
316
319
 
320
+ **Gated methods:** `tools/call` (single), `tools/call_batch` (batch — all-or-nothing billing, parallel execution). See [Batch Tool Calls](#batch-tool-calls).
321
+
317
322
  ## CLI Options
318
323
 
319
324
  ```
@@ -1038,6 +1043,131 @@ if (diags.some(d => d.level === 'error')) {
1038
1043
  }
1039
1044
  ```
1040
1045
 
1046
+ ### Batch Tool Calls
1047
+
1048
+ Call multiple tools in a single request with all-or-nothing billing:
1049
+
1050
+ ```json
1051
+ {
1052
+ "jsonrpc": "2.0",
1053
+ "id": 1,
1054
+ "method": "tools/call_batch",
1055
+ "params": {
1056
+ "calls": [
1057
+ { "name": "search", "arguments": { "q": "MCP servers" } },
1058
+ { "name": "translate", "arguments": { "text": "hello", "to": "es" } },
1059
+ { "name": "summarize", "arguments": { "url": "https://example.com" } }
1060
+ ]
1061
+ }
1062
+ }
1063
+ ```
1064
+
1065
+ **Response:**
1066
+ ```json
1067
+ {
1068
+ "jsonrpc": "2.0",
1069
+ "id": 1,
1070
+ "result": {
1071
+ "results": [
1072
+ { "tool": "search", "result": { "content": [...] }, "creditsCharged": 5 },
1073
+ { "tool": "translate", "result": { "content": [...] }, "creditsCharged": 3 },
1074
+ { "tool": "summarize", "result": { "content": [...] }, "creditsCharged": 2 }
1075
+ ],
1076
+ "totalCreditsCharged": 10,
1077
+ "remainingCredits": 90
1078
+ }
1079
+ }
1080
+ ```
1081
+
1082
+ **Key features:**
1083
+ - **All-or-nothing** — All calls are pre-validated (auth, ACL, rate limits, credits, quotas) before any execute. If any call would be denied, the entire batch is rejected and zero credits are charged.
1084
+ - **Aggregate pricing** — Total credits are checked and deducted atomically. A batch of 3 calls needing 5+3+2=10 credits requires 10 credits available.
1085
+ - **Parallel execution** — After gate approval, all tool calls execute concurrently for minimum latency.
1086
+ - **Refund on failure** — With `refundOnFailure` enabled, individual tools that error downstream get their credits refunded.
1087
+ - **Multi-server support** — Works with prefixed tools in multi-server mode (e.g., `fs:read`, `github:search`).
1088
+
1089
+ **Programmatic API:**
1090
+
1091
+ ```typescript
1092
+ import { Gate, BatchToolCall } from 'paygate-mcp';
1093
+
1094
+ const calls: BatchToolCall[] = [
1095
+ { name: 'search', arguments: { q: 'test' } },
1096
+ { name: 'translate', arguments: { text: 'hi' } },
1097
+ ];
1098
+
1099
+ const result = gate.evaluateBatch(apiKey, calls, clientIp);
1100
+ if (!result.allAllowed) {
1101
+ console.log(`Denied at index ${result.failedIndex}: ${result.reason}`);
1102
+ } else {
1103
+ console.log(`Charged ${result.totalCredits} credits for ${calls.length} calls`);
1104
+ }
1105
+ ```
1106
+
1107
+ ### Multi-Tenant Namespaces
1108
+
1109
+ Isolate API keys and usage data by tenant. Each key belongs to a `namespace` (default: `"default"`). All admin endpoints support namespace filtering for tenant-scoped views.
1110
+
1111
+ **Create a key in a namespace:**
1112
+
1113
+ ```bash
1114
+ curl -X POST http://localhost:3402/keys \
1115
+ -H "X-Admin-Key: YOUR_ADMIN_KEY" \
1116
+ -H "Content-Type: application/json" \
1117
+ -d '{"name": "acme-agent", "credits": 1000, "namespace": "acme-corp"}'
1118
+ ```
1119
+
1120
+ **List keys filtered by namespace:**
1121
+
1122
+ ```bash
1123
+ curl http://localhost:3402/keys?namespace=acme-corp \
1124
+ -H "X-Admin-Key: YOUR_ADMIN_KEY"
1125
+ ```
1126
+
1127
+ **List all namespaces with stats:**
1128
+
1129
+ ```bash
1130
+ curl http://localhost:3402/namespaces \
1131
+ -H "X-Admin-Key: YOUR_ADMIN_KEY"
1132
+ ```
1133
+
1134
+ Returns:
1135
+
1136
+ ```json
1137
+ {
1138
+ "namespaces": [
1139
+ { "namespace": "acme-corp", "keyCount": 3, "activeKeys": 2, "totalCredits": 2500, "totalSpent": 480 },
1140
+ { "namespace": "beta-inc", "keyCount": 1, "activeKeys": 1, "totalCredits": 500, "totalSpent": 120 }
1141
+ ],
1142
+ "count": 2
1143
+ }
1144
+ ```
1145
+
1146
+ **Namespace-filtered status, usage, and analytics:**
1147
+
1148
+ ```bash
1149
+ # Status filtered to one namespace
1150
+ curl http://localhost:3402/status?namespace=acme-corp -H "X-Admin-Key: ..."
1151
+
1152
+ # Usage events filtered by namespace
1153
+ curl http://localhost:3402/usage?namespace=acme-corp -H "X-Admin-Key: ..."
1154
+
1155
+ # Analytics filtered by namespace
1156
+ curl "http://localhost:3402/analytics?namespace=acme-corp&from=2025-01-01" -H "X-Admin-Key: ..."
1157
+
1158
+ # Search keys by tag within a namespace
1159
+ curl -X POST http://localhost:3402/keys/search \
1160
+ -H "X-Admin-Key: ..." -H "Content-Type: application/json" \
1161
+ -d '{"tags": {"env": "prod"}, "namespace": "acme-corp"}'
1162
+ ```
1163
+
1164
+ Namespace rules:
1165
+ - Alphanumeric + hyphens only, max 50 characters, case-insensitive (stored lowercase)
1166
+ - Defaults to `"default"` if omitted or invalid
1167
+ - Old keys automatically backfilled to `"default"` on state file load
1168
+ - Usage events carry the key's namespace for cross-cutting analytics
1169
+ - Namespaces are implicit — created automatically when a key is assigned to one
1170
+
1041
1171
  ### Horizontal Scaling (Redis)
1042
1172
 
1043
1173
  Enable Redis-backed state for multi-process deployments. Multiple PayGate instances share API keys, credits, and usage data through Redis:
@@ -1279,6 +1409,8 @@ const result = await client.callTool('search', { query: 'hello' });
1279
1409
  - [x] Alert webhooks — Configurable threshold alerts (spending, credits, quota, expiry, rate limits)
1280
1410
  - [x] Team management — Group API keys with shared budgets, quotas, and usage tracking
1281
1411
  - [x] Horizontal scaling — Redis-backed state for multi-process deployments
1412
+ - [x] Batch tool calls — `tools/call_batch` with all-or-nothing billing and parallel execution
1413
+ - [x] Multi-tenant namespaces — Isolate API keys and usage data by tenant with namespace-filtered endpoints
1282
1414
 
1283
1415
  ## Requirements
1284
1416
 
package/dist/gate.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * Fail-closed: any check failure => DENY.
11
11
  * Shadow mode: log but don't enforce (always ALLOW).
12
12
  */
13
- import { PayGateConfig, GateDecision, UsageEvent, ToolCallParams, ApiKeyRecord } from './types';
13
+ import { PayGateConfig, GateDecision, UsageEvent, ToolCallParams, ApiKeyRecord, BatchToolCall, BatchGateResult } from './types';
14
14
  import { KeyStore } from './store';
15
15
  import { RateLimiter } from './rate-limiter';
16
16
  import { UsageMeter } from './meter';
@@ -39,6 +39,18 @@ export declare class Gate {
39
39
  * Evaluate a tool call request.
40
40
  */
41
41
  evaluate(apiKey: string | null, toolCall: ToolCallParams, clientIp?: string): GateDecision;
42
+ /**
43
+ * Evaluate a batch of tool calls atomically (all-or-nothing).
44
+ *
45
+ * Pre-validates all calls (auth, ACL, rate limits, credits, quotas, spending limits)
46
+ * before executing any. If any call would be denied, the entire batch is rejected
47
+ * and no credits are deducted.
48
+ *
49
+ * On success, deducts credits for all calls at once.
50
+ */
51
+ evaluateBatch(apiKey: string | null, calls: BatchToolCall[], clientIp?: string): BatchGateResult;
52
+ /** Build a shadow-mode batch result (all allowed, zero charges). */
53
+ private shadowBatchResult;
42
54
  /**
43
55
  * Check if a tool call is allowed by the key's ACL.
44
56
  */
@@ -66,7 +78,15 @@ export declare class Gate {
66
78
  /**
67
79
  * Get full status for dashboard.
68
80
  */
69
- getStatus(): {
81
+ getStatus(namespace?: string): {
82
+ config: {
83
+ defaultCreditsPerCall: number;
84
+ globalRateLimitPerMin: number;
85
+ toolPricing: Record<string, import("./types").ToolPricing>;
86
+ refundOnFailure: boolean;
87
+ webhookUrl: string | null;
88
+ };
89
+ filteredNamespace?: string | undefined;
70
90
  name: string;
71
91
  shadowMode: boolean;
72
92
  activeKeys: number;
@@ -76,13 +96,13 @@ export declare class Gate {
76
96
  })[];
77
97
  usage: import("./types").UsageSummary;
78
98
  eventCount: number;
79
- config: {
80
- defaultCreditsPerCall: number;
81
- globalRateLimitPerMin: number;
82
- toolPricing: Record<string, import("./types").ToolPricing>;
83
- refundOnFailure: boolean;
84
- webhookUrl: string | null;
85
- };
99
+ namespaces: {
100
+ namespace: string;
101
+ keyCount: number;
102
+ activeKeys: number;
103
+ totalCredits: number;
104
+ totalSpent: number;
105
+ }[];
86
106
  };
87
107
  /**
88
108
  * Refund credits for a failed tool call.
@@ -1 +1 @@
1
- {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,YAAY,EAAe,MAAM,SAAS,CAAC;AAC7G,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC,qBAAa,IAAI;IACf,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IACxC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,mEAAmE;IACnE,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzF,uDAAuD;IACvD,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,iFAAiF;IACjF,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IAC3C,wEAAwE;IACxE,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;gBAEjD,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,MAAM;IAYrD;;OAEG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,YAAY;IAoJ1F;;OAEG;IACH,OAAO,CAAC,YAAY;IAgBpB;;;OAGG;IACH,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC,GAAG,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC,GAAG,IAAI;IAmBpJ;;OAEG;IACH,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAIrC;;;OAGG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM;IAetE;;OAEG;IACH,SAAS;;;;;;;;;;;;;;;;;;IAkBT;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAa/D,2CAA2C;IAC3C,IAAI,eAAe,IAAI,OAAO,CAE7B;IAED,OAAO,IAAI,IAAI;IAKf,OAAO,CAAC,WAAW;CAiBpB"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,YAAY,EAAe,aAAa,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC7I,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC,qBAAa,IAAI;IACf,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IACxC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,mEAAmE;IACnE,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzF,uDAAuD;IACvD,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,iFAAiF;IACjF,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IAC3C,wEAAwE;IACxE,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;gBAEjD,MAAM,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,MAAM;IAYrD;;OAEG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,YAAY;IAoJ1F;;;;;;;;OAQG;IACH,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,eAAe;IAkQhG,oEAAoE;IACpE,OAAO,CAAC,iBAAiB;IAUzB;;OAEG;IACH,OAAO,CAAC,YAAY;IAgBpB;;;OAGG;IACH,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC,GAAG,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC,GAAG,IAAI;IAmBpJ;;OAEG;IACH,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAIrC;;;OAGG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM;IAetE;;OAEG;IACH,SAAS,CAAC,SAAS,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;IAoB5B;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAa/D,2CAA2C;IAC3C,IAAI,eAAe,IAAI,OAAO,CAE7B;IAED,OAAO,IAAI,IAAI;IAKf,OAAO,CAAC,WAAW;CAmBpB"}
package/dist/gate.js CHANGED
@@ -74,7 +74,7 @@ class Gate {
74
74
  if (clientIp && keyRecord.ipAllowlist.length > 0) {
75
75
  if (!this.store.checkIp(apiKey, clientIp)) {
76
76
  const reason = `ip_not_allowed: ${clientIp} not in allowlist`;
77
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, reason);
77
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, reason, keyRecord.namespace);
78
78
  if (this.config.shadowMode) {
79
79
  return { allowed: true, reason: `shadow:${reason}`, creditsCharged: 0, remainingCredits: keyRecord.credits };
80
80
  }
@@ -84,7 +84,7 @@ class Gate {
84
84
  // Step 3: Tool ACL check
85
85
  const aclResult = this.checkToolAcl(keyRecord, toolName);
86
86
  if (!aclResult.allowed) {
87
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, aclResult.reason);
87
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, aclResult.reason, keyRecord.namespace);
88
88
  if (this.config.shadowMode) {
89
89
  return { allowed: true, reason: `shadow:${aclResult.reason}`, creditsCharged: 0, remainingCredits: keyRecord.credits };
90
90
  }
@@ -93,7 +93,7 @@ class Gate {
93
93
  // Step 4: Global rate limit?
94
94
  const rateResult = this.rateLimiter.check(apiKey);
95
95
  if (!rateResult.allowed) {
96
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, rateResult.reason);
96
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, rateResult.reason, keyRecord.namespace);
97
97
  if (this.config.shadowMode) {
98
98
  return { allowed: true, reason: `shadow:${rateResult.reason}`, creditsCharged: 0, remainingCredits: keyRecord.credits };
99
99
  }
@@ -106,7 +106,7 @@ class Gate {
106
106
  const toolRateResult = this.rateLimiter.checkCustom(compositeKey, toolPricing.rateLimitPerMin);
107
107
  if (!toolRateResult.allowed) {
108
108
  const reason = `tool_rate_limited: ${toolName} limited to ${toolPricing.rateLimitPerMin} calls/min`;
109
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, reason);
109
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, reason, keyRecord.namespace);
110
110
  if (this.config.shadowMode) {
111
111
  return { allowed: true, reason: `shadow:${reason}`, creditsCharged: 0, remainingCredits: keyRecord.credits };
112
112
  }
@@ -115,7 +115,7 @@ class Gate {
115
115
  }
116
116
  // Step 6: Sufficient credits?
117
117
  if (!this.store.hasCredits(apiKey, creditsRequired)) {
118
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, 'insufficient_credits');
118
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, 'insufficient_credits', keyRecord.namespace);
119
119
  if (this.config.shadowMode) {
120
120
  return { allowed: true, reason: 'shadow:insufficient_credits', creditsCharged: 0, remainingCredits: keyRecord.credits };
121
121
  }
@@ -130,7 +130,7 @@ class Gate {
130
130
  if (keyRecord.spendingLimit > 0) {
131
131
  const wouldSpend = keyRecord.totalSpent + creditsRequired;
132
132
  if (wouldSpend > keyRecord.spendingLimit) {
133
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, 'spending_limit_exceeded');
133
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, 'spending_limit_exceeded', keyRecord.namespace);
134
134
  if (this.config.shadowMode) {
135
135
  return { allowed: true, reason: 'shadow:spending_limit_exceeded', creditsCharged: 0, remainingCredits: keyRecord.credits };
136
136
  }
@@ -145,7 +145,7 @@ class Gate {
145
145
  // Step 8: Usage quota check (daily/monthly limits)
146
146
  const quotaResult = this.quotaTracker.check(keyRecord, creditsRequired, this.config.globalQuota);
147
147
  if (!quotaResult.allowed) {
148
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, quotaResult.reason);
148
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, quotaResult.reason, keyRecord.namespace);
149
149
  if (this.config.shadowMode) {
150
150
  return { allowed: true, reason: `shadow:${quotaResult.reason}`, creditsCharged: 0, remainingCredits: keyRecord.credits };
151
151
  }
@@ -155,7 +155,7 @@ class Gate {
155
155
  if (this.teamChecker) {
156
156
  const teamResult = this.teamChecker(apiKey, creditsRequired);
157
157
  if (!teamResult.allowed) {
158
- this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, teamResult.reason);
158
+ this.recordEvent(apiKey, keyRecord.name, toolName, 0, false, teamResult.reason, keyRecord.namespace);
159
159
  if (this.config.shadowMode) {
160
160
  return { allowed: true, reason: `shadow:${teamResult.reason}`, creditsCharged: 0, remainingCredits: keyRecord.credits };
161
161
  }
@@ -178,9 +178,267 @@ class Gate {
178
178
  }
179
179
  this.store.save();
180
180
  const remaining = this.store.getKey(apiKey)?.credits ?? 0;
181
- this.recordEvent(apiKey, keyRecord.name, toolName, creditsRequired, true);
181
+ this.recordEvent(apiKey, keyRecord.name, toolName, creditsRequired, true, undefined, keyRecord.namespace);
182
182
  return { allowed: true, creditsCharged: creditsRequired, remainingCredits: remaining };
183
183
  }
184
+ /**
185
+ * Evaluate a batch of tool calls atomically (all-or-nothing).
186
+ *
187
+ * Pre-validates all calls (auth, ACL, rate limits, credits, quotas, spending limits)
188
+ * before executing any. If any call would be denied, the entire batch is rejected
189
+ * and no credits are deducted.
190
+ *
191
+ * On success, deducts credits for all calls at once.
192
+ */
193
+ evaluateBatch(apiKey, calls, clientIp) {
194
+ if (calls.length === 0) {
195
+ return { allAllowed: true, totalCredits: 0, decisions: [], remainingCredits: 0, failedIndex: -1 };
196
+ }
197
+ // Step 1: API key present?
198
+ if (!apiKey) {
199
+ if (this.config.shadowMode) {
200
+ return this.shadowBatchResult(calls, 'shadow:missing_api_key');
201
+ }
202
+ return {
203
+ allAllowed: false,
204
+ totalCredits: 0,
205
+ decisions: calls.map(() => ({ allowed: false, reason: 'missing_api_key', creditsCharged: 0, remainingCredits: 0 })),
206
+ remainingCredits: 0,
207
+ reason: 'missing_api_key',
208
+ failedIndex: 0,
209
+ };
210
+ }
211
+ // Step 2: Valid key?
212
+ const keyRecord = this.store.getKey(apiKey);
213
+ if (!keyRecord) {
214
+ const isExpired = this.store.isExpired(apiKey);
215
+ const reason = isExpired ? 'api_key_expired' : 'invalid_api_key';
216
+ if (this.config.shadowMode) {
217
+ return this.shadowBatchResult(calls, `shadow:${reason}`);
218
+ }
219
+ return {
220
+ allAllowed: false,
221
+ totalCredits: 0,
222
+ decisions: calls.map(() => ({ allowed: false, reason, creditsCharged: 0, remainingCredits: 0 })),
223
+ remainingCredits: 0,
224
+ reason,
225
+ failedIndex: 0,
226
+ };
227
+ }
228
+ // Step 3: IP allowlist check
229
+ if (clientIp && keyRecord.ipAllowlist.length > 0) {
230
+ if (!this.store.checkIp(apiKey, clientIp)) {
231
+ const reason = `ip_not_allowed: ${clientIp} not in allowlist`;
232
+ if (this.config.shadowMode) {
233
+ return this.shadowBatchResult(calls, `shadow:${reason}`);
234
+ }
235
+ return {
236
+ allAllowed: false,
237
+ totalCredits: 0,
238
+ decisions: calls.map(() => ({ allowed: false, reason, creditsCharged: 0, remainingCredits: keyRecord.credits })),
239
+ remainingCredits: keyRecord.credits,
240
+ reason,
241
+ failedIndex: 0,
242
+ };
243
+ }
244
+ }
245
+ // Step 4: Per-call pre-validation (ACL, per-tool rate limits) + aggregate credits
246
+ let totalCreditsNeeded = 0;
247
+ const perCallCredits = [];
248
+ // Track per-tool occurrence counts within the batch for rate limit checking
249
+ const batchToolCounts = new Map();
250
+ for (let i = 0; i < calls.length; i++) {
251
+ const call = calls[i];
252
+ // ACL check
253
+ const aclResult = this.checkToolAcl(keyRecord, call.name);
254
+ if (!aclResult.allowed) {
255
+ if (this.config.shadowMode) {
256
+ perCallCredits.push(this.getToolPrice(call.name, call.arguments));
257
+ continue;
258
+ }
259
+ return {
260
+ allAllowed: false,
261
+ totalCredits: 0,
262
+ decisions: calls.map((_, j) => ({
263
+ allowed: false,
264
+ reason: j === i ? aclResult.reason : 'batch_rejected',
265
+ creditsCharged: 0,
266
+ remainingCredits: keyRecord.credits,
267
+ })),
268
+ remainingCredits: keyRecord.credits,
269
+ reason: aclResult.reason,
270
+ failedIndex: i,
271
+ };
272
+ }
273
+ // Per-tool rate limit check (batch-aware: count occurrences in batch)
274
+ const toolPricing = this.config.toolPricing[call.name];
275
+ if (toolPricing?.rateLimitPerMin && toolPricing.rateLimitPerMin > 0) {
276
+ const compositeKey = `${apiKey}:tool:${call.name}`;
277
+ const batchCount = (batchToolCounts.get(call.name) || 0) + 1;
278
+ batchToolCounts.set(call.name, batchCount);
279
+ // Check existing window usage + batch occurrences
280
+ const existingCount = this.rateLimiter.getCurrentCount(compositeKey);
281
+ if (existingCount + batchCount > toolPricing.rateLimitPerMin) {
282
+ const reason = `tool_rate_limited: ${call.name} limited to ${toolPricing.rateLimitPerMin} calls/min`;
283
+ if (this.config.shadowMode) {
284
+ perCallCredits.push(this.getToolPrice(call.name, call.arguments));
285
+ continue;
286
+ }
287
+ return {
288
+ allAllowed: false,
289
+ totalCredits: 0,
290
+ decisions: calls.map((_, j) => ({
291
+ allowed: false,
292
+ reason: j === i ? reason : 'batch_rejected',
293
+ creditsCharged: 0,
294
+ remainingCredits: keyRecord.credits,
295
+ })),
296
+ remainingCredits: keyRecord.credits,
297
+ reason,
298
+ failedIndex: i,
299
+ };
300
+ }
301
+ }
302
+ const price = this.getToolPrice(call.name, call.arguments);
303
+ perCallCredits.push(price);
304
+ totalCreditsNeeded += price;
305
+ }
306
+ // Step 5: Global rate limit — check once for the batch
307
+ const rateResult = this.rateLimiter.check(apiKey);
308
+ if (!rateResult.allowed) {
309
+ if (!this.config.shadowMode) {
310
+ return {
311
+ allAllowed: false,
312
+ totalCredits: 0,
313
+ decisions: calls.map(() => ({ allowed: false, reason: rateResult.reason, creditsCharged: 0, remainingCredits: keyRecord.credits })),
314
+ remainingCredits: keyRecord.credits,
315
+ reason: rateResult.reason,
316
+ failedIndex: 0,
317
+ };
318
+ }
319
+ }
320
+ // Step 6: Aggregate credit check
321
+ if (!this.store.hasCredits(apiKey, totalCreditsNeeded)) {
322
+ if (!this.config.shadowMode) {
323
+ return {
324
+ allAllowed: false,
325
+ totalCredits: 0,
326
+ decisions: calls.map(() => ({
327
+ allowed: false,
328
+ reason: `insufficient_credits: need ${totalCreditsNeeded}, have ${keyRecord.credits}`,
329
+ creditsCharged: 0,
330
+ remainingCredits: keyRecord.credits,
331
+ })),
332
+ remainingCredits: keyRecord.credits,
333
+ reason: `insufficient_credits: need ${totalCreditsNeeded}, have ${keyRecord.credits}`,
334
+ failedIndex: 0,
335
+ };
336
+ }
337
+ }
338
+ // Step 7: Spending limit check (aggregate)
339
+ if (keyRecord.spendingLimit > 0) {
340
+ const wouldSpend = keyRecord.totalSpent + totalCreditsNeeded;
341
+ if (wouldSpend > keyRecord.spendingLimit) {
342
+ if (!this.config.shadowMode) {
343
+ return {
344
+ allAllowed: false,
345
+ totalCredits: 0,
346
+ decisions: calls.map(() => ({
347
+ allowed: false,
348
+ reason: `spending_limit_exceeded: limit ${keyRecord.spendingLimit}, spent ${keyRecord.totalSpent}, need ${totalCreditsNeeded}`,
349
+ creditsCharged: 0,
350
+ remainingCredits: keyRecord.credits,
351
+ })),
352
+ remainingCredits: keyRecord.credits,
353
+ reason: `spending_limit_exceeded: limit ${keyRecord.spendingLimit}, spent ${keyRecord.totalSpent}, need ${totalCreditsNeeded}`,
354
+ failedIndex: 0,
355
+ };
356
+ }
357
+ }
358
+ }
359
+ // Step 8: Quota check (aggregate, batch-aware)
360
+ const quotaResult = this.quotaTracker.checkBatch(keyRecord, calls.length, totalCreditsNeeded, this.config.globalQuota);
361
+ if (!quotaResult.allowed) {
362
+ if (!this.config.shadowMode) {
363
+ return {
364
+ allAllowed: false,
365
+ totalCredits: 0,
366
+ decisions: calls.map(() => ({
367
+ allowed: false,
368
+ reason: quotaResult.reason,
369
+ creditsCharged: 0,
370
+ remainingCredits: keyRecord.credits,
371
+ })),
372
+ remainingCredits: keyRecord.credits,
373
+ reason: quotaResult.reason,
374
+ failedIndex: 0,
375
+ };
376
+ }
377
+ }
378
+ // Step 9: Team budget check (aggregate)
379
+ if (this.teamChecker) {
380
+ const teamResult = this.teamChecker(apiKey, totalCreditsNeeded);
381
+ if (!teamResult.allowed) {
382
+ if (!this.config.shadowMode) {
383
+ return {
384
+ allAllowed: false,
385
+ totalCredits: 0,
386
+ decisions: calls.map(() => ({
387
+ allowed: false,
388
+ reason: teamResult.reason,
389
+ creditsCharged: 0,
390
+ remainingCredits: keyRecord.credits,
391
+ })),
392
+ remainingCredits: keyRecord.credits,
393
+ reason: teamResult.reason,
394
+ failedIndex: 0,
395
+ };
396
+ }
397
+ }
398
+ }
399
+ // Step 10: ALL ALLOWED — deduct credits atomically
400
+ this.store.deductCredits(apiKey, totalCreditsNeeded);
401
+ this.onCreditsDeducted?.(apiKey, totalCreditsNeeded);
402
+ // Record rate limits and quota for each call
403
+ this.rateLimiter.record(apiKey);
404
+ for (const call of calls) {
405
+ const toolPricing = this.config.toolPricing[call.name];
406
+ if (toolPricing?.rateLimitPerMin && toolPricing.rateLimitPerMin > 0) {
407
+ this.rateLimiter.recordCustom(`${apiKey}:tool:${call.name}`);
408
+ }
409
+ }
410
+ this.quotaTracker.recordBatch(keyRecord, calls.length, totalCreditsNeeded);
411
+ if (this.teamRecorder) {
412
+ this.teamRecorder(apiKey, totalCreditsNeeded);
413
+ }
414
+ this.store.save();
415
+ const remaining = this.store.getKey(apiKey)?.credits ?? 0;
416
+ // Record usage events for each call
417
+ for (let i = 0; i < calls.length; i++) {
418
+ this.recordEvent(apiKey, keyRecord.name, calls[i].name, perCallCredits[i], true, undefined, keyRecord.namespace);
419
+ }
420
+ return {
421
+ allAllowed: true,
422
+ totalCredits: totalCreditsNeeded,
423
+ decisions: calls.map((_, i) => ({
424
+ allowed: true,
425
+ creditsCharged: perCallCredits[i],
426
+ remainingCredits: remaining,
427
+ })),
428
+ remainingCredits: remaining,
429
+ failedIndex: -1,
430
+ };
431
+ }
432
+ /** Build a shadow-mode batch result (all allowed, zero charges). */
433
+ shadowBatchResult(calls, reason) {
434
+ return {
435
+ allAllowed: true,
436
+ totalCredits: 0,
437
+ decisions: calls.map(() => ({ allowed: true, reason, creditsCharged: 0, remainingCredits: 0 })),
438
+ remainingCredits: 0,
439
+ failedIndex: -1,
440
+ };
441
+ }
184
442
  /**
185
443
  * Check if a tool call is allowed by the key's ACL.
186
444
  */
@@ -248,14 +506,16 @@ class Gate {
248
506
  /**
249
507
  * Get full status for dashboard.
250
508
  */
251
- getStatus() {
509
+ getStatus(namespace) {
252
510
  return {
253
511
  name: this.config.name,
254
512
  shadowMode: this.config.shadowMode,
255
513
  activeKeys: this.store.activeKeyCount,
256
- keys: this.store.listKeys(),
257
- usage: this.meter.getSummary(),
514
+ keys: this.store.listKeys(namespace),
515
+ usage: this.meter.getSummary(undefined, namespace),
258
516
  eventCount: this.meter.eventCount,
517
+ namespaces: this.store.listNamespaces(),
518
+ ...(namespace ? { filteredNamespace: namespace } : {}),
259
519
  config: {
260
520
  defaultCreditsPerCall: this.config.defaultCreditsPerCall,
261
521
  globalRateLimitPerMin: this.config.globalRateLimitPerMin,
@@ -279,7 +539,7 @@ class Gate {
279
539
  this.quotaTracker.unrecord(keyRecord, credits);
280
540
  this.store.save();
281
541
  }
282
- this.recordEvent(apiKey, keyRecord?.name || 'unknown', toolName, -credits, true, 'refund');
542
+ this.recordEvent(apiKey, keyRecord?.name || 'unknown', toolName, -credits, true, 'refund', keyRecord?.namespace);
283
543
  }
284
544
  /** Whether refund-on-failure is enabled */
285
545
  get refundOnFailure() {
@@ -289,7 +549,7 @@ class Gate {
289
549
  this.rateLimiter.destroy();
290
550
  this.webhook?.destroy();
291
551
  }
292
- recordEvent(apiKey, keyName, tool, creditsCharged, allowed, denyReason) {
552
+ recordEvent(apiKey, keyName, tool, creditsCharged, allowed, denyReason, namespace) {
293
553
  const event = {
294
554
  timestamp: new Date().toISOString(),
295
555
  apiKey: apiKey.slice(0, 10),
@@ -298,6 +558,7 @@ class Gate {
298
558
  creditsCharged,
299
559
  allowed,
300
560
  denyReason,
561
+ namespace,
301
562
  };
302
563
  this.meter.record(event);
303
564
  this.webhook?.emit(event);