diff --git a/src/api/server.ts b/src/api/server.ts index 0b13c5f..382cb9d 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,8 +1,5 @@ import Fastify from 'fastify' import { getDatabase } from '../lib/db' -import { hijackAlertsRoutes } from '../routes/hijack-alerts' -import { pdfExportRoutes } from '../routes/pdf-export' -import { aspaAdoptionRoutes } from '../routes/aspa-adoption' import { bgpCommunitiesRoutes } from '../features/bgp-communities/routes' import { irrAuditRoutes } from '../features/irr-audit/routes' import { assetExpandRoutes } from '../features/asset-expand/routes' @@ -15,7 +12,6 @@ import { submarineCablesRoutes } from '../features/submarine-cables/routes' import { globalInfraRoutes } from '../features/global-infra/routes' import { changelogRoutes } from '../features/changelog/routes' import { ribRoutes } from '../features/rib/routes' -import { hijackSubscribeRoutes } from '../features/hijack-subscribe/routes' export async function createApiServer(_port: number = 3102) { const fastify = Fastify({ @@ -24,10 +20,12 @@ export async function createApiServer(_port: number = 3102) { }, }) - // Initialize routes - await fastify.register(hijackAlertsRoutes, { prefix: '/api' }) - await fastify.register(pdfExportRoutes, { prefix: '/api' }) - await fastify.register(aspaAdoptionRoutes, { prefix: '/api' }) + // Initialize routes. hijack-alerts/pdf-export/aspa-adoption/hijack-subscribe + // were removed 2026-07-17: never reachable in production (server/routes/ + // proxy-stubs.js's EXACT_PATHS never proxied their paths here), and the + // old file-backed server/routes/*.js handlers are the only ones actually + // serving these features. See src/features/{aspa-adoption,hijack-alerts, + // pdf-export}/ history if reviving a Postgres-backed version later. await fastify.register(bgpCommunitiesRoutes, { prefix: '/api' }) await fastify.register(irrAuditRoutes, { prefix: '/api' }) await fastify.register(assetExpandRoutes, { prefix: '/api' }) @@ -40,7 +38,6 @@ export async function createApiServer(_port: number = 3102) { await fastify.register(globalInfraRoutes, { prefix: '/api' }) await fastify.register(changelogRoutes) await fastify.register(ribRoutes, { prefix: '/api' }) - await fastify.register(hijackSubscribeRoutes, { prefix: '/api' }) // Health check endpoint fastify.get('/health', async () => { @@ -58,7 +55,7 @@ export async function createApiServer(_port: number = 3102) { return { name: 'PeerCortex API', version: '1.0.0', - features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker', 'bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib', 'hijack-subscribe'], + features: ['bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib'], docs: 'https://peercortex.org/docs', } }) diff --git a/src/features/hijack-subscribe/routes.ts b/src/features/hijack-subscribe/routes.ts deleted file mode 100644 index 7def83a..0000000 --- a/src/features/hijack-subscribe/routes.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import fs from 'fs'; -import path from 'path'; - -// Path to legacy JSON file-based subscriptions (kept for backward compat) -const HIJACK_SUBS_FILE = process.env.HIJACK_SUBS_FILE || '/opt/peercortex-app/data/hijack-subs.json'; - -interface HijackSubscribeBody { - asn?: string | number; - email?: string; -} - -function loadHijackSubs(): any[] { - try { - if (!fs.existsSync(HIJACK_SUBS_FILE)) return []; - return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE, 'utf8')); - } catch { - return []; - } -} - -export async function hijackSubscribeRoutes(fastify: FastifyInstance): Promise { - // OPTIONS preflight - fastify.options('/hijack-subscribe', async (_request: FastifyRequest, reply: FastifyReply) => { - reply.header('Access-Control-Allow-Origin', '*'); - reply.header('Access-Control-Allow-Methods', 'POST,OPTIONS'); - reply.header('Access-Control-Allow-Headers', 'Content-Type'); - return reply.status(204).send(); - }); - - // POST /api/hijack-subscribe - fastify.post<{ Body: HijackSubscribeBody }>( - '/hijack-subscribe', - async (request: FastifyRequest<{ Body: HijackSubscribeBody }>, reply: FastifyReply) => { - reply.header('Access-Control-Allow-Origin', '*'); - - try { - const body = request.body as HijackSubscribeBody; - const asnNum = String(body.asn || '').replace(/[^0-9]/g, ''); - - if (!asnNum) { - return reply.status(400).send({ error: 'asn required' }); - } - - const subs = loadHijackSubs(); - const exists = subs.find((s: any) => s.asn === asnNum); - - if (!exists) { - // Add subscription without live hijack check (avoids circular dependency) - // The scheduler will pick it up on next cycle - subs.push({ - asn: asnNum, - email: body.email || '', - prefixes: [], - subscribed: new Date().toISOString() - }); - fs.mkdirSync(path.dirname(HIJACK_SUBS_FILE), { recursive: true }); - fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); - } - - const entry = exists || subs[subs.length - 1]; - return reply.status(200).send({ - ok: true, - asn: asnNum, - monitoring: true, - prefix_count: entry.prefixes?.length ?? 0 - }); - } catch (e: any) { - return reply.status(500).send({ error: e.message }); - } - } - ); -} diff --git a/src/routes/aspa-adoption.ts b/src/routes/aspa-adoption.ts deleted file mode 100644 index fa5f7f0..0000000 --- a/src/routes/aspa-adoption.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { ASPADatabaseClient } from '../features/aspa-adoption/db-client' -import { AdoptionStats } from '../features/aspa-adoption/types' -import { AdoptionForecaster } from '../features/aspa-adoption/forecaster' - -const dbClient = new ASPADatabaseClient() -const forecaster = new AdoptionForecaster() - -export async function aspaAdoptionRoutes(fastify: FastifyInstance) { - /** - * GET /api/aspa-adoption-stats - * Main adoption statistics with trend and forecast - */ - fastify.get<{ - Querystring: { period?: string; region?: string } - }>('/aspa-adoption-stats', async (request: FastifyRequest<{ Querystring: { period?: string; region?: string } }>, reply: FastifyReply) => { - try { - const period = request.query.period || '30d' - const days = parsePeriod(period) - - const latest = await dbClient.getLatestSnapshot() - if (!latest) { - return reply.code(404).send({ error: 'No ASPA adoption data available yet' }) - } - - const history = await dbClient.getAdoptionHistory(days) - const timeSeriesData = history.reverse().map((h) => ({ - date: h.sampleDate, - coverage: h.coveragePercentage, - })) - - const forecast = forecaster.forecast(timeSeriesData, { - historyDays: days, - forecastMonths: 6, - regressionAlpha: 0.05, - }) - - const stats: AdoptionStats = { - current: { - coverage: latest.coveragePercentage, - trend: forecast.trend, - change24h: calculateChange24h(history), - }, - trend: timeSeriesData.map((p) => ({ - date: p.date.toISOString().split('T')[0], - coveragePercentage: p.coverage, - sampledASNs: latest.totalASNsSampled, - })), - forecast, - regions: latest.regions || [], - topAdopters: latest.topAdopters || [], - } - - return stats - } catch (error) { - console.error('Error fetching ASPA adoption stats:', error) - return reply.code(500).send({ error: 'Failed to fetch adoption statistics' }) - } - }) - - /** - * GET /api/aspa-adoption-stats/regional - * Regional adoption breakdown - */ - fastify.get('/aspa-adoption-stats/regional', async (_request: FastifyRequest, reply: FastifyReply) => { - try { - const latest = await dbClient.getLatestSnapshot() - if (!latest) { - return reply.code(404).send({ error: 'No regional data available' }) - } - - const regions = (latest.regions || []).map((r) => ({ - region: r.region, - coveragePercentage: r.coveragePercentage, - totalASNs: r.totalASNs, - ASNsWithASPA: r.ASNsWithASPA, - })) - - return { regions } - } catch (error) { - console.error('Error fetching regional stats:', error) - return reply.code(500).send({ error: 'Failed to fetch regional statistics' }) - } - }) - - /** - * GET /api/aspa-adoption-stats/ixps - * IXP adoption breakdown - */ - fastify.get<{ - Querystring: { top?: string } - }>('/aspa-adoption-stats/ixps', async (request: FastifyRequest<{ Querystring: { top?: string } }>, reply: FastifyReply) => { - try { - const limit = parseInt(request.query.top || '20', 10) - const latest = await dbClient.getLatestSnapshot() - - if (!latest) { - return reply.code(404).send({ error: 'No IXP data available' }) - } - - // IXP data would be stored in database in production - const ixps = [ - { ixpName: 'DE-CIX Frankfurt', coveragePercentage: 67.5, participants: 850, participantsWithASPA: 573 }, - { ixpName: 'AMS-IX Amsterdam', coveragePercentage: 62.3, participants: 720, participantsWithASPA: 448 }, - { ixpName: 'LINX London', coveragePercentage: 58.9, participants: 580, participantsWithASPA: 342 }, - ].slice(0, limit) - - return { ixps } - } catch (error) { - console.error('Error fetching IXP stats:', error) - return reply.code(500).send({ error: 'Failed to fetch IXP statistics' }) - } - }) - - /** - * GET /api/aspa-adoption-stats/export - * Export adoption data as CSV or JSON - */ - fastify.get<{ - Querystring: { format?: 'csv' | 'json'; period?: string } - }>('/aspa-adoption-stats/export', async (request: FastifyRequest<{ Querystring: { format?: 'csv' | 'json'; period?: string } }>, reply: FastifyReply) => { - try { - const format = request.query.format || 'json' - const period = request.query.period || '30d' - const days = parsePeriod(period) - - const history = await dbClient.getAdoptionHistory(days) - - if (format === 'csv') { - const csv = convertToCSV(history) - return reply - .header('Content-Type', 'text/csv') - .header('Content-Disposition', 'attachment; filename="aspa-adoption.csv"') - .send(csv) - } - - return reply.send({ - exportDate: new Date().toISOString(), - period, - totalRecords: history.length, - data: history, - }) - } catch (error) { - console.error('Error exporting adoption data:', error) - return reply.code(500).send({ error: 'Failed to export data' }) - } - }) -} - -function parsePeriod(period: string): number { - const match = period.match(/^(\d+)([dwmy])$/) - if (!match) return 30 - - const value = parseInt(match[1], 10) - const unit = match[2] - - switch (unit) { - case 'd': - return value - case 'w': - return value * 7 - case 'm': - return value * 30 - case 'y': - return value * 365 - default: - return 30 - } -} - -function calculateChange24h(history: any[]): number { - if (history.length < 2) return 0 - - const today = history[0] - const yesterday = history[1] - - return Math.round((today.coveragePercentage - yesterday.coveragePercentage) * 100) / 100 -} - -function convertToCSV(records: any[]): string { - const headers = ['Date', 'Coverage %', 'ASNs Sampled', 'ASNs with ASPA'] - const rows = records.map((r) => [ - r.sampleDate.toISOString().split('T')[0], - r.coveragePercentage, - r.totalASNsSampled, - r.ASNsWithASPA, - ]) - - const csvContent = [ - headers.join(','), - ...rows.map((row) => row.join(',')), - ].join('\n') - - return csvContent -} diff --git a/src/routes/hijack-alerts.ts b/src/routes/hijack-alerts.ts deleted file mode 100644 index 8289002..0000000 --- a/src/routes/hijack-alerts.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { getDatabase } from '../lib/db' -import { HijackAlertsDatabaseClient } from '../features/hijack-alerts/db-client' -import { WebhookClient } from '../features/hijack-alerts/webhook-client' -import { checkForHijacks } from '../features/hijack-alerts/detector' -import crypto from 'crypto' - -const db = new HijackAlertsDatabaseClient(getDatabase()) -const webhookClient = new WebhookClient() - -function generateSecretKey(): string { - return 'sk_' + crypto.randomBytes(32).toString('hex') -} - -interface RegisterWebhookRequest { - endpoint_url: string - timeout_ms?: number - max_retries?: number -} - -interface CreateHijackRequest { - asn: number - prefix: string -} - -export async function hijackAlertsRoutes(fastify: FastifyInstance): Promise { - // Register webhook subscription - fastify.post<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>( - '/webhooks', - async (request: FastifyRequest<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>, reply: FastifyReply) => { - try { - const asn = parseInt(request.query.asn, 10) - const body = request.body as RegisterWebhookRequest - - if (isNaN(asn)) { - return reply.status(400).send({ error: 'Invalid ASN' }) - } - - if (!body.endpoint_url) { - return reply.status(400).send({ error: 'endpoint_url is required' }) - } - - const secretKey = generateSecretKey() - const webhook = await db.createWebhookSubscription( - { - asn, - endpoint_url: body.endpoint_url, - timeout_ms: body.timeout_ms, - max_retries: body.max_retries, - }, - secretKey - ) - - return reply.status(201).send({ - id: webhook.id, - asn: webhook.asn, - endpoint_url: webhook.endpoint_url, - secret_key: secretKey, - created_at: webhook.created_at, - active: webhook.active, - }) - } catch (error) { - console.error('[Routes] Error registering webhook:', error) - return reply.status(500).send({ error: 'Failed to register webhook' }) - } - } - ) - - // List webhooks for ASN - fastify.get<{ Querystring: { asn: string } }>( - '/webhooks', - async (request: FastifyRequest<{ Querystring: { asn: string } }>, reply: FastifyReply) => { - try { - const asn = parseInt(request.query.asn, 10) - - if (isNaN(asn)) { - return reply.status(400).send({ error: 'Invalid ASN' }) - } - - const webhooks = await db.getWebhooksByAsn(asn) - - return reply.send({ - asn, - webhooks: webhooks.map((w) => ({ - id: w.id, - endpoint_url: w.endpoint_url, - active: w.active, - failure_count: w.failure_count, - last_triggered_at: w.last_triggered_at, - max_retries: w.max_retries, - timeout_ms: w.timeout_ms, - })), - }) - } catch (error) { - console.error('[Routes] Error listing webhooks:', error) - return reply.status(500).send({ error: 'Failed to list webhooks' }) - } - } - ) - - // Delete webhook subscription - fastify.delete<{ Params: { id: string } }>( - '/webhooks/:id', - async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - try { - const webhookId = parseInt(request.params.id, 10) - - if (isNaN(webhookId)) { - return reply.status(400).send({ error: 'Invalid webhook ID' }) - } - - await db.deleteWebhookSubscription(webhookId) - - return reply.send({ deleted: true, id: webhookId }) - } catch (error) { - console.error('[Routes] Error deleting webhook:', error) - return reply.status(500).send({ error: 'Failed to delete webhook' }) - } - } - ) - - // Test webhook delivery - fastify.post<{ Params: { id: string } }>( - '/webhooks/:id/test', - async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - try { - const webhookId = parseInt(request.params.id, 10) - - if (isNaN(webhookId)) { - return reply.status(400).send({ error: 'Invalid webhook ID' }) - } - - const webhook = await db.getWebhookSubscription(webhookId) - - if (!webhook) { - return reply.status(404).send({ error: 'Webhook not found' }) - } - - const testEvent = { - id: 0, - asn: webhook.asn, - prefix: '0.0.0.0/0', - detected_at: new Date(), - expected_asn: webhook.asn, - detected_asns: [webhook.asn], - hijack_type: 'HIJACK' as const, - severity: 'HIGH' as const, - description: 'Test event from PeerCortex', - details: { source: 'api-test', timestamp: new Date().toISOString() }, - resolved: false, - resolved_at: null, - created_at: new Date(), - } - - const result = await webhookClient.sendWebhook( - testEvent, - webhook.endpoint_url, - webhook.secret_key, - webhook.timeout_ms - ) - - return reply.send({ - success: result.success, - status: result.status, - error: result.error ?? null, - response_time_ms: result.response_time_ms, - }) - } catch (error) { - console.error('[Routes] Error testing webhook:', error) - return reply.status(500).send({ error: 'Failed to test webhook' }) - } - } - ) - - // List hijack events for ASN - fastify.get<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>( - '/hijacks', - async (request: FastifyRequest<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>, reply: FastifyReply) => { - try { - const asn = parseInt(request.query.asn, 10) - const limit = parseInt(request.query.limit ?? '50', 10) - const offset = parseInt(request.query.offset ?? '0', 10) - const resolved = request.query.resolved ? request.query.resolved === 'true' : undefined - - if (isNaN(asn)) { - return reply.status(400).send({ error: 'Invalid ASN' }) - } - - const result = await db.getHijacksByAsn(asn, Math.min(limit, 500), offset, resolved) - - return reply.send({ - asn, - total: result.total, - limit, - offset, - events: result.events.map((e) => ({ - id: e.id, - prefix: e.prefix, - hijack_type: e.hijack_type, - severity: e.severity, - detected_at: e.detected_at, - resolved: e.resolved, - resolved_at: e.resolved_at, - description: e.description, - })), - }) - } catch (error) { - console.error('[Routes] Error listing hijacks:', error) - return reply.status(500).send({ error: 'Failed to list hijacks' }) - } - } - ) - - // Manually trigger hijack detection - fastify.post<{ Body: CreateHijackRequest }>( - '/hijacks/detect', - async (request: FastifyRequest<{ Body: CreateHijackRequest }>, reply: FastifyReply) => { - try { - const body = request.body as CreateHijackRequest - - if (!body.asn || !body.prefix) { - return reply.status(400).send({ error: 'asn and prefix are required' }) - } - - const results = await checkForHijacks(`${body.asn}:${body.prefix}`, db) - - if (results[0]?.detected && results[0]?.event) { - const hijack = await db.insertHijackEvent(results[0].event) - return reply.status(201).send({ detected: true, event: hijack }) - } - - return reply.send({ detected: false, reason: results[0]?.reason ?? 'No hijack detected' }) - } catch (error) { - console.error('[Routes] Error detecting hijacks:', error) - return reply.status(500).send({ error: 'Failed to detect hijacks' }) - } - } - ) - - // Resolve hijack - fastify.post<{ Params: { id: string }; Body: { resolution_notes: string } }>( - '/hijacks/:id/resolve', - async (request: FastifyRequest<{ Params: { id: string }; Body: { resolution_notes: string } }>, reply: FastifyReply) => { - try { - const eventId = parseInt(request.params.id, 10) - - if (isNaN(eventId)) { - return reply.status(400).send({ error: 'Invalid event ID' }) - } - - const hijack = await db.resolveHijack(eventId, request.body.resolution_notes) - - return reply.send({ - id: hijack.id, - resolved: hijack.resolved, - resolved_at: hijack.resolved_at, - }) - } catch (error) { - console.error('[Routes] Error resolving hijack:', error) - return reply.status(500).send({ error: 'Failed to resolve hijack' }) - } - } - ) -} diff --git a/src/routes/pdf-export.ts b/src/routes/pdf-export.ts deleted file mode 100644 index 9f2e132..0000000 --- a/src/routes/pdf-export.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { getDatabase } from '../lib/db' -import { PDFExportDatabaseClient } from '../features/pdf-export/db-client' -import { PDFRenderer, renderTemplate } from '../features/pdf-export/renderer' -import { PDFCacheManager } from '../features/pdf-export/cache-manager' -import * as fs from 'fs/promises' -import * as path from 'path' -import type { PDFTemplateData } from '../features/pdf-export/types' - -const db = new PDFExportDatabaseClient(getDatabase()) -const renderer = new PDFRenderer() -const cache = new PDFCacheManager() - -interface ExportPDFQuery { - asn: string - format?: string -} - -async function loadTemplate(format: string): Promise { - const templatePath = path.join( - __dirname, - '../features/pdf-export/templates', - `${format}-template.html` - ) - - try { - return await fs.readFile(templatePath, 'utf-8') - } catch (error) { - throw new Error(`Template not found: ${format}`) - } -} - -async function getNetworkData(asn: number): Promise { - const now = new Date() - - return { - asn, - network_name: `AS${asn} Network`, - generated_at: now.toISOString(), - health_score: { - overall: 78, - aspa: 65, - rpki: 85, - bgp_stability: 80, - peering_health: 75, - }, - aspa: { - adoption_status: 'in_progress', - provider_verification: 45, - readiness_score: 60, - }, - peering: { - ixp_connections: 12, - peer_count: 42, - open_peers: 28, - route_exports: 1250, - }, - threats: { - recent_hijacks: 0, - anomalies_detected: 2, - rpki_invalids: 3, - moas_events: 1, - }, - recommendations: [ - 'Implement RPKI ROV for route validation', - 'Increase ASPA adoption to reduce path spoofing risk', - 'Review BGP community tagging practices', - 'Monitor anomalies for potential routing issues', - 'Expand IXP presence for redundancy', - ], - data_sources: [ - 'RIPE RIS Route Collectors', - 'RouteViews BGP Archive', - 'RPKI Repository Objects', - 'PeeringDB Network Database', - 'WHOIS RDAP Queries', - ], - } -} - -export async function pdfExportRoutes(fastify: FastifyInstance): Promise { - fastify.get<{ Querystring: ExportPDFQuery }>( - '/export/pdf', - async (request: FastifyRequest<{ Querystring: ExportPDFQuery }>, reply: FastifyReply) => { - try { - const asn = parseInt(request.query.asn, 10) - const format = (request.query.format ?? 'report') as string - - if (isNaN(asn)) { - return reply.status(400).send({ error: 'Invalid ASN' }) - } - - if (!['report', 'executive', 'technical'].includes(format)) { - return reply.status(400).send({ error: 'Invalid format' }) - } - - const dateStr = new Date().toISOString().split('T')[0] - const cacheKey = cache.generateKey(asn, format, dateStr) - const cachedEntry = cache.get(cacheKey) - - if (cachedEntry) { - console.log(`[PDF Export] Cache hit for AS${asn} format=${format}`) - return reply - .header('Content-Type', 'application/pdf') - .header('Cache-Control', 'public, max-age=300') - .header('X-Cache-Hit', 'true') - .send(cachedEntry.pdfBuffer) - } - - await renderer.initialize() - - const template = await loadTemplate(format) - const data = await getNetworkData(asn) - const html = renderTemplate(template, data) - - const pdfBuffer = await renderer.renderPDF(html, 30000) - - cache.set(cacheKey, pdfBuffer) - - await db.recordPDFGeneration( - `AS${asn}`, - format, - require('crypto').createHash('sha256').update(pdfBuffer).digest('hex'), - pdfBuffer.length, - { format, generated_at: new Date().toISOString() } - ) - - return reply - .header('Content-Type', 'application/pdf') - .header('Cache-Control', 'public, max-age=300') - .header('X-Cache-Hit', 'false') - .send(pdfBuffer) - } catch (error) { - console.error('[PDF Export] Error generating PDF:', error) - return reply.status(500).send({ - error: 'Failed to generate PDF', - message: error instanceof Error ? error.message : 'Unknown error', - }) - } - } - ) - - fastify.get('/export/pdf/stats', async (request: FastifyRequest, reply: FastifyReply) => { - try { - const stats = await db.getPDFStats() - const cacheStats = cache.getStats() - - return reply.send({ - database: stats, - cache: cacheStats, - ttl_ms: 5 * 60 * 1000, - }) - } catch (error) { - console.error('[PDF Export] Error fetching stats:', error) - return reply.status(500).send({ error: 'Failed to fetch stats' }) - } - }) -}