import { createReadStream } from 'node:fs'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { SystemMaintenanceService } from '../../application/system/system-maintenance-service.js'; import type { ConnectionSettingsService } from '../../application/connections/connection-settings-service.js'; import type { FleetHeartbeatCoordinator } from '../../application/connections/fleet-heartbeat.js'; import { ComponentBackupError, type BackupComponentKey, type ComponentBackupService, } from '../../application/system/component-backup-service.js'; export interface SystemRoutesOptions { readonly maintenance: SystemMaintenanceService; readonly componentBackups: ComponentBackupService; readonly connectionSettings: ConnectionSettingsService; /** Optional so a read-only deployment can omit the background beat. */ readonly heartbeat?: FleetHeartbeatCoordinator; } function object(value: unknown): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError('Request body must be an object'); return value as Record; } export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutesOptions): void { const backups = () => options.componentBackups; const wrap = (action: (request: FastifyRequest, reply: FastifyReply) => T | Promise) => async (request: FastifyRequest, reply: FastifyReply) => { try { return await action(request, reply); } catch (error) { if (error instanceof ComponentBackupError) { const status = error.code === 'NOT_FOUND' ? 404 : error.code === 'INCOMPATIBLE' || error.code === 'INTEGRITY_FAILED' ? 422 : 400; return reply .code(status) .type('application/problem+json') .send({ type: 'about:blank', title: status === 404 ? 'Not Found' : status === 422 ? 'Unprocessable Entity' : 'Bad Request', status, code: `COMPONENT_BACKUP_${error.code}`, detail: 'The component backup request could not be completed.', requestId: request.id, }); } if (error instanceof TypeError || error instanceof RangeError || error instanceof Error) { const invalid = error instanceof TypeError || error instanceof RangeError || /invalid|unknown|required/iu.test(error.message); if (!invalid) throw error; return reply.code(400).type('application/problem+json').send({ type: 'about:blank', title: 'Bad Request', status: 400, code: 'MAINTENANCE_VALIDATION_FAILED', detail: 'The system maintenance request is invalid.', requestId: request.id, }); } throw error; } }; app.get( '/api/v1/system/maintenance', wrap(async () => options.maintenance.overview()), ); app.get( '/api/v1/system/connection', wrap(async () => options.connectionSettings.get()), ); app.put( '/api/v1/system/connection', wrap(async (request) => options.connectionSettings.update(request.body)), ); app.post( '/api/v1/system/connection/refresh', wrap(async (request, reply) => { const beat = options.heartbeat; if (!beat) return reply.code(501).type('application/problem+json').send({ type: 'about:blank', title: 'Not Implemented', status: 501, code: 'HEARTBEAT_UNAVAILABLE', detail: 'The device heartbeat is not enabled.', requestId: request.id, }); return beat.runOnce(); }), ); app.put( '/api/v1/system/maintenance/retention', wrap(async (request) => options.maintenance.updateRetention(request.body)), ); app.post( '/api/v1/system/maintenance/cleanup', wrap(async (request) => { const value = object(request.body).components; if (!Array.isArray(value) || value.length === 0) throw new TypeError('components is invalid'); return options.maintenance.cleanup(value as never); }), ); app.post( '/api/v1/system/maintenance/optimize', wrap(async () => options.maintenance.optimize()), ); app.get( '/api/v1/system/maintenance/backups', wrap(async () => ({ items: await options.maintenance.listBackups(), })), ); app.post( '/api/v1/system/maintenance/backups', wrap(async (_request, reply) => reply.code(201).send(await options.maintenance.createBackup())), ); app.get( '/api/v1/system/maintenance/backups/:filename', wrap(async (request, reply) => { const backup = await options.maintenance.backupFile( (request.params as { filename: string }).filename, ); if (!backup) return reply.code(404).type('application/problem+json').send({ type: 'about:blank', title: 'Not Found', status: 404, code: 'MAINTENANCE_BACKUP_NOT_FOUND', detail: 'The requested backup file no longer exists.', requestId: request.id, }); reply .header('content-disposition', `attachment; filename="${backup.filename}"`) .header('content-length', String(backup.sizeBytes)) .type('application/octet-stream'); return createReadStream(backup.path); }), ); app.delete( '/api/v1/system/maintenance/backups/:filename', wrap(async (request) => options.maintenance.deleteBackup((request.params as { filename: string }).filename), ), ); app.get( '/api/v1/system/component-backups/catalog', wrap(async () => ({ items: backups().catalog(), directory: backups().directory() })), ); app.get( '/api/v1/system/component-backups/auto/settings', wrap(async () => backups().autoSettings()), ); app.put( '/api/v1/system/component-backups/auto/settings', { bodyLimit: 8_192 }, wrap(async (request) => backups().updateAutoSettings(request.body)), ); app.post( '/api/v1/system/component-backups/auto/run', wrap(async () => ({ created: await backups().runDueAutoBackups() })), ); app.get( '/api/v1/system/component-backups', wrap(async () => ({ items: await backups().list() })), ); app.post( '/api/v1/system/component-backups', { bodyLimit: 8_192 }, wrap(async (request, reply) => { const body = object(request.body); const created = await backups().create( body.components as readonly BackupComponentKey[], body.note, ); return reply.code(201).send(created); }), ); app.get( '/api/v1/system/component-backups/:filename/preview', wrap(async (request) => backups().preview((request.params as { filename: string }).filename)), ); app.get( '/api/v1/system/component-backups/:filename/download', wrap(async (request, reply) => { const backup = await backups().backupFile((request.params as { filename: string }).filename); if (!backup) return reply.code(404).type('application/problem+json').send({ type: 'about:blank', title: 'Not Found', status: 404, code: 'COMPONENT_BACKUP_NOT_FOUND', detail: 'The requested component backup file no longer exists.', requestId: request.id, }); reply .header('content-disposition', `attachment; filename="${backup.filename}"`) .header('content-length', String(backup.sizeBytes)) .type('application/octet-stream'); return createReadStream(backup.path); }), ); app.post( '/api/v1/system/component-backups/:filename/restore', { bodyLimit: 8_192 }, wrap(async (request) => backups().restore( (request.params as { filename: string }).filename, object(request.body).components as readonly BackupComponentKey[], ), ), ); app.delete( '/api/v1/system/component-backups/:filename', wrap(async (request) => backups().remove((request.params as { filename: string }).filename)), ); }