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.
- package/README.md +132 -0
- package/dist/gate.d.ts +29 -9
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +275 -14
- package/dist/gate.js.map +1 -1
- package/dist/http-proxy.d.ts +6 -1
- package/dist/http-proxy.d.ts.map +1 -1
- package/dist/http-proxy.js +59 -0
- package/dist/http-proxy.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/meter.d.ts +2 -2
- package/dist/meter.d.ts.map +1 -1
- package/dist/meter.js +11 -6
- package/dist/meter.js.map +1 -1
- package/dist/proxy.d.ts +6 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +88 -2
- package/dist/proxy.js.map +1 -1
- package/dist/quota.d.ts +9 -0
- package/dist/quota.d.ts.map +1 -1
- package/dist/quota.js +49 -0
- package/dist/quota.js.map +1 -1
- package/dist/rate-limiter.d.ts +5 -0
- package/dist/rate-limiter.d.ts.map +1 -1
- package/dist/rate-limiter.js +12 -0
- package/dist/rate-limiter.js.map +1 -1
- package/dist/redis-sync.d.ts.map +1 -1
- package/dist/redis-sync.js +2 -0
- package/dist/redis-sync.js.map +1 -1
- package/dist/router.d.ts +6 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +87 -0
- package/dist/router.js.map +1 -1
- package/dist/server.d.ts +4 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +92 -7
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +20 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +43 -2
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- 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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
package/dist/gate.d.ts.map
CHANGED
|
@@ -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;
|
|
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);
|