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/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 response = await this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools);
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.