paygate-mcp 2.6.0 → 2.8.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.
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ /**
3
+ * Config Validator — Validates PayGate config files and CLI flags before starting.
4
+ *
5
+ * Returns a list of diagnostic messages (errors and warnings) so operators can
6
+ * catch misconfigurations before the server starts.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.validateConfig = validateConfig;
10
+ exports.formatDiagnostics = formatDiagnostics;
11
+ /**
12
+ * Validate a PayGate config object. Returns an array of diagnostics.
13
+ * Empty array = valid config.
14
+ */
15
+ function validateConfig(config) {
16
+ const diags = [];
17
+ // ─── Backend source validation ──────────────────────────────────────────
18
+ const hasServer = !!(config.serverCommand);
19
+ const hasRemote = !!(config.remoteUrl);
20
+ const hasMulti = !!(config.servers && config.servers.length > 0);
21
+ if (!hasServer && !hasRemote && !hasMulti) {
22
+ diags.push({
23
+ level: 'error',
24
+ field: 'serverCommand | remoteUrl | servers',
25
+ message: 'No backend configured. Provide serverCommand, remoteUrl, or servers[].',
26
+ });
27
+ }
28
+ if (hasMulti && (hasServer || hasRemote)) {
29
+ diags.push({
30
+ level: 'error',
31
+ field: 'servers',
32
+ message: 'Cannot combine servers[] with serverCommand or remoteUrl. Use one or the other.',
33
+ });
34
+ }
35
+ if (hasServer && hasRemote) {
36
+ diags.push({
37
+ level: 'error',
38
+ field: 'serverCommand | remoteUrl',
39
+ message: 'Cannot specify both serverCommand and remoteUrl. Use one or the other.',
40
+ });
41
+ }
42
+ // ─── Multi-server validation ────────────────────────────────────────────
43
+ if (hasMulti && config.servers) {
44
+ const prefixes = new Set();
45
+ for (let i = 0; i < config.servers.length; i++) {
46
+ const s = config.servers[i];
47
+ if (!s.prefix) {
48
+ diags.push({
49
+ level: 'error',
50
+ field: `servers[${i}].prefix`,
51
+ message: `Server at index ${i} is missing required "prefix" field.`,
52
+ });
53
+ }
54
+ else {
55
+ if (prefixes.has(s.prefix)) {
56
+ diags.push({
57
+ level: 'error',
58
+ field: `servers[${i}].prefix`,
59
+ message: `Duplicate prefix "${s.prefix}". Each server must have a unique prefix.`,
60
+ });
61
+ }
62
+ prefixes.add(s.prefix);
63
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(s.prefix)) {
64
+ diags.push({
65
+ level: 'warning',
66
+ field: `servers[${i}].prefix`,
67
+ message: `Prefix "${s.prefix}" contains special characters. Recommended: alphanumeric, hyphens, underscores.`,
68
+ });
69
+ }
70
+ }
71
+ if (!s.serverCommand && !s.remoteUrl) {
72
+ diags.push({
73
+ level: 'error',
74
+ field: `servers[${i}]`,
75
+ message: `Server "${s.prefix || i}" has no serverCommand or remoteUrl.`,
76
+ });
77
+ }
78
+ if (s.serverCommand && s.remoteUrl) {
79
+ diags.push({
80
+ level: 'error',
81
+ field: `servers[${i}]`,
82
+ message: `Server "${s.prefix || i}" has both serverCommand and remoteUrl. Use one.`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ // ─── Port validation ────────────────────────────────────────────────────
88
+ if (config.port !== undefined) {
89
+ if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
90
+ diags.push({
91
+ level: 'error',
92
+ field: 'port',
93
+ message: `Invalid port ${config.port}. Must be 0–65535.`,
94
+ });
95
+ }
96
+ }
97
+ // ─── Numeric field validation ──────────────────────────────────────────
98
+ if (config.defaultCreditsPerCall !== undefined) {
99
+ if (!Number.isFinite(config.defaultCreditsPerCall) || config.defaultCreditsPerCall < 0) {
100
+ diags.push({
101
+ level: 'error',
102
+ field: 'defaultCreditsPerCall',
103
+ message: `Invalid defaultCreditsPerCall: ${config.defaultCreditsPerCall}. Must be >= 0.`,
104
+ });
105
+ }
106
+ }
107
+ if (config.globalRateLimitPerMin !== undefined) {
108
+ if (!Number.isFinite(config.globalRateLimitPerMin) || config.globalRateLimitPerMin < 0) {
109
+ diags.push({
110
+ level: 'error',
111
+ field: 'globalRateLimitPerMin',
112
+ message: `Invalid globalRateLimitPerMin: ${config.globalRateLimitPerMin}. Must be >= 0.`,
113
+ });
114
+ }
115
+ }
116
+ if (config.webhookMaxRetries !== undefined) {
117
+ if (!Number.isInteger(config.webhookMaxRetries) || config.webhookMaxRetries < 0) {
118
+ diags.push({
119
+ level: 'error',
120
+ field: 'webhookMaxRetries',
121
+ message: `Invalid webhookMaxRetries: ${config.webhookMaxRetries}. Must be a non-negative integer.`,
122
+ });
123
+ }
124
+ }
125
+ // ─── Webhook validation ────────────────────────────────────────────────
126
+ if (config.webhookSecret && !config.webhookUrl) {
127
+ diags.push({
128
+ level: 'warning',
129
+ field: 'webhookSecret',
130
+ message: 'webhookSecret is set but webhookUrl is not. Secret will be ignored.',
131
+ });
132
+ }
133
+ if (config.webhookUrl) {
134
+ try {
135
+ new URL(config.webhookUrl);
136
+ }
137
+ catch {
138
+ diags.push({
139
+ level: 'error',
140
+ field: 'webhookUrl',
141
+ message: `Invalid webhookUrl: "${config.webhookUrl}". Must be a valid URL.`,
142
+ });
143
+ }
144
+ }
145
+ // ─── Redis URL validation ──────────────────────────────────────────────
146
+ if (config.redisUrl) {
147
+ try {
148
+ const url = new URL(config.redisUrl);
149
+ if (url.protocol !== 'redis:' && url.protocol !== 'rediss:') {
150
+ diags.push({
151
+ level: 'error',
152
+ field: 'redisUrl',
153
+ message: `Invalid redisUrl protocol "${url.protocol}". Expected "redis://" or "rediss://".`,
154
+ });
155
+ }
156
+ }
157
+ catch {
158
+ diags.push({
159
+ level: 'error',
160
+ field: 'redisUrl',
161
+ message: `Invalid redisUrl: "${config.redisUrl}". Must be a valid redis:// URL.`,
162
+ });
163
+ }
164
+ }
165
+ // ─── Tool pricing validation ───────────────────────────────────────────
166
+ if (config.toolPricing) {
167
+ for (const [tool, pricing] of Object.entries(config.toolPricing)) {
168
+ if (pricing.creditsPerCall !== undefined && (!Number.isFinite(pricing.creditsPerCall) || pricing.creditsPerCall < 0)) {
169
+ diags.push({
170
+ level: 'error',
171
+ field: `toolPricing.${tool}.creditsPerCall`,
172
+ message: `Invalid creditsPerCall for "${tool}": ${pricing.creditsPerCall}. Must be >= 0.`,
173
+ });
174
+ }
175
+ if (pricing.rateLimitPerMin !== undefined && (!Number.isFinite(pricing.rateLimitPerMin) || pricing.rateLimitPerMin < 0)) {
176
+ diags.push({
177
+ level: 'error',
178
+ field: `toolPricing.${tool}.rateLimitPerMin`,
179
+ message: `Invalid rateLimitPerMin for "${tool}": ${pricing.rateLimitPerMin}. Must be >= 0.`,
180
+ });
181
+ }
182
+ }
183
+ }
184
+ // ─── Quota validation ──────────────────────────────────────────────────
185
+ if (config.globalQuota) {
186
+ const q = config.globalQuota;
187
+ for (const field of ['dailyCallLimit', 'monthlyCallLimit', 'dailyCreditLimit', 'monthlyCreditLimit']) {
188
+ const val = q[field];
189
+ if (val !== undefined && (!Number.isFinite(val) || val < 0)) {
190
+ diags.push({
191
+ level: 'error',
192
+ field: `globalQuota.${field}`,
193
+ message: `Invalid ${field}: ${val}. Must be >= 0.`,
194
+ });
195
+ }
196
+ }
197
+ }
198
+ // ─── Import keys validation ────────────────────────────────────────────
199
+ if (config.importKeys) {
200
+ for (const [key, credits] of Object.entries(config.importKeys)) {
201
+ if (!Number.isFinite(credits) || credits < 0) {
202
+ diags.push({
203
+ level: 'error',
204
+ field: `importKeys.${key}`,
205
+ message: `Invalid credits for imported key "${key}": ${credits}. Must be >= 0.`,
206
+ });
207
+ }
208
+ }
209
+ }
210
+ // ─── OAuth validation ─────────────────────────────────────────────────
211
+ if (config.oauth) {
212
+ if (config.oauth.accessTokenTtl !== undefined) {
213
+ if (!Number.isFinite(config.oauth.accessTokenTtl) || config.oauth.accessTokenTtl <= 0) {
214
+ diags.push({
215
+ level: 'error',
216
+ field: 'oauth.accessTokenTtl',
217
+ message: `Invalid accessTokenTtl: ${config.oauth.accessTokenTtl}. Must be > 0.`,
218
+ });
219
+ }
220
+ }
221
+ if (config.oauth.refreshTokenTtl !== undefined) {
222
+ if (!Number.isFinite(config.oauth.refreshTokenTtl) || config.oauth.refreshTokenTtl <= 0) {
223
+ diags.push({
224
+ level: 'error',
225
+ field: 'oauth.refreshTokenTtl',
226
+ message: `Invalid refreshTokenTtl: ${config.oauth.refreshTokenTtl}. Must be > 0.`,
227
+ });
228
+ }
229
+ }
230
+ }
231
+ // ─── Warnings ──────────────────────────────────────────────────────────
232
+ if (config.shadowMode) {
233
+ diags.push({
234
+ level: 'warning',
235
+ field: 'shadowMode',
236
+ message: 'Shadow mode is enabled. Payment will not be enforced.',
237
+ });
238
+ }
239
+ if (config.stateFile && config.redisUrl) {
240
+ diags.push({
241
+ level: 'warning',
242
+ field: 'stateFile + redisUrl',
243
+ message: 'Both stateFile and redisUrl are configured. Redis is the source of truth; stateFile is redundant.',
244
+ });
245
+ }
246
+ if (config.remoteUrl) {
247
+ try {
248
+ const url = new URL(config.remoteUrl);
249
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
250
+ diags.push({
251
+ level: 'error',
252
+ field: 'remoteUrl',
253
+ message: `Invalid remoteUrl protocol "${url.protocol}". Expected "http://" or "https://".`,
254
+ });
255
+ }
256
+ }
257
+ catch {
258
+ diags.push({
259
+ level: 'error',
260
+ field: 'remoteUrl',
261
+ message: `Invalid remoteUrl: "${config.remoteUrl}". Must be a valid URL.`,
262
+ });
263
+ }
264
+ }
265
+ return diags;
266
+ }
267
+ /**
268
+ * Format diagnostics for human-readable console output.
269
+ */
270
+ function formatDiagnostics(diags) {
271
+ if (diags.length === 0)
272
+ return '✓ Config is valid.';
273
+ const errors = diags.filter(d => d.level === 'error');
274
+ const warnings = diags.filter(d => d.level === 'warning');
275
+ const lines = [];
276
+ if (errors.length > 0) {
277
+ lines.push(`✗ ${errors.length} error(s):`);
278
+ for (const e of errors) {
279
+ lines.push(` ERROR [${e.field}] ${e.message}`);
280
+ }
281
+ }
282
+ if (warnings.length > 0) {
283
+ lines.push(`⚠ ${warnings.length} warning(s):`);
284
+ for (const w of warnings) {
285
+ lines.push(` WARN [${w.field}] ${w.message}`);
286
+ }
287
+ }
288
+ return lines.join('\n');
289
+ }
290
+ //# sourceMappingURL=config-validator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-validator.js","sourceRoot":"","sources":["../src/config-validator.ts"],"names":[],"mappings":"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"}
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
  */
@@ -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;;;;;;;;;;;;;;;;;;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"}
package/dist/gate.js CHANGED
@@ -181,6 +181,264 @@ class Gate {
181
181
  this.recordEvent(apiKey, keyRecord.name, toolName, creditsRequired, true);
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);
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
  */