paygate-mcp 9.2.0 → 9.4.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/dist/server.js CHANGED
@@ -100,6 +100,8 @@ const stripe_checkout_1 = require("./stripe-checkout");
100
100
  const backup_1 = require("./backup");
101
101
  const response_cache_1 = require("./response-cache");
102
102
  const circuit_breaker_1 = require("./circuit-breaker");
103
+ const compliance_1 = require("./compliance");
104
+ const guardrails_1 = require("./guardrails");
103
105
  /** Max request body size: 1MB */
104
106
  const MAX_BODY_SIZE = 1_048_576;
105
107
  /**
@@ -431,6 +433,8 @@ class PayGateServer {
431
433
  responseCache = null;
432
434
  /** Circuit breaker for backend failure detection (null if disabled) */
433
435
  circuitBreaker = null;
436
+ /** Content guardrails for PII detection and content policy enforcement. */
437
+ guardrails;
434
438
  /** The active request handler — either proxy or router */
435
439
  get handler() {
436
440
  return (this.router || this.proxy);
@@ -654,6 +658,12 @@ class PayGateServer {
654
658
  cooldownSeconds: this.config.circuitBreakerCooldownSeconds ?? 30,
655
659
  });
656
660
  }
661
+ // Content guardrails for PII detection
662
+ this.guardrails = new guardrails_1.ContentGuardrails({
663
+ enabled: this.config.guardrails?.enabled ?? false,
664
+ includeContext: this.config.guardrails?.includeContext ?? false,
665
+ maxViolations: this.config.guardrails?.maxViolations ?? 10_000,
666
+ });
657
667
  // Backup manager for full state snapshots
658
668
  this.backup = new backup_1.BackupManager(this.createBackupProvider());
659
669
  }
