Files
multi-simadmin/apps/api/src/interface/http/system-routes.ts
T
chick 635527dd26 feat(api): integrate identity, notification scope and connection settings into control plane
- 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.
2026-09-07 00:45:18 +08:00

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)),
);
}