- Wire identity, metrics and update services into the assembled API. - Add connection authorization modes and scoped console authorization. - Preserve notification group IDs for filtered delivery. - Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
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<string, unknown> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
throw new TypeError('Request body must be an object');
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutesOptions): void {
|
|
const backups = () => options.componentBackups;
|
|
const wrap =
|
|
<T>(action: (request: FastifyRequest, reply: FastifyReply) => T | Promise<T>) =>
|
|
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)),
|
|
);
|
|
}
|