@@ -911,7 +921,7 @@ class PayGateServer {
911
921
  res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
912
922
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, HEAD, OPTIONS');
913
923
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Admin-Key, Mcp-Session-Id, Authorization, X-Request-Id');
914
- res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Credits-Remaining, X-Request-Id, X-PayGate-Version, X-Cache');
924
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Credits-Remaining, X-Request-Id, X-PayGate-Version, X-Cache, X-Output-Surcharge');
915
925
  if (corsConfig?.credentials) {
916
926
  res.setHeader('Access-Control-Allow-Credentials', 'true');
917
927
  }
@@ -1109,6 +1119,8 @@ class PayGateServer {
1109
1119
  return this.handleCreditTransfer(req, res);
1110
1120
  case '/keys/bulk':
1111
1121
  return this.handleBulkOperations(req, res);
1122
+ case '/keys/webhook':
1123
+ return this.handleKeyWebhook(req, res);
1112
1124
  case '/keys/export':
1113
1125
  return this.handleKeyExport(req, res);
1114
1126
  case '/keys/import':
@@ -1380,6 +1392,17 @@ class PayGateServer {
1380
1392
  return this.handleComplianceReport(req, res);
1381
1393
  this.sendError(res, 405, 'Method not allowed. Use GET.');
1382
1394
  return;
1395
+ case '/admin/compliance/export':
1396
+ if (req.method === 'GET')
1397
+ return this.handleComplianceExport(req, res);
1398
+ this.sendError(res, 405, 'Method not allowed. Use GET.');
1399
+ return;
1400
+ case '/admin/guardrails':
1401
+ return this.handleGuardrails(req, res);
1402
+ case '/admin/guardrails/violations':
1403
+ return this.handleGuardrailViolations(req, res);
1404
+ case '/keys/geo':
1405
+ return this.handleKeyGeo(req, res);
1383
1406
  case '/admin/sla':
1384
1407
  if (req.method === 'GET')
1385
1408
  return this.handleSlaMonitoring(req, res);
@@ -1765,6 +1788,9 @@ class PayGateServer {
1765
1788
  }
1766
1789
  // Extract client IP for IP allowlist checking (trusted proxy-aware)
1767
1790
  const clientIp = resolveClientIp(req, this.config.trustedProxies);
1791
+ // Extract country code from reverse proxy header (Cloudflare, AWS, etc.)
1792
+ const geoHeader = this.config.geoCountryHeader || 'X-Country';
1793
+ const countryCode = req.headers[geoHeader.toLowerCase()] || undefined;
1768
1794
  // Extract scoped token tool restrictions (set by resolveApiKey)
1769
1795
  const scopedTokenTools = req._scopedTokenTools;
1770
1796
  // ─── Batch tool calls ────────────────────────────────────────────────
@@ -1782,7 +1808,7 @@ class PayGateServer {
1782
1808
  res.end(JSON.stringify(errResp));
1783
1809
  return;
1784
1810
  }
1785
- const batchResponse = await this.handler.handleBatchRequest(calls, request.id, apiKey, clientIp, scopedTokenTools);
1811
+ const batchResponse = await this.handler.handleBatchRequest(calls, request.id, apiKey, clientIp, scopedTokenTools, countryCode);
1786
1812
  // Audit + metrics for batch
1787
1813
  if (batchResponse.error) {
1788
1814
  this.audit.log('gate.deny', (0, audit_1.maskKeyForAudit)(apiKey || 'anonymous'), `Batch denied (${calls.length} calls)`, {
@@ -1837,6 +1863,38 @@ class PayGateServer {
1837
1863
  const toolCallStartTime = Date.now();
1838
1864
  let cacheHit = false;
1839
1865
  let circuitBroken = false;
1866
+ // ─── Content Guardrails: scan inputs ────────────────────────────
1867
+ if (this.guardrails.isEnabled && pluginRequest.method === 'tools/call') {
1868
+ const grToolName = pluginRequest.params?.name || '';
1869
+ const grToolArgs = pluginRequest.params?.arguments;
1870
+ const inputContent = grToolArgs ? JSON.stringify(grToolArgs) : '';
1871
+ if (inputContent) {
1872
+ const grResult = this.guardrails.check(inputContent, grToolName, 'input', apiKey ? apiKey.slice(0, 10) : 'anon');
1873
+ if (grResult.blocked) {
1874
+ const ruleNames = grResult.violations.filter(v => v.action === 'block').map(v => v.ruleName).join(', ');
1875
+ this.audit.log('guardrail.block', (0, audit_1.maskKeyForAudit)(apiKey || 'anonymous'), `Input blocked by guardrail: ${ruleNames}`, { tool: grToolName, scope: 'input', requestId });
1876
+ const errResp = {
1877
+ jsonrpc: '2.0',
1878
+ id: pluginRequest.id,
1879
+ error: { code: -32406, message: `Content policy violation: input blocked by guardrail (${ruleNames})` },
1880
+ };
1881
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Mcp-Session-Id': sessionId });
1882
+ res.end(JSON.stringify(errResp));
1883
+ return;
1884
+ }
1885
+ if (grResult.redactedContent && grToolArgs) {
1886
+ // Replace arguments with redacted version
1887
+ try {
1888
+ pluginRequest.params.arguments = JSON.parse(grResult.redactedContent);
1889
+ }
1890
+ catch { /* ignore parse error, use original */ }
1891
+ }
1892
+ // Add warnings as response headers
1893
+ for (const warning of grResult.warnings) {
1894
+ res.setHeader('X-Guardrail-Warning', warning);
1895
+ }
1896
+ }
1897
+ }
1840
1898
  // ─── Response Cache check (before forwarding to backend) ────────
1841
1899
  // response is guaranteed assigned before use: either via cache hit, circuit breaker, or handler
1842
1900
  let response = null;
@@ -1885,12 +1943,12 @@ class PayGateServer {
1885
1943
  }, timeoutMs);
1886
1944
  });
1887
1945
  response = await Promise.race([
1888
- this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools),
1946
+ this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools, countryCode),
1889
1947
  timeoutPromise,
1890
1948
  ]);
1891
1949
  }
1892
1950
  else {
1893
- response = await this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools);
1951
+ response = await this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools, countryCode);
1894
1952
  }
1895
1953
  // Record circuit breaker outcome
1896
1954
  if (pluginRequest.method === 'tools/call' && this.circuitBreaker) {
@@ -1910,6 +1968,64 @@ class PayGateServer {
1910
1968
  }
1911
1969
  }
1912
1970
  }
