paygate-mcp 9.0.0 → 9.2.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 +32 -4
- package/dist/audit.d.ts +1 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/circuit-breaker.d.ts +64 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +114 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/openapi.js +130 -0
- package/dist/openapi.js.map +1 -1
- package/dist/portal.d.ts.map +1 -1
- package/dist/portal.js +284 -0
- package/dist/portal.js.map +1 -1
- package/dist/response-cache.d.ts +74 -0
- package/dist/response-cache.d.ts.map +1 -0
- package/dist/response-cache.js +139 -0
- package/dist/response-cache.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +355 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -98,6 +98,8 @@ const expiry_scanner_1 = require("./expiry-scanner");
|
|
|
98
98
|
const key_templates_1 = require("./key-templates");
|
|
99
99
|
const stripe_checkout_1 = require("./stripe-checkout");
|
|
100
100
|
const backup_1 = require("./backup");
|
|
101
|
+
const response_cache_1 = require("./response-cache");
|
|
102
|
+
const circuit_breaker_1 = require("./circuit-breaker");
|
|
101
103
|
/** Max request body size: 1MB */
|
|
102
104
|
const MAX_BODY_SIZE = 1_048_576;
|
|
103
105
|
/**
|
|
@@ -425,6 +427,10 @@ class PayGateServer {
|
|
|
425
427
|
inflight = 0;
|
|
426
428
|
/** Config file path for hot reload (null if not using config file) */
|
|
427
429
|
configPath = null;
|
|
430
|
+
/** Response cache for tool calls (null if caching disabled) */
|
|
431
|
+
responseCache = null;
|
|
432
|
+
/** Circuit breaker for backend failure detection (null if disabled) */
|
|
433
|
+
circuitBreaker = null;
|
|
428
434
|
/** The active request handler — either proxy or router */
|
|
429
435
|
get handler() {
|
|
430
436
|
return (this.router || this.proxy);
|
|
@@ -632,6 +638,22 @@ class PayGateServer {
|
|
|
632
638
|
collectEmail: stripeConfig.collectEmail,
|
|
633
639
|
});
|
|
634
640
|
}
|
|
641
|
+
// Response cache for tool calls (if configured)
|
|
642
|
+
const cacheTtl = this.config.cacheTtlSeconds ?? 0;
|
|
643
|
+
if (cacheTtl > 0 || Object.values(this.config.toolPricing).some(t => t.cacheTtlSeconds > 0)) {
|
|
644
|
+
this.responseCache = new response_cache_1.ResponseCache(this.config.maxCacheEntries ?? 10_000);
|
|
645
|
+
this.metrics.registerGauge('paygate_cache_entries', 'Number of cached tool call responses', () => {
|
|
646
|
+
return this.responseCache?.stats().entries ?? 0;
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
// Circuit breaker for backend failure detection (if configured)
|
|
650
|
+
const cbThreshold = this.config.circuitBreakerThreshold ?? 0;
|
|
651
|
+
if (cbThreshold > 0) {
|
|
652
|
+
this.circuitBreaker = new circuit_breaker_1.CircuitBreaker({
|
|
653
|
+
threshold: cbThreshold,
|
|
654
|
+
cooldownSeconds: this.config.circuitBreakerCooldownSeconds ?? 30,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
635
657
|
// Backup manager for full state snapshots
|
|
636
658
|
this.backup = new backup_1.BackupManager(this.createBackupProvider());
|
|
637
659
|
}
|
|
@@ -834,6 +856,12 @@ class PayGateServer {
|
|
|
834
856
|
features.push('trusted-proxies');
|
|
835
857
|
if (this.config.cors && this.config.cors.origin !== '*')
|
|
836
858
|
features.push('cors-restricted');
|
|
859
|
+
if (this.responseCache)
|
|
860
|
+
features.push('response-cache');
|
|
861
|
+
if (this.circuitBreaker)
|
|
862
|
+
features.push('circuit-breaker');
|
|
863
|
+
if (this.config.toolTimeoutMs)
|
|
864
|
+
features.push(`timeout(${this.config.toolTimeoutMs}ms)`);
|
|
837
865
|
const transport = this.router ? 'multi-server' : (this.proxy instanceof http_proxy_1.HttpMcpProxy ? 'http' : 'stdio');
|
|
838
866
|
const keys = this.gate.store.getKeyCount?.() ?? 0;
|
|
839
867
|
this.logger.info(`Listening on port ${port}`, {
|
|
@@ -883,7 +911,7 @@ class PayGateServer {
|
|
|
883
911
|
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
884
912
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, HEAD, OPTIONS');
|
|
885
913
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Admin-Key, Mcp-Session-Id, Authorization, X-Request-Id');
|
|
886
|
-
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');
|
|
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');
|
|
887
915
|
if (corsConfig?.credentials) {
|
|
888
916
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
889
917
|
}
|
|
@@ -1087,6 +1115,12 @@ class PayGateServer {
|
|
|
1087
1115
|
return this.handleKeyImport(req, res);
|
|
1088
1116
|
case '/balance':
|
|
1089
1117
|
return this.handleBalance(req, res);
|
|
1118
|
+
case '/balance/history':
|
|
1119
|
+
return this.handleBalanceHistory(req, res);
|
|
1120
|
+
case '/balance/alerts':
|
|
1121
|
+
return this.handleBalanceAlerts(req, res);
|
|
1122
|
+
case '/portal/rotate':
|
|
1123
|
+
return this.handlePortalRotate(req, res);
|
|
1090
1124
|
case '/limits':
|
|
1091
1125
|
return this.handleLimits(req, res);
|
|
1092
1126
|
case '/usage':
|
|
@@ -1101,6 +1135,10 @@ class PayGateServer {
|
|
|
1101
1135
|
return this.handleAdminBackup(req, res);
|
|
1102
1136
|
case '/admin/restore':
|
|
1103
1137
|
return this.handleAdminRestore(req, res);
|
|
1138
|
+
case '/admin/cache':
|
|
1139
|
+
return this.handleAdminCache(req, res);
|
|
1140
|
+
case '/admin/circuit':
|
|
1141
|
+
return this.handleAdminCircuit(req, res);
|
|
1104
1142
|
case '/dashboard':
|
|
1105
1143
|
return this.handleDashboard(req, res);
|
|
1106
1144
|
case '/audit':
|
|
@@ -1797,7 +1835,81 @@ class PayGateServer {
|
|
|
1797
1835
|
pluginRequest = await this.plugins.executeBeforeToolCall(pluginCtx);
|
|
1798
1836
|
}
|
|
1799
1837
|
const toolCallStartTime = Date.now();
|
|
1800
|
-
let
|
|
1838
|
+
let cacheHit = false;
|
|
1839
|
+
let circuitBroken = false;
|
|
1840
|
+
// ─── Response Cache check (before forwarding to backend) ────────
|
|
1841
|
+
// response is guaranteed assigned before use: either via cache hit, circuit breaker, or handler
|
|
1842
|
+
let response = null;
|
|
1843
|
+
if (pluginRequest.method === 'tools/call' && this.responseCache) {
|
|
1844
|
+
const toolName = pluginRequest.params?.name || '';
|
|
1845
|
+
const toolArgs = pluginRequest.params?.arguments;
|
|
1846
|
+
const toolPricingConfig = this.config.toolPricing[toolName];
|
|
1847
|
+
const ttl = toolPricingConfig?.cacheTtlSeconds ?? this.config.cacheTtlSeconds ?? 0;
|
|
1848
|
+
if (ttl > 0) {
|
|
1849
|
+
const cached = this.responseCache.get(toolName, toolArgs);
|
|
1850
|
+
if (cached !== undefined) {
|
|
1851
|
+
cacheHit = true;
|
|
1852
|
+
response = cached;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
// ─── Circuit Breaker check (before forwarding to backend) ────────
|
|
1857
|
+
if (!cacheHit && pluginRequest.method === 'tools/call' && this.circuitBreaker) {
|
|
1858
|
+
if (!this.circuitBreaker.allowRequest()) {
|
|
1859
|
+
const cbStatus = this.circuitBreaker.status();
|
|
1860
|
+
circuitBroken = true;
|
|
1861
|
+
response = {
|
|
1862
|
+
jsonrpc: '2.0',
|
|
1863
|
+
id: pluginRequest.id,
|
|
1864
|
+
error: {
|
|
1865
|
+
code: -32003,
|
|
1866
|
+
message: `circuit_breaker_open: Backend unavailable (${cbStatus.consecutiveFailures} consecutive failures). Retry after cooldown.`,
|
|
1867
|
+
},
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
// ─── Forward to backend (with optional timeout) ────────
|
|
1872
|
+
if (!cacheHit && !circuitBroken) {
|
|
1873
|
+
const toolName = pluginRequest.method === 'tools/call'
|
|
1874
|
+
? (pluginRequest.params?.name || '') : '';
|
|
1875
|
+
const toolPricingConfig = toolName ? this.config.toolPricing[toolName] : undefined;
|
|
1876
|
+
const timeoutMs = toolPricingConfig?.timeoutMs ?? this.config.toolTimeoutMs ?? 0;
|
|
1877
|
+
if (timeoutMs > 0 && pluginRequest.method === 'tools/call') {
|
|
1878
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1879
|
+
setTimeout(() => {
|
|
1880
|
+
resolve({
|
|
1881
|
+
jsonrpc: '2.0',
|
|
1882
|
+
id: pluginRequest.id,
|
|
1883
|
+
error: { code: -32004, message: `tool_timeout: ${toolName} exceeded ${timeoutMs}ms timeout` },
|
|
1884
|
+
});
|
|
1885
|
+
}, timeoutMs);
|
|
1886
|
+
});
|
|
1887
|
+
response = await Promise.race([
|
|
1888
|
+
this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools),
|
|
1889
|
+
timeoutPromise,
|
|
1890
|
+
]);
|
|
1891
|
+
}
|
|
1892
|
+
else {
|
|
1893
|
+
response = await this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools);
|
|
1894
|
+
}
|
|
1895
|
+
// Record circuit breaker outcome
|
|
1896
|
+
if (pluginRequest.method === 'tools/call' && this.circuitBreaker) {
|
|
1897
|
+
if (response.error && (response.error.code === -32603 || response.error.code === -32004)) {
|
|
1898
|
+
this.circuitBreaker.recordFailure();
|
|
1899
|
+
}
|
|
1900
|
+
else if (!response.error || response.error.code === -32402 || response.error.code === -32001) {
|
|
1901
|
+
this.circuitBreaker.recordSuccess();
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
// Store successful responses in cache
|
|
1905
|
+
if (pluginRequest.method === 'tools/call' && this.responseCache && !response.error) {
|
|
1906
|
+
const cacheTtl = toolPricingConfig?.cacheTtlSeconds ?? this.config.cacheTtlSeconds ?? 0;
|
|
1907
|
+
if (cacheTtl > 0) {
|
|
1908
|
+
const toolArgs = pluginRequest.params?.arguments;
|
|
1909
|
+
this.responseCache.set(toolName, toolArgs, response, cacheTtl);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1801
1913
|
// Plugin: afterToolCall — let plugins modify the response
|
|
1802
1914
|
if (this.plugins.count > 0 && request.method === 'tools/call') {
|
|
1803
1915
|
const toolName = request.params?.name || '';
|
|
@@ -1910,6 +2022,10 @@ class PayGateServer {
|
|
|
1910
2022
|
}
|
|
1911
2023
|
// Build rate limit + credits headers for tools/call responses
|
|
1912
2024
|
const rateLimitHeaders = this.buildRateLimitHeaders(apiKey, request);
|
|
2025
|
+
// Add X-Cache header for tool calls when caching is active
|
|
2026
|
+
if (request.method === 'tools/call' && this.responseCache) {
|
|
2027
|
+
rateLimitHeaders['X-Cache'] = cacheHit ? 'HIT' : 'MISS';
|
|
2028
|
+
}
|
|
1913
2029
|
// Check if client accepts SSE
|
|
1914
2030
|
const accept = req.headers['accept'] || '';
|
|
1915
2031
|
const wantsSse = accept.includes('text/event-stream');
|
|
@@ -2135,6 +2251,9 @@ class PayGateServer {
|
|
|
2135
2251
|
mcp: 'POST /mcp — JSON-RPC (MCP transport). Send X-API-Key header.',
|
|
2136
2252
|
info: 'GET /info — Server capabilities, features, pricing summary (public)',
|
|
2137
2253
|
balance: 'GET /balance — Check own credits (requires X-API-Key)',
|
|
2254
|
+
balanceHistory: 'GET /balance/history — Credit mutation history (requires X-API-Key)',
|
|
2255
|
+
balanceAlerts: 'GET/POST /balance/alerts — Self-service low-credit alerts (requires X-API-Key)',
|
|
2256
|
+
portalRotate: 'POST /portal/rotate — Self-service key rotation (requires X-API-Key)',
|
|
2138
2257
|
portal: 'GET /portal — Self-service API key portal (browser UI, requires X-API-Key)',
|
|
2139
2258
|
dashboard: 'GET /dashboard — Admin web dashboard (browser UI)',
|
|
2140
2259
|
ready: 'GET /ready — Readiness probe for k8s (returns 503 when draining/maintenance)',
|
|
@@ -2292,6 +2411,8 @@ class PayGateServer {
|
|
|
2292
2411
|
stripePackages: 'GET /stripe/packages — List available credit packages (public)',
|
|
2293
2412
|
adminBackup: 'GET /admin/backup — Full state snapshot for disaster recovery (requires X-Admin-Key, admin)',
|
|
2294
2413
|
adminRestore: 'POST /admin/restore — Restore state from backup snapshot (requires X-Admin-Key, admin)',
|
|
2414
|
+
adminCache: 'GET /admin/cache — Response cache stats; DELETE /admin/cache?tool= — Clear cache (requires X-Admin-Key)',
|
|
2415
|
+
adminCircuit: 'GET /admin/circuit — Circuit breaker status; POST /admin/circuit — Reset circuit breaker (requires X-Admin-Key)',
|
|
2295
2416
|
...(this.oauth ? {
|
|
2296
2417
|
oauthMetadata: 'GET /.well-known/oauth-authorization-server — OAuth 2.1 server metadata',
|
|
2297
2418
|
oauthRegister: 'POST /oauth/register — Register OAuth client',
|
|
@@ -4460,6 +4581,184 @@ class PayGateServer {
|
|
|
4460
4581
|
},
|
|
4461
4582
|
});
|
|
4462
4583
|
}
|
|
4584
|
+
// ─── /balance/history — Self-service credit history ──────────────────────
|
|
4585
|
+
handleBalanceHistory(req, res) {
|
|
4586
|
+
if (req.method !== 'GET') {
|
|
4587
|
+
this.sendError(res, 405, 'Method not allowed');
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
const apiKey = req.headers['x-api-key'] || null;
|
|
4591
|
+
if (!apiKey) {
|
|
4592
|
+
this.sendError(res, 401, 'Missing X-API-Key header');
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
const record = this.gate.store.getKey(apiKey);
|
|
4596
|
+
if (!record || !record.active) {
|
|
4597
|
+
this.sendError(res, 404, 'Invalid or inactive API key');
|
|
4598
|
+
return;
|
|
4599
|
+
}
|
|
4600
|
+
// Parse query params
|
|
4601
|
+
const url = new URL(req.url || '/', `http://localhost`);
|
|
4602
|
+
const type = url.searchParams.get('type') || undefined;
|
|
4603
|
+
const since = url.searchParams.get('since') || undefined;
|
|
4604
|
+
const limitStr = url.searchParams.get('limit');
|
|
4605
|
+
const limit = limitStr ? Math.min(200, Math.max(1, parseInt(limitStr, 10) || 50)) : 50;
|
|
4606
|
+
const history = this.creditLedger.getHistory(apiKey, { type, limit, since });
|
|
4607
|
+
const velocity = this.creditLedger.getSpendingVelocity(apiKey, record.credits);
|
|
4608
|
+
this.sendJson(res, 200, {
|
|
4609
|
+
key: (0, audit_1.maskKeyForAudit)(apiKey),
|
|
4610
|
+
entries: history,
|
|
4611
|
+
total: this.creditLedger.count(apiKey),
|
|
4612
|
+
velocity,
|
|
4613
|
+
});
|
|
4614
|
+
}
|
|
4615
|
+
// ─── /balance/alerts — Self-service usage alert configuration ──────────
|
|
4616
|
+
selfServiceAlerts = new Map();
|
|
4617
|
+
async handleBalanceAlerts(req, res) {
|
|
4618
|
+
const apiKey = req.headers['x-api-key'] || null;
|
|
4619
|
+
if (!apiKey) {
|
|
4620
|
+
this.sendError(res, 401, 'Missing X-API-Key header');
|
|
4621
|
+
return;
|
|
4622
|
+
}
|
|
4623
|
+
const record = this.gate.store.getKey(apiKey);
|
|
4624
|
+
if (!record || !record.active) {
|
|
4625
|
+
this.sendError(res, 404, 'Invalid or inactive API key');
|
|
4626
|
+
return;
|
|
4627
|
+
}
|
|
4628
|
+
if (req.method === 'GET') {
|
|
4629
|
+
const alert = this.selfServiceAlerts.get(apiKey);
|
|
4630
|
+
this.sendJson(res, 200, {
|
|
4631
|
+
configured: !!alert,
|
|
4632
|
+
alert: alert || null,
|
|
4633
|
+
currentCredits: record.credits,
|
|
4634
|
+
});
|
|
4635
|
+
return;
|
|
4636
|
+
}
|
|
4637
|
+
if (req.method === 'POST') {
|
|
4638
|
+
const body = await this.readBody(req);
|
|
4639
|
+
let params;
|
|
4640
|
+
try {
|
|
4641
|
+
params = safeJsonParse(body);
|
|
4642
|
+
}
|
|
4643
|
+
catch {
|
|
4644
|
+
this.sendError(res, 400, 'Invalid JSON');
|
|
4645
|
+
return;
|
|
4646
|
+
}
|
|
4647
|
+
// Validate threshold
|
|
4648
|
+
const threshold = params.lowCreditThreshold;
|
|
4649
|
+
if (threshold !== undefined && (typeof threshold !== 'number' || threshold < 0 || threshold > 1_000_000)) {
|
|
4650
|
+
this.sendError(res, 400, 'lowCreditThreshold must be 0-1000000');
|
|
4651
|
+
return;
|
|
4652
|
+
}
|
|
4653
|
+
// Validate webhook URL if provided (optional — alerts can work without webhook via portal polling)
|
|
4654
|
+
if (params.webhookUrl !== undefined && params.webhookUrl !== null) {
|
|
4655
|
+
if (typeof params.webhookUrl !== 'string' || params.webhookUrl.length > 500) {
|
|
4656
|
+
this.sendError(res, 400, 'webhookUrl must be a string under 500 chars');
|
|
4657
|
+
return;
|
|
4658
|
+
}
|
|
4659
|
+
// Basic URL validation — must be https
|
|
4660
|
+
if (params.webhookUrl && !params.webhookUrl.startsWith('https://')) {
|
|
4661
|
+
this.sendError(res, 400, 'webhookUrl must use HTTPS');
|
|
4662
|
+
return;
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
// Handle disable
|
|
4666
|
+
if (params.enabled === false) {
|
|
4667
|
+
this.selfServiceAlerts.delete(apiKey);
|
|
4668
|
+
this.sendJson(res, 200, { configured: false, message: 'Alert disabled' });
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
4671
|
+
const alert = {
|
|
4672
|
+
lowCreditThreshold: threshold ?? 10,
|
|
4673
|
+
webhookUrl: params.webhookUrl || null,
|
|
4674
|
+
enabled: true,
|
|
4675
|
+
createdAt: new Date().toISOString(),
|
|
4676
|
+
lastTriggeredAt: null,
|
|
4677
|
+
};
|
|
4678
|
+
// Cap total alerts to prevent memory abuse
|
|
4679
|
+
if (this.selfServiceAlerts.size >= 10_000 && !this.selfServiceAlerts.has(apiKey)) {
|
|
4680
|
+
this.sendError(res, 429, 'Too many alert configurations');
|
|
4681
|
+
return;
|
|
4682
|
+
}
|
|
4683
|
+
this.selfServiceAlerts.set(apiKey, alert);
|
|
4684
|
+
this.sendJson(res, 200, { configured: true, alert });
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
if (req.method === 'DELETE') {
|
|
4688
|
+
this.selfServiceAlerts.delete(apiKey);
|
|
4689
|
+
this.sendJson(res, 200, { configured: false, message: 'Alert removed' });
|
|
4690
|
+
return;
|
|
4691
|
+
}
|
|
4692
|
+
this.sendError(res, 405, 'Method not allowed');
|
|
4693
|
+
}
|
|
4694
|
+
// ─── /portal/rotate — Self-service key rotation ─────────────────────────
|
|
4695
|
+
async handlePortalRotate(req, res) {
|
|
4696
|
+
if (req.method !== 'POST') {
|
|
4697
|
+
this.sendError(res, 405, 'Method not allowed');
|
|
4698
|
+
return;
|
|
4699
|
+
}
|
|
4700
|
+
const apiKey = req.headers['x-api-key'] || null;
|
|
4701
|
+
if (!apiKey) {
|
|
4702
|
+
this.sendError(res, 401, 'Missing X-API-Key header');
|
|
4703
|
+
return;
|
|
4704
|
+
}
|
|
4705
|
+
const record = this.gate.store.getKey(apiKey);
|
|
4706
|
+
if (!record || !record.active) {
|
|
4707
|
+
this.sendError(res, 404, 'Invalid or inactive API key');
|
|
4708
|
+
return;
|
|
4709
|
+
}
|
|
4710
|
+
// Rate limit rotation: once per 5 minutes per key (track by name to survive rotation)
|
|
4711
|
+
const now = Date.now();
|
|
4712
|
+
const rateKey = `rotate:${record.name || apiKey}`;
|
|
4713
|
+
const lastRotation = this.selfServiceRotationTimestamps.get(rateKey);
|
|
4714
|
+
if (lastRotation && now - lastRotation < 300_000) {
|
|
4715
|
+
const waitSec = Math.ceil((300_000 - (now - lastRotation)) / 1000);
|
|
4716
|
+
this.sendError(res, 429, `Key rotation rate limited. Try again in ${waitSec}s`);
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4719
|
+
const rotated = this.gate.store.rotateKey(apiKey);
|
|
4720
|
+
if (!rotated) {
|
|
4721
|
+
this.sendError(res, 500, 'Key rotation failed');
|
|
4722
|
+
return;
|
|
4723
|
+
}
|
|
4724
|
+
// Track rotation timestamp for rate limiting (by name so it persists across rotations)
|
|
4725
|
+
this.selfServiceRotationTimestamps.set(rateKey, now);
|
|
4726
|
+
// Clean up old timestamps (> 10 min) periodically
|
|
4727
|
+
if (this.selfServiceRotationTimestamps.size > 10_000) {
|
|
4728
|
+
for (const [k, ts] of this.selfServiceRotationTimestamps) {
|
|
4729
|
+
if (now - ts > 600_000)
|
|
4730
|
+
this.selfServiceRotationTimestamps.delete(k);
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
// Sync to Redis if applicable
|
|
4734
|
+
if (this.redisSync) {
|
|
4735
|
+
this.redisSync.saveKey(rotated).catch(() => { });
|
|
4736
|
+
this.redisSync.publishEvent({ type: 'key_created', key: rotated.key }).catch(() => { });
|
|
4737
|
+
}
|
|
4738
|
+
this.audit.log('key.rotated', (0, audit_1.maskKeyForAudit)(apiKey), `Self-service key rotation`, {
|
|
4739
|
+
oldKeyMasked: (0, audit_1.maskKeyForAudit)(apiKey),
|
|
4740
|
+
newKeyMasked: (0, audit_1.maskKeyForAudit)(rotated.key),
|
|
4741
|
+
source: 'portal',
|
|
4742
|
+
});
|
|
4743
|
+
this.emitWebhookAdmin('key.rotated', (0, audit_1.maskKeyForAudit)(apiKey), {
|
|
4744
|
+
oldKeyMasked: (0, audit_1.maskKeyForAudit)(apiKey),
|
|
4745
|
+
newKeyMasked: (0, audit_1.maskKeyForAudit)(rotated.key),
|
|
4746
|
+
source: 'portal',
|
|
4747
|
+
});
|
|
4748
|
+
// Migrate self-service alerts to new key
|
|
4749
|
+
const alertConfig = this.selfServiceAlerts.get(apiKey);
|
|
4750
|
+
if (alertConfig) {
|
|
4751
|
+
this.selfServiceAlerts.delete(apiKey);
|
|
4752
|
+
this.selfServiceAlerts.set(rotated.key, alertConfig);
|
|
4753
|
+
}
|
|
4754
|
+
this.sendJson(res, 200, {
|
|
4755
|
+
message: 'Key rotated successfully',
|
|
4756
|
+
newKey: rotated.key,
|
|
4757
|
+
name: rotated.name,
|
|
4758
|
+
credits: rotated.credits,
|
|
4759
|
+
});
|
|
4760
|
+
}
|
|
4761
|
+
selfServiceRotationTimestamps = new Map();
|
|
4463
4762
|
// ─── /limits — Set spending limit ────────────────────────────────────────
|
|
4464
4763
|
async handleLimits(req, res) {
|
|
4465
4764
|
if (req.method !== 'POST') {
|
|
@@ -4709,6 +5008,60 @@ class PayGateServer {
|
|
|
4709
5008
|
this.audit.log('admin.backup_restored', 'admin', `State restored from backup (mode: ${mode})`, { mode, results: result.results, warnings: result.warnings });
|
|
4710
5009
|
this.sendJson(res, result.success ? 200 : 207, result);
|
|
4711
5010
|
}
|
|
5011
|
+
// ─── /admin/cache — Response cache management ─────────────────────────────
|
|
5012
|
+
handleAdminCache(req, res) {
|
|
5013
|
+
if (req.method === 'GET') {
|
|
5014
|
+
if (!this.checkAdmin(req, res, 'viewer'))
|
|
5015
|
+
return;
|
|
5016
|
+
if (!this.responseCache) {
|
|
5017
|
+
this.sendJson(res, 200, { enabled: false, message: 'Response caching is not enabled' });
|
|
5018
|
+
return;
|
|
5019
|
+
}
|
|
5020
|
+
this.sendJson(res, 200, { enabled: true, ...this.responseCache.stats() });
|
|
5021
|
+
return;
|
|
5022
|
+
}
|
|
5023
|
+
if (req.method === 'DELETE') {
|
|
5024
|
+
if (!this.checkAdmin(req, res, 'admin'))
|
|
5025
|
+
return;
|
|
5026
|
+
if (!this.responseCache) {
|
|
5027
|
+
this.sendJson(res, 200, { enabled: false, cleared: 0 });
|
|
5028
|
+
return;
|
|
5029
|
+
}
|
|
5030
|
+
const url = new URL(req.url || '/', `http://localhost`);
|
|
5031
|
+
const tool = url.searchParams.get('tool') || undefined;
|
|
5032
|
+
const cleared = this.responseCache.clear(tool);
|
|
5033
|
+
this.audit.log('admin.cache_cleared', 'admin', `Cache cleared${tool ? ` for tool: ${tool}` : ''}`, { tool, cleared });
|
|
5034
|
+
this.sendJson(res, 200, { cleared, tool: tool || null });
|
|
5035
|
+
return;
|
|
5036
|
+
}
|
|
5037
|
+
this.sendError(res, 405, 'Method not allowed. Use GET or DELETE.');
|
|
5038
|
+
}
|
|
5039
|
+
// ─── /admin/circuit — Circuit breaker status and management ───────────────
|
|
5040
|
+
handleAdminCircuit(req, res) {
|
|
5041
|
+
if (req.method === 'GET') {
|
|
5042
|
+
if (!this.checkAdmin(req, res, 'viewer'))
|
|
5043
|
+
return;
|
|
5044
|
+
if (!this.circuitBreaker) {
|
|
5045
|
+
this.sendJson(res, 200, { enabled: false, message: 'Circuit breaker is not enabled' });
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
5048
|
+
this.sendJson(res, 200, { enabled: true, ...this.circuitBreaker.status() });
|
|
5049
|
+
return;
|
|
5050
|
+
}
|
|
5051
|
+
if (req.method === 'POST') {
|
|
5052
|
+
if (!this.checkAdmin(req, res, 'admin'))
|
|
5053
|
+
return;
|
|
5054
|
+
if (!this.circuitBreaker) {
|
|
5055
|
+
this.sendJson(res, 200, { enabled: false, message: 'Circuit breaker is not enabled' });
|
|
5056
|
+
return;
|
|
5057
|
+
}
|
|
5058
|
+
this.circuitBreaker.reset();
|
|
5059
|
+
this.audit.log('admin.circuit_reset', 'admin', 'Circuit breaker manually reset to closed state');
|
|
5060
|
+
this.sendJson(res, 200, { reset: true, ...this.circuitBreaker.status() });
|
|
5061
|
+
return;
|
|
5062
|
+
}
|
|
5063
|
+
this.sendError(res, 405, 'Method not allowed. Use GET or POST.');
|
|
5064
|
+
}
|
|
4712
5065
|
// ─── /dashboard — Admin web dashboard ─────────────────────────────────────
|
|
4713
5066
|
handleDashboard(_req, res) {
|
|
4714
5067
|
// Dashboard is public HTML — auth is done client-side via admin key prompt.
|