1971
+ // ─── Outcome-based pricing: post-call output surcharge ────────
1972
+ let outputSurcharge = 0;
1973
+ if (!cacheHit && !circuitBroken && request.method === 'tools/call' && response && !response.error && apiKey) {
1974
+ const toolName = request.params?.name || '';
1975
+ const toolPricingForOutput = this.config.toolPricing[toolName];
1976
+ const creditsPerKbOutput = toolPricingForOutput?.creditsPerKbOutput ?? 0;
1977
+ if (creditsPerKbOutput > 0) {
1978
+ const outputBytes = Buffer.byteLength(JSON.stringify(response.result ?? ''), 'utf-8');
1979
+ const outputKb = outputBytes / 1024;
1980
+ outputSurcharge = Math.ceil(outputKb * creditsPerKbOutput);
1981
+ if (outputSurcharge > 0) {
1982
+ const keyRecord = this.gate.store.getKey(apiKey);
1983
+ if (keyRecord && keyRecord.credits >= outputSurcharge) {
1984
+ this.gate.store.deductCredits(apiKey, outputSurcharge);
1985
+ keyRecord.totalSpent += outputSurcharge;
1986
+ this.gate.store.save();
1987
+ this.gate.onCreditsDeducted?.(apiKey, outputSurcharge);
1988
+ }
1989
+ else if (keyRecord) {
1990
+ // Insufficient credits for output surcharge — deduct what's available
1991
+ const available = keyRecord.credits;
1992
+ if (available > 0) {
1993
+ this.gate.store.deductCredits(apiKey, available);
1994
+ keyRecord.totalSpent += available;
1995
+ this.gate.store.save();
1996
+ this.gate.onCreditsDeducted?.(apiKey, available);
1997
+ }
1998
+ outputSurcharge = available;
1999
+ }
2000
+ }
2001
+ }
2002
+ }
2003
+ // ─── Content Guardrails: scan outputs ────────────────────────────
2004
+ if (this.guardrails.isEnabled && request.method === 'tools/call' && response && !response.error) {
2005
+ const grToolName = request.params?.name || '';
2006
+ const outputContent = response.result ? JSON.stringify(response.result) : '';
2007
+ if (outputContent) {
2008
+ const grResult = this.guardrails.check(outputContent, grToolName, 'output', apiKey ? apiKey.slice(0, 10) : 'anon');
2009
+ if (grResult.blocked) {
2010
+ const ruleNames = grResult.violations.filter(v => v.action === 'block').map(v => v.ruleName).join(', ');
2011
+ this.audit.log('guardrail.block', (0, audit_1.maskKeyForAudit)(apiKey || 'anonymous'), `Output blocked by guardrail: ${ruleNames}`, { tool: grToolName, scope: 'output', requestId });
2012
+ response = {
2013
+ jsonrpc: '2.0',
2014
+ id: response.id,
2015
+ error: { code: -32406, message: `Content policy violation: output blocked by guardrail (${ruleNames})` },
2016
+ };
2017
+ }
2018
+ else if (grResult.redactedContent) {
2019
+ try {
2020
+ response = { ...response, result: JSON.parse(grResult.redactedContent) };
2021
+ }
2022
+ catch { /* ignore parse error, use original */ }
2023
+ }
2024
+ for (const warning of grResult.warnings) {
2025
+ res.setHeader('X-Guardrail-Warning', warning);
2026
+ }
2027
+ }
2028
+ }
1913
2029
  // Plugin: afterToolCall — let plugins modify the response
1914
2030
  if (this.plugins.count > 0 && request.method === 'tools/call') {
1915
2031
  const toolName = request.params?.name || '';
@@ -2026,6 +2142,10 @@ class PayGateServer {
2026
2142
  if (request.method === 'tools/call' && this.responseCache) {
2027
2143
  rateLimitHeaders['X-Cache'] = cacheHit ? 'HIT' : 'MISS';
2028
2144
  }
2145
+ // Add X-Output-Surcharge header for outcome-based pricing
2146
+ if (request.method === 'tools/call' && outputSurcharge > 0) {
2147
+ rateLimitHeaders['X-Output-Surcharge'] = String(outputSurcharge);
2148
+ }
2029
2149
  // Check if client accepts SSE
2030
2150
  const accept = req.headers['accept'] || '';
2031
2151
  const wantsSse = accept.includes('text/event-stream');
@@ -2413,6 +2533,11 @@ class PayGateServer {
2413
2533
  adminRestore: 'POST /admin/restore — Restore state from backup snapshot (requires X-Admin-Key, admin)',
2414
2534
  adminCache: 'GET /admin/cache — Response cache stats; DELETE /admin/cache?tool= — Clear cache (requires X-Admin-Key)',
2415
2535
  adminCircuit: 'GET /admin/circuit — Circuit breaker status; POST /admin/circuit — Reset circuit breaker (requires X-Admin-Key)',
2536
+ adminComplianceExport: 'GET /admin/compliance/export?framework=soc2|gdpr|hipaa&format=json|csv — Framework-specific compliance audit export with event classification (requires X-Admin-Key)',
2537
+ keyWebhook: 'POST /keys/webhook — Set per-key webhook URL; DELETE /keys/webhook — Remove per-key webhook (requires X-Admin-Key)',
2538
+ adminGuardrails: 'GET /admin/guardrails — View guardrail rules and stats; POST /admin/guardrails — Toggle, upsert rule, or import rules; DELETE /admin/guardrails?id= — Remove rule (requires X-Admin-Key)',
2539
+ adminGuardrailViolations: 'GET /admin/guardrails/violations — View content policy violations with filters; DELETE — Clear all violations (requires X-Admin-Key)',
2540
+ keyGeo: 'POST /keys/geo — Set per-key country restrictions; GET /keys/geo?key= — View geo restrictions; DELETE /keys/geo?key= — Clear restrictions (requires X-Admin-Key)',
2416
2541
  ...(this.oauth ? {
2417
2542
  oauthMetadata: 'GET /.well-known/oauth-authorization-server — OAuth 2.1 server metadata',
2418
2543
  oauthRegister: 'POST /oauth/register — Register OAuth client',
@@ -3044,6 +3169,54 @@ class PayGateServer {
3044
3169
  });
3045
3170
  break;
3046
3171
  }
3172
+ case 'suspend': {
3173
+ const key = op.key;
3174
+ if (!key) {
3175
+ results.push({ index: i, action: 'suspend', success: false, error: 'Missing key' });
3176
+ break;
3177
+ }
3178
+ const rec = this.gate.store.getKey(key);
3179
+ if (!rec) {
3180
+ results.push({ index: i, action: 'suspend', success: false, error: 'Key not found' });
3181
+ break;
3182
+ }
3183
+ if (rec.suspended) {
3184
+ results.push({ index: i, action: 'suspend', success: true, result: { keyMasked: (0, audit_1.maskKeyForAudit)(key), alreadySuspended: true } });
3185
+ break;
3186
+ }
3187
+ rec.suspended = true;
3188
+ this.gate.store.save();
3189
+ if (this.redisSync)
3190
+ this.redisSync.saveKey(rec).catch(() => { });
3191
+ this.audit.log('key.suspended', 'admin', 'Key suspended (bulk)', { keyMasked: (0, audit_1.maskKeyForAudit)(key) });
3192
+ this.emitWebhookAdmin('key.suspended', 'admin', { keyMasked: (0, audit_1.maskKeyForAudit)(key) });
3193
+ results.push({ index: i, action: 'suspend', success: true, result: { keyMasked: (0, audit_1.maskKeyForAudit)(key) } });
3194
+ break;
3195
+ }
3196
+ case 'resume': {
3197
+ const key = op.key;
3198
+ if (!key) {
3199
+ results.push({ index: i, action: 'resume', success: false, error: 'Missing key' });
3200
+ break;
3201
+ }
3202
+ const rec = this.gate.store.getKey(key);
3203
+ if (!rec) {
3204
+ results.push({ index: i, action: 'resume', success: false, error: 'Key not found' });
3205
+ break;
3206
+ }
3207
+ if (!rec.suspended) {
3208
+ results.push({ index: i, action: 'resume', success: true, result: { keyMasked: (0, audit_1.maskKeyForAudit)(key), alreadyActive: true } });
3209
+ break;
3210
+ }
3211
+ rec.suspended = false;
3212
+ this.gate.store.save();
3213
+ if (this.redisSync)
3214
+ this.redisSync.saveKey(rec).catch(() => { });
3215
+ this.audit.log('key.resumed', 'admin', 'Key resumed (bulk)', { keyMasked: (0, audit_1.maskKeyForAudit)(key) });
3216
+ this.emitWebhookAdmin('key.resumed', 'admin', { keyMasked: (0, audit_1.maskKeyForAudit)(key) });
3217
+ results.push({ index: i, action: 'resume', success: true, result: { keyMasked: (0, audit_1.maskKeyForAudit)(key) } });
3218
+ break;
3219
+ }
3047
3220
  default:
3048
3221
  results.push({ index: i, action: op.action || 'unknown', success: false, error: `Unknown action: ${op.action}` });
3049
3222
  }
@@ -3063,6 +3236,116 @@ class PayGateServer {
3063
3236
  });
3064
3237
  }
3065
3238
  // ─── /keys/export — Export all API keys for backup ────────────────────────
3239
+ // ─── /keys/webhook — Per-key webhook URL management ────────────────────────
3240
+ async handleKeyWebhook(req, res) {
3241
+ if (!this.checkAdmin(req, res))
3242
+ return;
3243
+ if (req.method === 'POST') {
3244
+ // Set per-key webhook URL
3245
+ const body = await this.readBody(req);
3246
+ let params;
3247
+ try {
3248
+ params = JSON.parse(body);
3249
+ }
3250
+ catch {
3251
+ this.sendError(res, 400, 'Invalid JSON');
3252
+ return;
3253
+ }
3254
+ const apiKeyPrefix = params.apiKey;
3255
+ const webhookUrl = params.webhookUrl;
3256
+ const webhookSecret = params.webhookSecret;
3257
+ if (!apiKeyPrefix) {
3258
+ this.sendError(res, 400, 'Missing apiKey parameter');
3259
+ return;
3260
+ }
3261
+ if (!webhookUrl) {
3262
+ this.sendError(res, 400, 'Missing webhookUrl parameter');
3263
+ return;
3264
+ }
3265
+ // SSRF check
3266
+ const ssrfError = (0, webhook_1.checkSsrf)(webhookUrl);
3267
+ if (ssrfError) {
3268
+ this.sendError(res, 400, `Webhook URL blocked: ${ssrfError}`);
3269
+ return;
3270
+ }
3271
+ // Find key
3272
+ const allKeys = this.gate.store.getAllRecords();
3273
+ const keyRecord = allKeys.find((k) => k.key.startsWith(apiKeyPrefix));
3274
+ if (!keyRecord) {
3275
+ this.sendError(res, 404, 'API key not found');
3276
+ return;
3277
+ }
3278
+ keyRecord.webhookUrl = webhookUrl;
3279
+ if (webhookSecret !== undefined) {
3280
+ keyRecord.webhookSecret = webhookSecret;
3281
+ }
3282
+ this.gate.store.save();
3283
+ // Reset cached emitter so next event creates fresh one
3284
+ this.gate.removeKeyWebhook(keyRecord.key);
3285
+ this.audit.log('key.webhook_updated', 'admin', `Per-key webhook set for ${keyRecord.name}`, {
3286
+ keyPrefix: keyRecord.key.slice(0, 7),
3287
+ webhookUrl: webhookUrl.replace(/\/\/([^:]+):([^@]+)@/, '//$1:***@'), // mask credentials
3288
+ });
3289
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3290
+ res.end(JSON.stringify({
3291
+ ok: true,
3292
+ keyName: keyRecord.name,
3293
+ webhookUrl,
3294
+ webhookSecret: webhookSecret ? '***' : null,
3295
+ }));
3296
+ return;
3297
+ }
3298
+ if (req.method === 'DELETE') {
3299
+ // Remove per-key webhook URL
3300
+ const url = new URL(req.url || '/', 'http://localhost');
3301
+ const apiKeyPrefix = url.searchParams.get('apiKey');
3302
+ if (!apiKeyPrefix) {
3303
+ this.sendError(res, 400, 'Missing apiKey query parameter');
3304
+ return;
3305
+ }
3306
+ const allKeys2 = this.gate.store.getAllRecords();
3307
+ const keyRecord = allKeys2.find((k) => k.key.startsWith(apiKeyPrefix));
3308
+ if (!keyRecord) {
3309
+ this.sendError(res, 404, 'API key not found');
3310
+ return;
3311
+ }
3312
+ delete keyRecord.webhookUrl;
3313
+ delete keyRecord.webhookSecret;
3314
+ this.gate.store.save();
3315
+ // Destroy cached emitter
3316
+ this.gate.removeKeyWebhook(keyRecord.key);
3317
+ this.audit.log('key.webhook_updated', 'admin', `Per-key webhook removed for ${keyRecord.name}`, {
3318
+ keyPrefix: keyRecord.key.slice(0, 7),
3319
+ });
3320
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3321
+ res.end(JSON.stringify({ ok: true, keyName: keyRecord.name, webhookUrl: null }));
3322
+ return;
3323
+ }
3324
+ if (req.method === 'GET') {
3325
+ // Get per-key webhook status
3326
+ const url = new URL(req.url || '/', 'http://localhost');
3327
+ const apiKeyPrefix = url.searchParams.get('apiKey');
3328
+ if (!apiKeyPrefix) {
3329
+ this.sendError(res, 400, 'Missing apiKey query parameter');
3330
+ return;
3331
+ }
3332
+ const allKeys3 = this.gate.store.getAllRecords();
3333
+ const keyRecord = allKeys3.find((k) => k.key.startsWith(apiKeyPrefix));
3334
+ if (!keyRecord) {
3335
+ this.sendError(res, 404, 'API key not found');
3336
+ return;
3337
+ }
3338
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3339
+ res.end(JSON.stringify({
3340
+ keyName: keyRecord.name,
3341
+ webhookUrl: keyRecord.webhookUrl || null,
3342
+ webhookSecret: keyRecord.webhookSecret ? '***' : null,
3343
+ configured: !!keyRecord.webhookUrl,
3344
+ }));
3345
+ return;
3346
+ }
3347
+ this.sendError(res, 405, 'Method not allowed. Use GET, POST, or DELETE.');
3348
+ }
3066
3349
  handleKeyExport(req, res) {
3067
3350
  if (req.method !== 'GET') {
3068
3351
  this.sendError(res, 405, 'Method not allowed');
@@ -7192,6 +7475,44 @@ class PayGateServer {
7192
7475
  generatedAt: new Date().toISOString(),
7193
7476
  });
7194
7477
  }
7478
+ // ─── /admin/compliance/export — Framework-specific audit export ──────────
7479
+ handleComplianceExport(req, res) {
7480
+ if (!this.checkAdmin(req, res))
7481
+ return;
7482
+ const url = new URL(req.url || '/', `http://localhost`);
7483
+ const framework = (url.searchParams.get('framework') || 'soc2');
7484
+ const format = url.searchParams.get('format') || 'json';
7485
+ const since = url.searchParams.get('since') || undefined;
7486
+ const until = url.searchParams.get('until') || undefined;
7487
+ const validFrameworks = ['soc2', 'gdpr', 'hipaa'];
7488
+ if (!validFrameworks.includes(framework)) {
7489
+ this.sendError(res, 400, `Invalid framework. Must be one of: ${validFrameworks.join(', ')}`);
7490
+ return;
7491
+ }
7492
+ const report = (0, compliance_1.generateComplianceReport)(this.audit, framework, {
7493
+ since,
7494
+ until,
7495
+ serverVersion: PKG_VERSION,
7496
+ });
7497
+ this.audit.log('config.export', 'admin', `Compliance report exported: ${framework} (${format})`, {
7498
+ framework, format, events: report.meta.totalEvents,
7499
+ });
7500
+ if (format === 'csv') {
7501
+ const csv = (0, compliance_1.complianceReportToCsv)(report);
7502
+ res.writeHead(200, {
7503
+ 'Content-Type': 'text/csv',
7504
+ 'Content-Disposition': `attachment; filename="compliance-${framework}-${new Date().toISOString().slice(0, 10)}.csv"`,
7505
+ });
7506
+ res.end(csv);
7507
+ return;
7508
+ }
7509
+ // JSON (default) with download Content-Disposition
7510
+ res.writeHead(200, {
7511
+ 'Content-Type': 'application/json',
7512
+ 'Content-Disposition': `attachment; filename="compliance-${framework}-${new Date().toISOString().slice(0, 10)}.json"`,
7513
+ });
7514
+ res.end(JSON.stringify(report, null, 2));
7515
+ }
7195
7516
  // ─── /admin/sla — SLA Monitoring ─────────────────────────────────────────
7196
7517
  handleSlaMonitoring(req, res) {
7197
7518
  if (!this.checkAdmin(req, res))
@@ -12026,6 +12347,14 @@ class PayGateServer {
12026
12347
  else if (this.gate.webhook) {
12027
12348
  this.gate.webhook.emitAdmin(type, actor, metadata);
12028
12349
  }
12350
+ // Per-key webhook: also emit to key-specific webhook if the event references a key
12351
+ const apiKey = (metadata.apiKey || metadata.key);
12352
+ if (apiKey) {
12353
+ const keyWebhook = this.gate.getKeyWebhook(apiKey);
12354
+ if (keyWebhook) {
12355
+ keyWebhook.emitAdmin(type, actor, metadata);
12356
+ }
12357
+ }
12029
12358
  }
12030
12359
  syncKeyMutation(apiKey) {
12031
12360
  if (!this.redisSync)
@@ -12804,6 +13133,238 @@ class PayGateServer {
12804
13133
  tools,
12805
13134
  });
12806
13135
  }
13136
+ // ─── /admin/guardrails — Guardrail rule management ─────────────────────────
13137
+ async handleGuardrails(req, res) {
13138
+ if (req.method === 'GET') {
13139
+ if (!this.checkAdmin(req, res, 'viewer'))
13140
+ return;
13141
+ this.sendJson(res, 200, {
13142
+ enabled: this.guardrails.isEnabled,
13143
+ rules: this.guardrails.getRules(),
13144
+ stats: this.guardrails.getStats(),
13145
+ });
13146
+ return;
13147
+ }
13148
+ if (req.method === 'POST') {
13149
+ if (!this.checkAdmin(req, res, 'admin'))
13150
+ return;
13151
+ const body = await this.readBody(req);
13152
+ let params;
13153
+ try {
13154
+ params = safeJsonParse(body);
13155
+ }
13156
+ catch {
13157
+ this.sendError(res, 400, 'Invalid JSON');
13158
+ return;
13159
+ }
13160
+ // Toggle enabled/disabled
13161
+ if (params.enabled !== undefined) {
13162
+ this.guardrails.setEnabled(!!params.enabled);
13163
+ this.audit.log('guardrail.toggle', 'admin', `Guardrails ${params.enabled ? 'enabled' : 'disabled'}`);
13164
+ this.sendJson(res, 200, { enabled: this.guardrails.isEnabled });
13165
+ return;
13166
+ }
13167
+ // Upsert a rule
13168
+ if (params.rule && typeof params.rule === 'object') {
13169
+ const rule = params.rule;
13170
+ if (!rule.id || !rule.name || !rule.pattern || !rule.action) {
13171
+ this.sendError(res, 400, 'Rule requires: id, name, pattern, action');
13172
+ return;
13173
+ }
13174
+ if (!['log', 'warn', 'block', 'redact'].includes(rule.action)) {
13175
+ this.sendError(res, 400, 'Invalid action. Must be: log, warn, block, redact');
13176
+ return;
13177
+ }
13178
+ // Validate regex compiles
13179
+ try {
13180
+ new RegExp(rule.pattern, rule.flags ?? 'gi');
13181
+ }
13182
+ catch {
13183
+ this.sendError(res, 400, 'Invalid regex pattern');
13184
+ return;
13185
+ }
13186
+ const saved = this.guardrails.upsertRule({
13187
+ ...rule,
13188
+ active: rule.active !== false,
13189
+ scope: rule.scope || 'both',
13190
+ tools: rule.tools || [],
13191
+ });
13192
+ this.audit.log('guardrail.rule_upsert', 'admin', `Guardrail rule upserted: ${saved.name}`, { ruleId: saved.id, action: saved.action });
13193
+ this.sendJson(res, 200, { rule: saved });
13194
+ return;
13195
+ }
13196
+ // Import rules
13197
+ if (params.rules && Array.isArray(params.rules)) {
13198
+ const mode = params.mode === 'replace' ? 'replace' : 'merge';
13199
+ const count = this.guardrails.importRules(params.rules, mode);
13200
+ this.audit.log('guardrail.rules_import', 'admin', `Imported ${count} guardrail rules (${mode})`);
13201
+ this.sendJson(res, 200, { imported: count, mode, totalRules: this.guardrails.getRules().length });
13202
+ return;
13203
+ }
13204
+ this.sendError(res, 400, 'Provide "enabled" (boolean), "rule" (object), or "rules" (array)');
13205
+ return;
13206
+ }
13207
+ if (req.method === 'DELETE') {
13208
+ if (!this.checkAdmin(req, res, 'admin'))
13209
+ return;
13210
+ const url = new URL(req.url || '', `http://localhost`);
13211
+ const ruleId = url.searchParams.get('id');
13212
+ if (!ruleId) {
13213
+ this.sendError(res, 400, 'Missing ?id= query parameter');
13214
+ return;
13215
+ }
13216
+ const removed = this.guardrails.removeRule(ruleId);
13217
+ if (!removed) {
13218
+ this.sendError(res, 404, `Rule not found: ${ruleId}`);
13219
+ return;
13220
+ }
13221
+ this.audit.log('guardrail.rule_delete', 'admin', `Guardrail rule deleted: ${ruleId}`);
13222
+ this.sendJson(res, 200, { deleted: ruleId });
13223
+ return;
13224
+ }
13225
+ this.sendError(res, 405, 'Method not allowed. Use GET/POST/DELETE.');
13226
+ }
13227
+ // ─── /admin/guardrails/violations — View guardrail violations ──────────────
13228
+ handleGuardrailViolations(req, res) {
13229
+ if (req.method === 'GET') {
13230
+ if (!this.checkAdmin(req, res, 'viewer'))
13231
+ return;
13232
+ const url = new URL(req.url || '', `http://localhost`);
13233
+ const limit = clampInt(parseInt(url.searchParams.get('limit') || '100', 10), 1, 1000);
13234
+ const offset = clampInt(parseInt(url.searchParams.get('offset') || '0', 10), 0, 100_000);
13235
+ const ruleId = url.searchParams.get('ruleId') || undefined;
13236
+ const tool = url.searchParams.get('tool') || undefined;
13237
+ const action = url.searchParams.get('action') || undefined;
13238
+ const since = url.searchParams.get('since') || undefined;
13239
+ const until = url.searchParams.get('until') || undefined;
13240
+ const result = this.guardrails.queryViolations({ ruleId, tool, action, since, until, limit, offset });
13241
+ this.sendJson(res, 200, {
13242
+ violations: result.violations,
13243
+ total: result.total,
13244
+ limit,
13245
+ offset,
13246
+ hasMore: offset + limit < result.total,
13247
+ });
13248
+ return;
13249
+ }
13250
+ if (req.method === 'DELETE') {
13251
+ if (!this.checkAdmin(req, res, 'admin'))
13252
+ return;
13253
+ const cleared = this.guardrails.clearViolations();
13254
+ this.sendJson(res, 200, { cleared });
13255
+ return;
13256
+ }
13257
+ this.sendError(res, 405, 'Method not allowed. Use GET/DELETE.');
13258
+ }
13259
+ // ─── /keys/geo — Per-key geo-restriction management ─────────────────────────
13260
+ async handleKeyGeo(req, res) {
13261
+ if (!this.checkAdmin(req, res, 'admin'))
13262
+ return;
13263
+ if (req.method === 'GET') {
13264
+ const url = new URL(req.url || '', `http://localhost`);
13265
+ const apiKeyPrefix = url.searchParams.get('key');
13266
+ if (!apiKeyPrefix) {
13267
+ this.sendError(res, 400, 'Missing ?key= query parameter (key prefix)');
13268
+ return;
13269
+ }
13270
+ const record = this.gate.store.getAllRecords().find((k) => k.key.startsWith(apiKeyPrefix));
13271
+ if (!record) {
13272
+ this.sendError(res, 404, 'Key not found');
13273
+ return;
13274
+ }
13275
+ this.sendJson(res, 200, {
13276
+ keyPrefix: record.key.slice(0, 10) + '...',
13277
+ allowedCountries: record.allowedCountries || [],
13278
+ deniedCountries: record.deniedCountries || [],
13279
+ });
13280
+ return;
13281
+ }
13282
+ if (req.method === 'POST') {
13283
+ const body = await this.readBody(req);
13284
+ let params;
13285
+ try {
13286
+ params = safeJsonParse(body);
13287
+ }
13288
+ catch {
13289
+ this.sendError(res, 400, 'Invalid JSON');
13290
+ return;
13291
+ }
13292
+ const apiKeyPrefix = params.key;
13293
+ if (!apiKeyPrefix) {
13294
+ this.sendError(res, 400, 'Missing "key" parameter (key prefix)');
13295
+ return;
13296
+ }
13297
+ const record = this.gate.store.getAllRecords().find((k) => k.key.startsWith(apiKeyPrefix));
13298
+ if (!record) {
13299
+ this.sendError(res, 404, 'Key not found');
13300
+ return;
13301
+ }
13302
+ // Validate country codes (ISO 3166-1 alpha-2)
13303
+ const COUNTRY_CODE_RE = /^[A-Z]{2}$/;
13304
+ if (params.allowedCountries !== undefined) {
13305
+ if (!Array.isArray(params.allowedCountries)) {
13306
+ this.sendError(res, 400, '"allowedCountries" must be an array of ISO 3166-1 alpha-2 codes');
13307
+ return;
13308
+ }
13309
+ const codes = params.allowedCountries.map(c => String(c).toUpperCase());
13310
+ if (codes.some(c => !COUNTRY_CODE_RE.test(c))) {
13311
+ this.sendError(res, 400, 'Invalid country code. Use ISO 3166-1 alpha-2 (e.g., "US", "GB", "DE")');
13312
+ return;
13313
+ }
13314
+ record.allowedCountries = codes.length > 0 ? codes : undefined;
13315
+ }
13316
+ if (params.deniedCountries !== undefined) {
13317
+ if (!Array.isArray(params.deniedCountries)) {
13318
+ this.sendError(res, 400, '"deniedCountries" must be an array of ISO 3166-1 alpha-2 codes');
13319
+ return;
13320
+ }
13321
+ const codes = params.deniedCountries.map(c => String(c).toUpperCase());
13322
+ if (codes.some(c => !COUNTRY_CODE_RE.test(c))) {
13323
+ this.sendError(res, 400, 'Invalid country code. Use ISO 3166-1 alpha-2 (e.g., "US", "GB", "DE")');
13324
+ return;
13325
+ }
13326
+ record.deniedCountries = codes.length > 0 ? codes : undefined;
13327
+ }
13328
+ this.gate.store.save();
13329
+ if (this.redisSync)
13330
+ this.redisSync.saveKey(record).catch(() => { });
13331
+ this.audit.log('key.geo_set', 'admin', `Geo restrictions set for ${record.key.slice(0, 10)}...`, {
13332
+ keyMasked: (0, audit_1.maskKeyForAudit)(record.key),
13333
+ allowedCountries: record.allowedCountries || [],
13334
+ deniedCountries: record.deniedCountries || [],
13335
+ });
13336
+ this.sendJson(res, 200, {
13337
+ keyPrefix: record.key.slice(0, 10) + '...',
13338
+ allowedCountries: record.allowedCountries || [],
13339
+ deniedCountries: record.deniedCountries || [],
13340
+ });
13341
+ return;
13342
+ }
13343
+ if (req.method === 'DELETE') {
13344
+ const url = new URL(req.url || '', `http://localhost`);
13345
+ const apiKeyPrefix = url.searchParams.get('key');
13346
+ if (!apiKeyPrefix) {
13347
+ this.sendError(res, 400, 'Missing ?key= query parameter (key prefix)');
13348
+ return;
13349
+ }
13350
+ const record = this.gate.store.getAllRecords().find((k) => k.key.startsWith(apiKeyPrefix));
13351
+ if (!record) {
13352
+ this.sendError(res, 404, 'Key not found');
13353
+ return;
13354
+ }
13355
+ record.allowedCountries = undefined;
13356
+ record.deniedCountries = undefined;
13357
+ this.gate.store.save();
13358
+ if (this.redisSync)
13359
+ this.redisSync.saveKey(record).catch(() => { });
13360
+ this.audit.log('key.geo_cleared', 'admin', `Geo restrictions cleared for ${record.key.slice(0, 10)}...`, {
13361
+ keyMasked: (0, audit_1.maskKeyForAudit)(record.key),
13362
+ });
13363
+ this.sendJson(res, 200, { keyPrefix: record.key.slice(0, 10) + '...', allowedCountries: [], deniedCountries: [] });
13364
+ return;
13365
+ }
13366
+ this.sendError(res, 405, 'Method not allowed. Use GET/POST/DELETE.');
13367
+ }
12807
13368
  /** Calculate percentile from an array of numbers. */
12808
13369
  percentile(values, p) {
12809
13370
  if (values.length === 0)