feat(api): absorb the Hub control plane into the local instance model

Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f11877f13e
commit f9186bd851
97 changed files with 18646 additions and 104 deletions
@@ -113,15 +113,24 @@ export function registerAutomationRoutes(
app.post('/api/v1/automation/cron/preview', async (request, reply) =>
action(request, reply, () => {
const body = object(request.body);
if (Object.keys(body).some((key) => key !== 'cronExpression' && key !== 'count'))
if (
Object.keys(body).some(
(key) => key !== 'cronExpression' && key !== 'trigger' && key !== 'count',
)
)
throw new TypeError('Unknown preview field');
if (typeof body.cronExpression !== 'string')
throw new TypeError('cronExpression is required');
if (body.cronExpression === undefined && body.trigger === undefined)
throw new TypeError('trigger or cronExpression is required');
if (body.cronExpression !== undefined && body.trigger !== undefined)
throw new TypeError('Provide only one of trigger and cronExpression');
const count = body.count === undefined ? 5 : body.count;
if (!Number.isSafeInteger(count)) throw new TypeError('count is invalid');
return {
timezone: AUTOMATION_TIMEZONE,
occurrences: options.service.preview(body.cronExpression, count as number),
occurrences: options.service.preview(
body.trigger === undefined ? (body.cronExpression as string) : body.trigger,
count as number,
),
};
}),
);
@@ -0,0 +1,174 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { buildApp } from '../../app.js';
import { CentralNotificationService } from '../../application/notifications/central-notification-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerCentralNotificationRoutes } from './central-notification-routes.js';
class MemorySecrets implements SecretStore {
readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string }, value: string) {
const reference = `memory://${key.instanceId}/${key.purpose}`;
this.values.set(reference, value);
return reference;
}
async get(reference: string) {
return this.values.get(reference);
}
async delete() {
return false;
}
}
const databases: Database.Database[] = [];
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
function app() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
databases.push(db);
const notifications = new CentralNotificationService(db, {
store: new MemorySecrets(),
now: () => new Date('2026-09-03T09:00:00.000Z'),
deliver: async () => ({ ok: true }),
});
const server = buildApp({
registerRoutes: (scope) => registerCentralNotificationRoutes(scope, { notifications }),
});
return { db, notifications, server };
}
describe('central notification routes', () => {
it('creates and lists redacted channels through native control-plane routes', async () => {
const { server } = app();
const created = await server.inject({
method: 'POST',
url: '/api/v1/notifications/channels',
payload: {
name: 'Bark',
type: 'bark',
config: { server_url: 'https://api.day.app', device_key: 'opaque-device-key' },
},
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ name: 'Bark', type: 'bark', hasSecret: true });
expect(created.body).not.toContain('opaque-device-key');
const list = await server.inject('/api/v1/notifications/channels');
expect(list.statusCode).toBe(200);
expect(list.json().items).toHaveLength(1);
expect(list.body).not.toContain('opaque-device-key');
});
it('creates, updates, and lists rules through native control-plane routes', async () => {
const { server } = app();
const channel = (
await server.inject({
method: 'POST',
url: '/api/v1/notifications/channels',
payload: { name: 'Webhook', type: 'webhook', config: { url: 'https://hooks.invalid' } },
})
).json();
const created = await server.inject({
method: 'POST',
url: '/api/v1/notifications/rules',
payload: {
name: '短信转发',
eventType: 'sms',
scope: { mode: 'all' },
channelIds: [channel.id],
rateLimit: { enabled: true, maxMessages: 5, windowSeconds: 300 },
quietHours: [{ start: '22:30', end: '07:15' }],
},
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({
rateLimit: { enabled: true, maxMessages: 5, windowSeconds: 300 },
quietHours: [{ start: '22:30', end: '07:15' }],
});
const id = created.json().id;
const updated = await server.inject({
method: 'PATCH',
url: `/api/v1/notifications/rules/${id}`,
payload: { enabled: false },
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({
enabled: false,
channels: [{ id: channel.id }],
rateLimit: { enabled: true, maxMessages: 5, windowSeconds: 300 },
quietHours: [{ start: '22:30', end: '07:15' }],
});
const list = await server.inject('/api/v1/notifications/rules');
expect(list.json()).toEqual({
items: [updated.json()],
page: { page: 1, pageSize: 50, total: 1 },
});
const rejected = await server.inject({
method: 'PATCH',
url: `/api/v1/notifications/rules/${id}`,
payload: { quietHours: [{ start: '09:00', end: '09:00' }] },
});
expect(rejected.statusCode).toBe(400);
expect(rejected.json()).toMatchObject({ code: 'NOTIFICATION_VALIDATION_FAILED' });
});
it('tests channels and lists delivery logs without exposing credentials', async () => {
const { server } = app();
const channel = (
await server.inject({
method: 'POST',
url: '/api/v1/notifications/channels',
payload: {
name: 'Bark',
type: 'bark',
config: { server_url: 'https://api.day.app', device_key: 'opaque-device-key' },
},
})
).json();
const tested = await server.inject({
method: 'POST',
url: `/api/v1/notifications/channels/${channel.id}/test`,
payload: { title: '测试通知', body: '链路正常' },
});
expect(tested.statusCode).toBe(200);
expect(tested.json()).toEqual({ ok: true, status: 'success' });
const logs = await server.inject('/api/v1/notifications/logs');
expect(logs.statusCode).toBe(200);
expect(logs.json().items[0]).toMatchObject({
eventType: 'test',
status: 'success',
channelName: 'Bark',
});
expect(logs.body).not.toContain('opaque-device-key');
});
it('maps validation and missing-resource failures to problem responses', async () => {
const { server } = app();
const invalid = await server.inject({
method: 'POST',
url: '/api/v1/notifications/channels',
payload: { name: 'Bad', type: 'not-a-channel', config: {} },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'NOTIFICATION_VALIDATION_FAILED' });
const missing = await server.inject({
method: 'POST',
url: '/api/v1/notifications/channels/missing/test',
payload: {},
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ code: 'NOTIFICATION_CHANNEL_NOT_FOUND' });
});
});
@@ -0,0 +1,291 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
CentralNotificationService,
CentralNotificationServiceError,
} from '../../application/notifications/central-notification-service.js';
export interface CentralNotificationRoutesOptions {
readonly notifications: CentralNotificationService;
readonly devices?: () => Promise<
readonly {
readonly id: string;
readonly name: string;
readonly state: string;
readonly tags: readonly string[];
}[]
>;
}
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
): FastifyReply {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 404 ? 'Not Found' : status === 204 ? 'No Content' : 'Bad Request',
status,
code,
detail: 'The notification request is invalid.',
requestId: request.id,
});
}
async function action<T>(
request: FastifyRequest,
reply: FastifyReply,
operation: () => T | Promise<T>,
): Promise<T | FastifyReply> {
try {
return await operation();
} catch (error) {
if (error instanceof CentralNotificationServiceError) {
if (error.code === 'CHANNEL_NOT_FOUND')
return problem(request, reply, 404, 'NOTIFICATION_CHANNEL_NOT_FOUND');
if (error.code === 'RULE_NOT_FOUND')
return problem(request, reply, 404, 'NOTIFICATION_RULE_NOT_FOUND');
if (error.code === 'QUEUE_NOT_FOUND')
return problem(request, reply, 404, 'NOTIFICATION_QUEUE_ITEM_NOT_FOUND');
return problem(request, reply, 400, 'NOTIFICATION_VALIDATION_FAILED');
}
if (error instanceof TypeError)
return problem(request, reply, 400, 'NOTIFICATION_VALIDATION_FAILED');
throw error;
}
}
function pagination(query: unknown): { readonly page: number; readonly pageSize: number } {
const value = record(query);
const parse = (key: string, fallback: number): number => {
const raw = value?.[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^[1-9]\d*$/u.test(raw) || Number(raw) > 100)
throw new TypeError(`${key} is invalid`);
return Number(raw);
};
return { page: parse('page', 1), pageSize: parse('pageSize', 50) };
}
function queuePagination(query: unknown): {
readonly page: number;
readonly pageSize: number;
readonly status?: string;
} {
const value = record(query);
const base = pagination(query);
const status = value?.status;
if (
status !== undefined &&
!['pending', 'sending', 'succeeded', 'failed', 'cancelled'].includes(String(status))
)
throw new TypeError('status is invalid');
return { ...base, ...(status === undefined ? {} : { status: String(status) }) };
}
function record(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function logFilter(query: unknown): Record<string, unknown> {
const value = record(query) ?? {};
const known = ['page', 'pageSize', 'status', 'eventType', 'from', 'to'];
if (Object.keys(value).some((key) => !known.includes(key)))
throw new TypeError('Unknown log filter field');
const pick = (key: 'status' | 'eventType' | 'from' | 'to'): Record<string, string> =>
value[key] === undefined ? {} : { [key]: String(value[key]) };
return {
...pick('status'),
...pick('eventType'),
...pick('from'),
...pick('to'),
};
}
export function registerCentralNotificationRoutes(
app: FastifyInstance,
options: CentralNotificationRoutesOptions,
): void {
app.get('/api/v1/notifications/overview', async (request, reply) =>
action(request, reply, async () => {
const overview = options.notifications.overview();
return {
observedAt: new Date().toISOString(),
devices: options.devices ? await options.devices() : [],
config: overview.config,
logs: {
total: overview.logs.total,
success: overview.logs.success,
failed: overview.logs.failed,
recent: overview.logs.recent,
},
queue: overview.queue,
};
}),
);
app.get('/api/v1/notifications/channels', async (request, reply) =>
action(request, reply, async () => ({ items: options.notifications.listChannels() })),
);
app.post('/api/v1/notifications/channels', async (request, reply) =>
action(request, reply, async () => {
const channel = await options.notifications.createChannel(request.body);
return reply.code(201).send(channel);
}),
);
app.put('/api/v1/notifications/channels/:channelId', async (request, reply) =>
action(request, reply, async () =>
options.notifications.updateChannel(
(request.params as { channelId: string }).channelId,
request.body,
),
),
);
app.delete('/api/v1/notifications/channels/:channelId', async (request, reply) =>
action(request, reply, async () => {
await options.notifications.deleteChannel(
(request.params as { channelId: string }).channelId,
);
return reply.code(204).send();
}),
);
app.post('/api/v1/notifications/channels/:channelId/test', async (request, reply) =>
action(request, reply, async () => {
const body = record(request.body) ?? {};
if (Object.keys(body).some((key) => key !== 'title' && key !== 'body'))
throw new TypeError('Unknown channel test field');
return options.notifications.testChannel(
(request.params as { channelId: string }).channelId,
body,
);
}),
);
app.get('/api/v1/notifications/rules', async (request, reply) =>
action(request, reply, () => {
const query = pagination(request.query);
const items = options.notifications.listRules();
const offset = (query.page - 1) * query.pageSize;
return {
items: items.slice(offset, offset + query.pageSize),
page: { ...query, total: items.length },
};
}),
);
app.post('/api/v1/notifications/rules', async (request, reply) =>
action(request, reply, async () => {
const rule = await options.notifications.createRule(request.body);
return reply.code(201).send(rule);
}),
);
app.patch('/api/v1/notifications/rules/:ruleId', async (request, reply) =>
action(request, reply, async () =>
options.notifications.updateRule((request.params as { ruleId: string }).ruleId, request.body),
),
);
app.delete('/api/v1/notifications/rules/:ruleId', async (request, reply) =>
action(request, reply, () => {
options.notifications.deleteRule((request.params as { ruleId: string }).ruleId);
return reply.code(204).send();
}),
);
app.get('/api/v1/notifications/logs', async (request, reply) =>
action(request, reply, () => {
const query = pagination(request.query);
return options.notifications.listLogs(query.page, query.pageSize, logFilter(request.query));
}),
);
app.get('/api/v1/notifications/logs/cleanup-settings', async (request, reply) =>
action(request, reply, () => options.notifications.logCleanup()),
);
app.put(
'/api/v1/notifications/logs/cleanup-settings',
{
bodyLimit: 4_096,
schema: {
body: {
type: 'object',
additionalProperties: false,
properties: {
retentionDaysEnabled: { type: 'boolean' },
retentionDays: { type: 'integer', minimum: 1, maximum: 36_500 },
maxEntriesEnabled: { type: 'boolean' },
maxEntries: { type: 'integer', minimum: 1, maximum: 1_000_000 },
},
},
},
},
async (request, reply) =>
action(request, reply, () => options.notifications.updateLogCleanup(request.body)),
);
app.post('/api/v1/notifications/logs/prune', async (request, reply) =>
action(request, reply, () => {
const body = record(request.body) ?? {};
if (Object.keys(body).length > 0) throw new TypeError('Prune takes no fields');
return { affected: options.notifications.pruneLogs() };
}),
);
app.post('/api/v1/notifications/logs/clear', async (request, reply) =>
action(request, reply, () => {
return { affected: options.notifications.clearLogs(request.body) };
}),
);
app.get('/api/v1/notifications/queue', async (request, reply) =>
action(request, reply, () => {
const query = queuePagination(request.query);
return options.notifications.listQueue(query.page, query.pageSize, query.status as never);
}),
);
app.post('/api/v1/notifications/queue/process', async (request, reply) =>
action(request, reply, () => options.notifications.processQueue()),
);
app.post('/api/v1/notifications/queue/:queueId/retry', async (request, reply) =>
action(request, reply, () => {
options.notifications.retryQueueItem((request.params as { queueId: string }).queueId);
return { retried: true };
}),
);
app.delete('/api/v1/notifications/queue/:queueId', async (request, reply) =>
action(request, reply, () => {
options.notifications.deleteQueueItem((request.params as { queueId: string }).queueId);
return reply.code(204).send();
}),
);
app.delete('/api/v1/notifications/queue', async (request, reply) =>
action(request, reply, () => {
const query = record(request.query) ?? {};
const raw = query.status;
const status =
raw === undefined
? undefined
: ['pending', 'sending', 'succeeded', 'failed', 'cancelled'].includes(String(raw))
? (String(raw) as never)
: undefined;
if (raw !== undefined && status === undefined) throw new TypeError('status is invalid');
return { affected: options.notifications.clearQueue(status) };
}),
);
}
@@ -0,0 +1,173 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { DeviceActionService } from '../../application/instances/device-action-service.js';
import {
InstanceSessionStore,
type UpstreamRequest,
} from '../../application/connections/upstream-session-client.js';
import type { InstanceService } from '../../application/instances/instance-service.js';
import { buildApp } from '../../app.js';
import { registerDeviceActionRoutes } from './device-action-routes.js';
function fixture(replies: { readonly status: number; readonly body?: unknown }[] = []) {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
db.prepare(
`INSERT INTO instances (id,name,base_url,config_revision,created_at,updated_at)
VALUES ('node-a','Node A','http://node-a.local',1,?,?)`,
).run('2026-09-04T00:00:00.000Z', '2026-09-04T00:00:00.000Z');
const sessions = new InstanceSessionStore();
sessions.set('node-a', 'http://node-a.local', 'simadmin_session=opaque');
const calls: UpstreamRequest[] = [];
let cursor = 0;
const actions = new DeviceActionService({
instances: {
get: async (id: string) =>
id === 'node-a'
? { id: 'node-a', name: 'Node A', origin: 'http://node-a.local' }
: undefined,
} as unknown as InstanceService,
sessions,
db,
request: async (request) => {
calls.push(request);
const reply = replies[Math.min(cursor, Math.max(replies.length - 1, 0))] as
| { status: number; body?: unknown }
| undefined;
cursor += 1;
return {
status: reply?.status ?? 200,
headers: {},
body: reply?.body === undefined ? '' : JSON.stringify(reply.body),
};
},
now: () => new Date('2026-09-04T00:00:00.000Z'),
id: () => 'audit-row-1',
});
const app = buildApp({
registerRoutes: (scope) => registerDeviceActionRoutes(scope, { actions }),
});
return { app, db, calls };
}
describe('device action routes', () => {
it('serves the catalog so the console never has to hardcode a device path', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/instances/node-a/device-actions',
});
expect(response.statusCode, response.body).toBe(200);
const body = response.json() as { actions: { id: string; module: string; risk: string }[] };
expect(body.actions.length).toBeGreaterThan(40);
expect(body.actions.some((action) => action.id === 'network.register-manual')).toBe(true);
expect(body.actions.every((action) => !('path' in action))).toBe(true);
});
it('executes an allowlisted action and returns the sanitized device reply', async () => {
const { app, calls } = fixture([
{ status: 200, body: { status: 'success', data: { ok: true } } },
]);
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/node-a/device-actions/sim.refresh-details',
payload: { params: {} },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json()).toMatchObject({ actionId: 'sim.refresh-details', ok: true });
expect(calls[0]?.url).toBe('http://node-a.local/api/sim/details/refresh');
});
it('maps an unknown action to a problem response', async () => {
const { app, calls } = fixture();
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/node-a/device-actions/self-destruct',
payload: { params: {} },
});
expect(response.statusCode).toBe(404);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ code: 'NOT_FOUND' });
expect(calls).toHaveLength(0);
});
it('refuses a risky action that arrives without a confirmation', async () => {
const { app, calls } = fixture();
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/node-a/device-actions/backup.data-clear',
payload: { params: {} },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' });
expect(calls).toHaveLength(0);
});
it('reports a device-side refusal as data the console can show', async () => {
const { app } = fixture([{ status: 500, body: { status: 'error', msg: 'radio busy' } }]);
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/node-a/device-actions/network.scan',
payload: { params: {} },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ ok: false, status: 500, message: 'radio busy' });
});
it('turns a transport failure into a bad gateway', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
db.prepare(
`INSERT INTO instances (id,name,base_url,config_revision,created_at,updated_at)
VALUES ('node-a','Node A','http://node-a.local',1,?,?)`,
).run('2026-09-04T00:00:00.000Z', '2026-09-04T00:00:00.000Z');
const sessions = new InstanceSessionStore();
const actions = new DeviceActionService({
instances: {
get: async (id: string) =>
id === 'node-a'
? { id: 'node-a', name: 'Node A', origin: 'http://node-a.local' }
: undefined,
} as unknown as InstanceService,
sessions,
db,
request: async () => {
throw new Error('pinned transport rejected the request');
},
now: () => new Date('2026-09-04T00:00:00.000Z'),
id: () => 'audit-row-1',
});
const app = buildApp({
registerRoutes: (scope) => registerDeviceActionRoutes(scope, { actions }),
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/node-a/device-actions/ota.cancel',
payload: { params: {} },
});
expect(response.statusCode).toBe(502);
expect(response.json()).toMatchObject({ code: 'UPSTREAM_FAILED' });
const row = db
.prepare('SELECT result_code FROM audit_events WHERE id=?')
.get('audit-row-1') as {
result_code: string;
};
expect(row.result_code).toBe('failed');
});
it('rejects a malformed params payload before the service runs', async () => {
const { app, calls } = fixture();
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances/node-a/device-actions/network.scan',
payload: { params: [1, 2] },
});
expect(response.statusCode).toBe(400);
expect(calls).toHaveLength(0);
});
});
@@ -0,0 +1,107 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
DeviceActionError,
type DeviceActionService,
} from '../../application/instances/device-action-service.js';
export interface DeviceActionRoutesOptions {
readonly actions: DeviceActionService;
}
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
detail: string,
): FastifyReply {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 404 ? 'Not Found' : 'Bad Request',
status,
code,
detail,
requestId: request.id,
});
}
const statusFor = (code: DeviceActionError['code']): number => {
switch (code) {
case 'NOT_FOUND':
return 404;
case 'SESSION_INVALID':
return 401;
case 'UPSTREAM_FAILED':
case 'NOT_DISPATCHED':
return 502;
default:
return 400;
}
};
export function registerDeviceActionRoutes(
app: FastifyInstance,
options: DeviceActionRoutesOptions,
): void {
app.get('/api/v1/instances/:instanceId/device-actions', async (request, reply) => {
const instanceId = (request.params as { instanceId?: unknown }).instanceId;
if (typeof instanceId !== 'string' || instanceId.length === 0)
return problem(request, reply, 400, 'VALIDATION_FAILED', 'Instance id is required');
return { actions: options.actions.list() };
});
app.post(
'/api/v1/instances/:instanceId/device-actions/:actionId',
{
bodyLimit: 16_384,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['params'],
properties: {
confirm: { type: 'boolean' },
params: { type: 'object' },
},
},
},
},
async (request, reply) => {
const params = request.params as { instanceId?: unknown; actionId?: unknown };
const instanceId = typeof params.instanceId === 'string' ? params.instanceId : '';
const actionId = typeof params.actionId === 'string' ? params.actionId : '';
if (!instanceId || !actionId)
return problem(request, reply, 400, 'VALIDATION_FAILED', 'Route parameters are required');
const body = request.body as { confirm?: unknown; params?: unknown } | undefined;
const values = body?.params;
if (values === null || typeof values !== 'object' || Array.isArray(values))
return problem(request, reply, 400, 'VALIDATION_FAILED', 'Action params must be an object');
try {
return await options.actions.execute(
instanceId,
actionId,
values as Record<string, unknown>,
{
actor: 'loopback-control-plane',
requestId: request.id,
confirm: body?.confirm === true,
},
);
} catch (error) {
if (error instanceof DeviceActionError)
return problem(
request,
reply,
statusFor(error.code),
error.code,
'The device action could not be completed.',
);
throw error;
}
},
);
}
@@ -0,0 +1,157 @@
import { describe, expect, it, vi } from 'vitest';
import type { networkInterfaces } from 'node:os';
import type { InstancePage } from '@multi-simadmin/contracts';
import {
DeviceDiscoveryService,
DiscoveryError,
type DiscoveryTransport,
type KnownInstanceOrigin,
} from '../../application/instances/device-discovery-service.js';
import { buildApp } from '../../app.js';
import { registerDiscoveryRoutes } from './discovery-routes.js';
const INTERFACES: ReturnType<typeof networkInterfaces> = {
en0: [
{
address: '192.168.1.23',
family: 'IPv4',
internal: false,
netmask: '255.255.255.0',
cidr: '192.168.1.23/24',
mac: '',
},
],
};
const emptyPage: InstancePage = { items: [], page: { page: 1, pageSize: 200, total: 0 } };
function fixture(responses: Readonly<Record<string, { status: number; body: string }>> = {}) {
const transport: DiscoveryTransport = {
async get(url) {
const hit = responses[url];
if (hit) return hit;
throw new Error('ECONNREFUSED');
},
};
const instances: KnownInstanceOrigin = { list: async () => emptyPage };
const discovery = new DeviceDiscoveryService({
transport,
instances,
interfaces: () => INTERFACES,
ports: [3000],
maxTargets: 3,
concurrency: 2,
});
const app = buildApp({
registerRoutes: (scope) => registerDiscoveryRoutes(scope, { discovery }),
});
return { app, discovery };
}
describe('discovery routes', () => {
it('runs the session lifecycle over HTTP', async () => {
const { app } = fixture({
'http://192.168.1.1:3000/api/health': { status: 200, body: '{}' },
'http://192.168.1.1:3000/api/device': { status: 200, body: '{"model":"UFI-003"}' },
});
const started = await app.inject({ method: 'POST', url: '/api/v1/discovery/sessions' });
expect(started.statusCode, started.body).toBe(201);
const sessionId = String(started.json().data.sessionId);
expect(started.json().data).toMatchObject({
status: 'scanning',
ranges: ['192.168.1.0/24'],
total: 3,
});
const renewed = await app.inject({
method: 'PUT',
url: `/api/v1/discovery/sessions/${sessionId}`,
});
expect(renewed.statusCode, renewed.body).toBe(200);
const closed = await app.inject({
method: 'DELETE',
url: `/api/v1/discovery/sessions/${sessionId}`,
});
expect(closed.statusCode).toBe(204);
const gone = await app.inject({
method: 'GET',
url: `/api/v1/discovery/sessions/${sessionId}`,
});
expect(gone.statusCode).toBe(404);
expect(gone.headers['content-type']).toContain('application/problem+json');
await app.close();
});
it('rejects a session id that is not an opaque token', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/discovery/sessions/..%2Fsecret',
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe('DISCOVERY_VALIDATION_FAILED');
await app.close();
});
it('lets an unexpected service failure surface as a server error', async () => {
const broken = {
start: async () => {
throw new Error('boom');
},
} as unknown as DeviceDiscoveryService;
const app = buildApp({
registerRoutes: (scope) => registerDiscoveryRoutes(scope, { discovery: broken }),
});
const response = await app.inject({ method: 'POST', url: '/api/v1/discovery/sessions' });
expect(response.statusCode).toBe(500);
await app.close();
});
it('probes a manually entered address', async () => {
const { app } = fixture({
'http://192.168.68.1:3000/api/health': { status: 200, body: '{}' },
'http://192.168.68.1:3000/api/device': { status: 200, body: '{"model":"UFI-003"}' },
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/discovery/probe',
payload: { device_url: '192.168.68.1:3000' },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toMatchObject({
origin: 'http://192.168.68.1:3000',
reachable: true,
httpStatus: 200,
identity: { model: 'UFI-003' },
knownInstanceId: null,
});
await app.close();
});
it('maps a validation failure from the probe body to a problem response', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'POST',
url: '/api/v1/discovery/probe',
payload: { device_url: 'https://example.com' },
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe('DISCOVERY_VALIDATION_FAILED');
await app.close();
});
it('maps a session ceiling to 429', async () => {
const { app, discovery } = fixture();
vi.spyOn(discovery, 'start').mockRejectedValue(
new DiscoveryError('TOO_MANY_SESSIONS', '设备发现会话数量已达上限'),
);
const response = await app.inject({ method: 'POST', url: '/api/v1/discovery/sessions' });
expect(response.statusCode).toBe(429);
expect(response.json().code).toBe('DISCOVERY_TOO_MANY_SESSIONS');
await app.close();
});
});
@@ -0,0 +1,101 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
DiscoveryError,
type DeviceDiscoveryService,
} from '../../application/instances/device-discovery-service.js';
export interface DiscoveryRoutesOptions {
readonly discovery: DeviceDiscoveryService;
}
const SESSION_ID = /^[0-9a-fA-F-]{1,64}$/u;
function problem(
reply: FastifyReply,
request: FastifyRequest,
status: number,
code: string,
title: string,
) {
return reply.code(status).type('application/problem+json').send({
type: 'about:blank',
title,
status,
code,
detail: 'The device discovery request could not be completed.',
requestId: request.id,
});
}
function sessionId(value: unknown): string {
if (typeof value !== 'string' || !SESSION_ID.test(value))
throw new DiscoveryError('VALIDATION_FAILED', 'Discovery session id is invalid');
return value;
}
export function registerDiscoveryRoutes(
app: FastifyInstance,
options: DiscoveryRoutesOptions,
): void {
const wrap =
<T>(action: (request: FastifyRequest, reply: FastifyReply) => Promise<T>) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return await action(request, reply);
} catch (error) {
if (error instanceof DiscoveryError) {
if (error.code === 'NOT_FOUND')
return problem(reply, request, 404, 'DISCOVERY_NOT_FOUND', 'Not Found');
if (error.code === 'TOO_MANY_SESSIONS')
return problem(reply, request, 429, 'DISCOVERY_TOO_MANY_SESSIONS', 'Too Many Requests');
return problem(reply, request, 400, 'DISCOVERY_VALIDATION_FAILED', 'Bad Request');
}
throw error;
}
};
app.post(
'/api/v1/discovery/sessions',
wrap(async (_request, reply) => {
const session = await options.discovery.start();
return reply.code(201).send({ data: session });
}),
);
app.get(
'/api/v1/discovery/sessions/:sessionId',
wrap(async (request) => {
const params = request.params as { sessionId?: unknown };
return { data: await options.discovery.status(sessionId(params.sessionId)) };
}),
);
app.put(
'/api/v1/discovery/sessions/:sessionId',
wrap(async (request) => {
const params = request.params as { sessionId?: unknown };
return { data: await options.discovery.renew(sessionId(params.sessionId)) };
}),
);
app.delete(
'/api/v1/discovery/sessions/:sessionId',
wrap(async (request, reply) => {
const params = request.params as { sessionId?: unknown };
await options.discovery.stop(sessionId(params.sessionId));
return reply.code(204).send();
}),
);
app.post(
'/api/v1/discovery/probe',
wrap(async (request) => {
const body = request.body;
if (typeof body !== 'object' || body === null || Array.isArray(body))
throw new DiscoveryError('VALIDATION_FAILED', 'Request body must be an object');
const { device_url: deviceUrl } = body as Record<string, unknown>;
return { data: await options.discovery.probe(deviceUrl) };
}),
);
}
@@ -10,7 +10,8 @@ import { registerEventRoutes } from './event-routes.js';
const databases: Database.Database[] = [];
const apps: ReturnType<typeof buildApp>[] = [];
const at = '2026-07-17T12:34:56.789Z';
// Journal retention prunes envelopes older than a day, so fixtures stay clock-relative.
const at = new Date(Date.now() - 60_000).toISOString();
const event = (id: string): EventEnvelope => ({
kind: 'job',
+564
View File
@@ -0,0 +1,564 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { Instance } from '@multi-simadmin/contracts';
import type { InstanceService } from '../../application/instances/instance-service.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import type {
ConnectionState,
ConnectionProbe,
} from '../../application/connections/connection-probe.js';
import {
MessageServiceError,
type DeleteSmsMessageRequest,
} from '../../application/messages/instance-message-service.js';
import type { HubMessageService } from '../../application/messages/hub-message-service.js';
import {
type SmsOutboxItem,
type SmsOutboxService,
type SmsOutboxStatus,
} from '../../application/messages/sms-outbox-service.js';
import {
NotificationServiceError,
type InstanceNotificationService,
} from '../../application/notifications/instance-notification-service.js';
export interface FleetMessageDevice {
readonly id: string;
readonly name: string;
readonly availability: 'online' | 'unavailable';
}
export interface FleetMessage {
readonly id: string;
readonly instanceId: string;
readonly instanceName: string;
readonly direction: string;
readonly phoneNumber: string;
readonly content: string;
readonly timestamp: string;
readonly status: string;
readonly transport: string;
}
export interface FleetMessagesResponse {
readonly messages: readonly FleetMessage[];
readonly devices: readonly FleetMessageDevice[];
readonly total: number;
}
export interface FleetMessageConversation {
readonly instanceId: string;
readonly instanceName: string;
readonly phoneNumber: string;
readonly messageCount: number;
readonly incomingCount: number;
readonly lastMessage: FleetMessage;
}
export interface FleetMessageConversationsResponse {
readonly conversations: readonly FleetMessageConversation[];
readonly total: number;
readonly stats: { readonly incoming: number; readonly outgoing: number; readonly total: number };
}
export interface FleetRoutesOptions {
readonly instances: InstanceService;
readonly resources: InstanceResourceService;
readonly messages: HubMessageService;
readonly notifications: InstanceNotificationService;
/** Reads the heartbeat journal; the overview never probes live so the page stays cheap. */
readonly connections?: ConnectionProbe;
/** Offline send queue; absent means sends fail fast instead of waiting for the device. */
readonly outbox?: SmsOutboxService;
}
/** One queued send, with the node label the queue table itself does not store. */
export type FleetOutboxItem = SmsOutboxItem & { readonly instanceName: string };
function parseOutboxQuery(query: unknown): {
readonly status: SmsOutboxStatus | 'open' | 'all';
readonly instanceId?: string;
readonly limit: number;
readonly offset: number;
} {
const value = record(query) ?? {};
if (Object.keys(value).some((key) => !['status', 'instanceId', 'limit', 'offset'].includes(key)))
throw new MessageServiceError('VALIDATION_FAILED');
const status = value.status;
if (status !== undefined && typeof status !== 'string')
throw new MessageServiceError('VALIDATION_FAILED');
const resolved = (status ?? 'open') as SmsOutboxStatus | 'open' | 'all';
if (!OUTBOX_STATUSES.includes(resolved)) throw new MessageServiceError('VALIDATION_FAILED');
const instanceId = value.instanceId;
if (
instanceId !== undefined &&
(typeof instanceId !== 'string' || instanceId.length > 256 || instanceId.trim() === '')
)
throw new MessageServiceError('VALIDATION_FAILED');
const integer = (key: 'limit' | 'offset', fallback: number, maximum: number): number => {
const raw = value[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^\d+$/u.test(raw))
throw new MessageServiceError('VALIDATION_FAILED');
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximum)
throw new MessageServiceError('VALIDATION_FAILED');
return parsed;
};
return {
status: resolved,
...(typeof instanceId === 'string' && instanceId.trim() !== ''
? { instanceId: instanceId.trim() }
: {}),
limit: integer('limit', 25, MAX_FLEET_OUTBOX_LIMIT),
offset: integer('offset', 0, MAX_FLEET_MESSAGE_OFFSET),
};
}
function outboxQueueId(value: unknown): string {
const id = typeof value === 'string' ? value : '';
if (!/^[A-Za-z0-9_.-]{1,64}$/u.test(id)) throw new MessageServiceError('VALIDATION_FAILED');
return id;
}
const OUTBOX_STATUSES: readonly (SmsOutboxStatus | 'open' | 'all')[] = [
'open',
'all',
'queued',
'sending',
'sent',
'failed',
'cancelled',
];
/**
* Heartbeat view of one device. `probed: false` means the control plane has never reached it,
* which the fleet table shows as 未知 rather than a misleading 离线.
*/
export interface FleetConnectionSummary {
readonly probed: boolean;
readonly reachable: boolean;
readonly authenticated: boolean;
readonly checkedAt: string | null;
}
const PAGE_SIZE = 100;
const MAX_FLEET_DEVICES = 200;
const MAX_FLEET_MESSAGE_LIMIT = 100;
const MAX_FLEET_CONVERSATION_LIMIT = 200;
const MAX_FLEET_MESSAGE_OFFSET = 1000;
const MAX_FLEET_OUTBOX_LIMIT = 100;
const EMPTY_OUTBOX_SUMMARY = Object.freeze({ queued: 0, sending: 0, failed: 0, sent: 0 });
interface QueryMessagesInput {
readonly limit: number;
readonly offset: number;
readonly search?: string;
readonly instanceId?: string;
readonly phoneNumber?: string;
}
/** Thread-level direction filter, accepted only by the conversation endpoint. */
export interface QueryConversationsInput extends QueryMessagesInput {
readonly direction?: 'incoming' | 'outgoing';
}
async function listInstances(instances: InstanceService): Promise<readonly Instance[]> {
const result: Instance[] = [];
let page = 1;
while (result.length < MAX_FLEET_DEVICES) {
const current = await instances.list({ page, pageSize: PAGE_SIZE });
result.push(...current.items.slice(0, MAX_FLEET_DEVICES - result.length));
if (current.items.length < PAGE_SIZE) break;
page += 1;
}
return result;
}
function connectionSummary(state: ConnectionState | undefined): FleetConnectionSummary {
if (!state) return { probed: false, reachable: false, authenticated: false, checkedAt: null };
return {
probed: true,
reachable: state.reachable,
authenticated: state.authenticated,
checkedAt: state.checkedAt,
};
}
function record(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function parseMessagesQuery(
query: unknown,
maximumLimit = MAX_FLEET_MESSAGE_LIMIT,
allowDirection = false,
): QueryConversationsInput {
const value = record(query) ?? {};
if (
Object.keys(value).some(
(key) =>
!['limit', 'offset', 'search', 'instanceId', 'phoneNumber'].includes(key) &&
!(allowDirection && key === 'direction'),
)
)
throw new MessageServiceError('VALIDATION_FAILED');
const integer = (key: 'limit' | 'offset', fallback: number, maximum: number): number => {
const raw = value[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^\d+$/u.test(raw)) {
throw new MessageServiceError('VALIDATION_FAILED');
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximum)
throw new MessageServiceError('VALIDATION_FAILED');
return parsed;
};
const search = value.search;
if (search !== undefined && (typeof search !== 'string' || search.length > 200))
throw new MessageServiceError('VALIDATION_FAILED');
const instanceId = value.instanceId;
if (
instanceId !== undefined &&
(typeof instanceId !== 'string' || instanceId.length > 256 || instanceId.trim() === '')
)
throw new MessageServiceError('VALIDATION_FAILED');
const phoneNumber = value.phoneNumber;
if (
phoneNumber !== undefined &&
(typeof phoneNumber !== 'string' || phoneNumber.length > 32 || phoneNumber.trim() === '')
)
throw new MessageServiceError('VALIDATION_FAILED');
const direction = value.direction;
if (
direction !== undefined &&
(!allowDirection || (direction !== 'incoming' && direction !== 'outgoing'))
)
throw new MessageServiceError('VALIDATION_FAILED');
return {
limit: integer('limit', 24, maximumLimit),
offset: integer('offset', 0, MAX_FLEET_MESSAGE_OFFSET),
...(typeof search === 'string' && search.trim() !== '' ? { search: search.trim() } : {}),
...(typeof instanceId === 'string' && instanceId.trim() !== ''
? { instanceId: instanceId.trim() }
: {}),
...(typeof phoneNumber === 'string' && phoneNumber.trim() !== ''
? { phoneNumber: phoneNumber.trim() }
: {}),
...(direction === 'incoming' || direction === 'outgoing' ? { direction } : {}),
};
}
function parseMessageDeleteItems(value: unknown): readonly DeleteSmsMessageRequest[] {
const body = record(value);
const items = body?.items;
if (!Array.isArray(items) || items.length < 1 || items.length > 500)
throw new MessageServiceError('VALIDATION_FAILED');
return items.map((item) => {
const current = record(item);
const instanceId = typeof current?.instanceId === 'string' ? current.instanceId : '';
const id =
typeof current?.id === 'string'
? current.id
: typeof current?.id === 'number' && Number.isSafeInteger(current.id) && current.id >= 0
? String(current.id)
: '';
if (!instanceId || !id) throw new MessageServiceError('VALIDATION_FAILED');
return { instanceId, id };
});
}
async function mapWithConcurrency<Item, Result>(
items: readonly Item[],
concurrency: number,
mapper: (item: Item, index: number) => Promise<Result>,
): Promise<readonly Result[]> {
const results: Result[] = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await mapper(items[index]!, index);
}
});
await Promise.all(workers);
return results;
}
function wrapMessageAction<T>(
handler: (request: FastifyRequest) => Promise<T>,
): (request: FastifyRequest, reply: FastifyReply) => Promise<T> {
return async (request, reply) => {
try {
return await handler(request);
} catch (error) {
if (error instanceof MessageServiceError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 400 ? 'Bad Request' : status === 404 ? 'Not Found' : 'Bad Gateway',
status,
code: error.code,
detail: 'The requested Fleet message operation could not be completed.',
requestId: request.id,
});
}
throw error;
}
};
}
interface FleetNotificationQueueItemParams {
readonly instanceId: string;
readonly queueId: string;
}
function wrapNotificationAction<T>(
handler: (
request: FastifyRequest<{ readonly Params: FleetNotificationQueueItemParams }>,
) => Promise<T>,
): (request: FastifyRequest, reply: FastifyReply) => Promise<T> {
return async (request, reply) => {
try {
return await handler(
request as FastifyRequest<{ readonly Params: FleetNotificationQueueItemParams }>,
);
} catch (error) {
if (error instanceof NotificationServiceError) {
const status =
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 400 ? 'Bad Request' : status === 404 ? 'Not Found' : 'Bad Gateway',
status,
code: error.code,
detail: 'The requested Fleet notification queue operation could not be completed.',
requestId: request.id,
});
}
throw error;
}
};
}
export function registerFleetRoutes(app: FastifyInstance, options: FleetRoutesOptions): void {
app.get('/api/v1/fleet/overview', async () => {
const instances = await listInstances(options.instances);
const reachability = options.connections?.reachability();
const items = await mapWithConcurrency(instances, 6, async (instance) => ({
...instance,
connection: connectionSummary(reachability?.get(instance.id)),
resources: await options.resources.get(instance.id),
}));
return { items };
});
app.get('/api/v1/fleet/notifications', async () => options.notifications.summarize());
app.post('/api/v1/fleet/notifications/queue/retry-all', async () =>
options.notifications.retryAllQueue(),
);
app.post(
'/api/v1/fleet/notifications/queue/:instanceId/items/:queueId/retry',
wrapNotificationAction(async (request) =>
options.notifications.retryQueueItem(request.params.instanceId, request.params.queueId),
),
);
app.delete(
'/api/v1/fleet/notifications/queue/:instanceId/items/:queueId',
wrapNotificationAction(async (request) =>
options.notifications.deleteQueueItem(request.params.instanceId, request.params.queueId),
),
);
app.get(
'/api/v1/fleet/messages',
wrapMessageAction(async (request) =>
options.messages.snapshot(parseMessagesQuery(request.query)),
),
);
app.get(
'/api/v1/fleet/messages/conversations',
wrapMessageAction(async (request) => {
const query = parseMessagesQuery(request.query, MAX_FLEET_CONVERSATION_LIMIT, true);
await options.messages.refresh();
const page = options.messages.conversations(query);
return {
conversations: page.items.map((item) => ({
instanceId: item.instanceId,
instanceName: item.instanceName,
phoneNumber: item.phoneNumber,
messageCount: item.messageCount,
incomingCount: item.incomingCount,
lastMessage: {
id: item.lastMessage.id,
instanceId: item.lastMessage.instanceId,
instanceName: item.lastMessage.instanceName,
direction: item.lastMessage.direction,
phoneNumber: item.lastMessage.phoneNumber,
content: item.lastMessage.content,
timestamp: item.lastMessage.timestamp,
status: item.lastMessage.status,
transport: item.lastMessage.transport,
},
})),
total: page.totalCount,
stats: page.stats,
} satisfies FleetMessageConversationsResponse;
}),
);
app.post(
'/api/v1/fleet/messages/sync',
{
bodyLimit: 4_096,
schema: {
body: {
type: 'object',
additionalProperties: false,
properties: {
instanceId: { type: 'string', minLength: 1, maxLength: 256 },
},
},
},
},
wrapMessageAction(async (request) => {
const body = record(request.body) ?? {};
const instanceId = typeof body.instanceId === 'string' ? body.instanceId : undefined;
return instanceId
? { synced: await options.messages.syncDevice(instanceId), instanceId }
: await options.messages.syncAll();
}),
);
app.post(
'/api/v1/fleet/messages/send',
{
bodyLimit: 8_192,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['instanceId', 'phoneNumber', 'content'],
properties: {
instanceId: { type: 'string', minLength: 1, maxLength: 256 },
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
content: { type: 'string', minLength: 1, maxLength: 2000 },
},
},
},
},
wrapMessageAction(async (request) => {
const value = record(request.body);
const instanceId = typeof value?.instanceId === 'string' ? value.instanceId : '';
const phoneNumber = typeof value?.phoneNumber === 'string' ? value.phoneNumber : '';
const content = typeof value?.content === 'string' ? value.content : '';
if (!options.outbox) {
await options.messages.send(instanceId, { phoneNumber, content });
return { sent: true, queued: false, instanceId };
}
// The queue owns a foreign key onto instances, so an unknown node stays a 404.
if (!(await options.instances.get(instanceId))) throw new MessageServiceError('NOT_FOUND');
const result = await options.outbox.submit(instanceId, { phoneNumber, content });
return {
sent: result.status === 'sent',
queued: result.status === 'queued',
instanceId,
...(result.item ? { queueId: result.item.id } : {}),
};
}),
);
app.post(
'/api/v1/fleet/messages/delete',
{
bodyLimit: 32_768,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['items'],
properties: {
items: {
type: 'array',
minItems: 1,
maxItems: 500,
items: {
type: 'object',
additionalProperties: false,
required: ['instanceId', 'id'],
properties: {
instanceId: { type: 'string', minLength: 1, maxLength: 256 },
id: {
anyOf: [
{ type: 'string', minLength: 1, maxLength: 64 },
{ type: 'integer', minimum: 0 },
],
},
},
},
},
},
},
},
},
wrapMessageAction(async (request) =>
options.messages.deleteMany(parseMessageDeleteItems(request.body)),
),
);
app.get(
'/api/v1/fleet/messages/outbox',
wrapMessageAction(async (request) => {
if (!options.outbox) return { items: [], total: 0, summary: EMPTY_OUTBOX_SUMMARY };
const query = parseOutboxQuery(request.query);
const page = options.outbox.list(query);
const names = new Map((await listInstances(options.instances)).map((i) => [i.id, i.name]));
return {
items: page.items.map(
(item): FleetOutboxItem => ({
...item,
instanceName: names.get(item.instanceId) ?? item.instanceId,
}),
),
total: page.total,
summary: options.outbox.summary(),
};
}),
);
app.post(
'/api/v1/fleet/messages/outbox/flush',
wrapMessageAction(async () => {
if (!options.outbox)
return { attempted: 0, delivered: 0, deferred: 0, failed: 0, remaining: 0 };
return await options.outbox.flush();
}),
);
app.post(
'/api/v1/fleet/messages/outbox/:queueId/cancel',
wrapMessageAction(async (request) => {
if (!options.outbox) throw new MessageServiceError('NOT_FOUND');
const params = record(request.params);
return options.outbox.cancel(outboxQueueId(params?.queueId));
}),
);
app.post(
'/api/v1/fleet/messages/outbox/:queueId/retry',
wrapMessageAction(async (request) => {
if (!options.outbox) throw new MessageServiceError('NOT_FOUND');
const params = record(request.params);
return options.outbox.retry(outboxQueueId(params?.queueId));
}),
);
app.delete(
'/api/v1/fleet/messages/outbox/:queueId',
wrapMessageAction(async (request) => {
if (!options.outbox) throw new MessageServiceError('NOT_FOUND');
const params = record(request.params);
const queueId = outboxQueueId(params?.queueId);
options.outbox.remove(queueId);
return { removed: true, queueId };
}),
);
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { InstanceModuleService } from '../../application/instances/instance-module-service.js';
import { InstanceSessionStore } from '../../application/connections/upstream-session-client.js';
import type { InstanceService } from '../../application/instances/instance-service.js';
import { buildApp } from '../../app.js';
import { registerInstanceModuleRoutes } from './instance-module-routes.js';
function fixture() {
const service = new InstanceModuleService({
instances: {
get: async (id: string) =>
id === 'node-a' ? { id: 'node-a', origin: 'http://node-a.local' } : undefined,
} as unknown as InstanceService,
sessions: new InstanceSessionStore(),
request: async (request) =>
request.url.endsWith('/device')
? { status: 200, headers: {}, body: JSON.stringify({ model: 'LPAX' }) }
: { status: 200, headers: {}, body: '{}' },
defaultTimeoutMs: 200,
});
const app = buildApp({
registerRoutes: (scope) => registerInstanceModuleRoutes(scope, { modules: service }),
});
return { app, service };
}
describe('instance module routes', () => {
it('serves a module snapshot', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/instances/node-a/modules/overview',
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json()).toMatchObject({
instanceId: 'node-a',
module: 'overview',
authenticated: false,
});
const device = (response.json().sections as { key: string; data: unknown }[]).find(
(section) => section.key === 'device',
);
expect(device?.data).toMatchObject({ model: 'LPAX' });
});
it('rejects an unknown module', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/instances/node-a/modules/nope',
});
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ code: 'MODULE_VALIDATION_FAILED' });
});
it('returns 404 for an unknown instance', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'GET',
url: '/api/v1/instances/missing/modules/sim',
});
expect(response.statusCode).toBe(404);
expect(response.json()).toMatchObject({ code: 'NOT_FOUND' });
});
});
@@ -0,0 +1,62 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
isInstanceModuleKey,
type InstanceModuleKey,
} from '../../application/instances/instance-module-catalog.js';
import {
InstanceModuleError,
type InstanceModuleService,
} from '../../application/instances/instance-module-service.js';
export interface InstanceModuleRoutesOptions {
readonly modules: InstanceModuleService;
}
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
detail: string,
): FastifyReply {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 404 ? 'Not Found' : 'Bad Request',
status,
code,
detail,
requestId: request.id,
});
}
export function registerInstanceModuleRoutes(
app: FastifyInstance,
options: InstanceModuleRoutesOptions,
): void {
const handler = async (request: FastifyRequest, reply: FastifyReply): Promise<unknown> => {
const params = request.params as { instanceId?: unknown; module?: unknown };
const instanceId = typeof params.instanceId === 'string' ? params.instanceId : '';
const requested = typeof params.module === 'string' ? params.module : '';
if (!instanceId || !isInstanceModuleKey(requested))
return problem(request, reply, 400, 'MODULE_VALIDATION_FAILED', 'Unknown instance module');
try {
return await options.modules.read(instanceId, requested as InstanceModuleKey);
} catch (error) {
if (error instanceof InstanceModuleError)
return problem(
request,
reply,
error.code === 'NOT_FOUND' ? 404 : 400,
error.code,
'The instance module could not be read.',
);
throw error;
}
};
app.get('/api/v1/instances/:instanceId/modules/:module', handler);
}
@@ -105,12 +105,14 @@ const bodyInput = (body: unknown): InstanceInput => {
name: string;
origin: string;
tags?: readonly string[];
groupId?: string | null;
password?: PasswordUpdate;
} = {
name: typeof value.name === 'string' ? value.name : '',
origin: typeof value.origin === 'string' ? value.origin : '',
};
if (tags) patch.tags = tags;
if (typeof value.groupId === 'string' || value.groupId === null) patch.groupId = value.groupId;
if (value.password && typeof value.password === 'object')
patch.password = value.password as PasswordUpdate;
return patch;
@@ -122,11 +124,13 @@ const bodyPatch = (body: unknown): InstancePatch => {
name?: string;
origin?: string;
tags?: readonly string[];
groupId?: string | null;
password?: PasswordUpdate;
} = {};
if (typeof value.name === 'string') patch.name = value.name;
if (typeof value.origin === 'string') patch.origin = value.origin;
if (tags) patch.tags = tags;
if (typeof value.groupId === 'string' || value.groupId === null) patch.groupId = value.groupId;
if (value.password && typeof value.password === 'object')
patch.password = value.password as PasswordUpdate;
return patch;
@@ -157,6 +161,7 @@ const page = (query: unknown): InstancePageQuery => {
direction?: 'asc' | 'desc';
search?: string;
tag?: string;
groupId?: string;
capabilityStatus?: 'supported' | 'unsupported' | 'auth-required' | 'degraded' | 'unknown';
freshness?: 'fresh' | 'stale' | 'expired' | 'unknown';
credentialConfigured?: boolean;
@@ -184,6 +189,8 @@ const page = (query: unknown): InstancePageQuery => {
}
const tag = optionalString('tag');
if (tag !== undefined) queryValue.tag = tag;
const groupId = optionalString('groupId');
if (groupId !== undefined) queryValue.groupId = groupId;
const capabilityStatus = optionalString('capabilityStatus');
if (capabilityStatus !== undefined) {
if (
@@ -236,6 +243,7 @@ const instanceProperties = {
name: { type: 'string' },
origin: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
groupId: { anyOf: [{ type: 'string', minLength: 1 }, { type: 'null' }] },
password: passwordUpdateSchema,
} as const;
const instanceInputSchema = {
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../app.js';
import { JobQueryService } from '../../application/jobs/job-query-service.js';
import { JobReconcileService } from '../../application/jobs/job-reconcile-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerJobRoutes } from './job-routes.js';
@@ -205,3 +206,93 @@ describe('job HTTP read routes', () => {
await app.close();
});
});
describe('job control routes', () => {
function controlFixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const jobs = new JobQueryService(db);
const reconcile = new JobReconcileService({ db, now: () => new Date(timestamp) });
const app = buildApp({
registerRoutes: (scope) => registerJobRoutes(scope, { jobs, reconcile }),
});
return { app, db, jobs, reconcile };
}
function insertActiveJob(db: Database.Database, id: string, status: string): void {
insertJob(db, id, status);
db.prepare(
`INSERT INTO job_items
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
VALUES (?, ?, 'instance-1', 1, 'running', ?, ?)`,
).run(`${id}-item`, id, timestamp, timestamp);
}
it('cancels an active job and returns the terminal projection', async () => {
const { app, db } = controlFixture();
insertActiveJob(db, 'job-run', 'running');
const response = await app.inject({ method: 'POST', url: '/api/v1/jobs/job-run/cancel' });
expect(response.statusCode).toBe(202);
expect(response.json()).toMatchObject({ id: 'job-run', status: 'cancelled' });
expect(
db.prepare("SELECT status, result_code FROM job_items WHERE id = 'job-run-item'").get() as {
status: string;
result_code: string;
},
).toEqual({ status: 'cancelled', result_code: 'CANCELLED_BY_OPERATOR' });
await app.close();
});
it('maps a terminal job and a missing job to exact problems', async () => {
const { app, db } = controlFixture();
insertJob(db, 'job-done', 'succeeded');
const conflict = await app.inject({ method: 'POST', url: '/api/v1/jobs/job-done/cancel' });
expect(conflict.statusCode).toBe(409);
exactProblem(conflict, {
title: 'Conflict',
status: 409,
code: 'NOT_CANCELLABLE',
detail: 'The job already reached a terminal state.',
});
const missing = await app.inject({ method: 'POST', url: '/api/v1/jobs/nope/cancel' });
expect(missing.statusCode).toBe(404);
exactProblem(missing, {
title: 'Not Found',
status: 404,
code: 'NOT_FOUND',
detail: 'The requested job does not exist.',
});
await app.close();
});
it('reports and closes interrupted jobs through the reconcile routes', async () => {
const { app, db } = controlFixture();
insertActiveJob(db, 'job-stuck', 'running');
db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run(
new Date(Date.parse(timestamp) - 20 * 60_000).toISOString(),
'job-stuck',
);
const before = await app.inject({ method: 'GET', url: '/api/v1/jobs/reconcile' });
expect(before.statusCode).toBe(200);
expect(before.json()).toMatchObject({ pending: 1, dispatched: 1, queued: 0 });
const run = await app.inject({ method: 'POST', url: '/api/v1/jobs/reconcile' });
expect(run.statusCode).toBe(200);
expect(run.json()).toEqual({ interrupted: 1, pending: 0 });
await app.close();
});
it('keeps the reconcile routes off when the runtime cannot reconcile', async () => {
const { app, db } = fixture();
insertActiveJob(db, 'job-stuck', 'running');
const response = await app.inject({ method: 'POST', url: '/api/v1/jobs/reconcile' });
expect(response.statusCode).toBe(404);
await app.close();
});
});
+41
View File
@@ -2,9 +2,14 @@ import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js';
import {
JobCancelError,
JobReconcileService,
} from '../../application/jobs/job-reconcile-service.js';
export interface JobRoutesOptions {
readonly jobs: JobQueryService;
readonly reconcile?: JobReconcileService;
}
const QUERY_KEYS = new Set([
@@ -130,4 +135,40 @@ export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOption
'/api/v1/jobs/:jobId',
handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)),
);
const reconcile = options.reconcile;
if (reconcile) {
app.get('/api/v1/jobs/reconcile', async () => reconcile.summary());
app.post('/api/v1/jobs/reconcile', () => reconcile.reconcile());
app.post('/api/v1/jobs/:jobId/cancel', async (request, reply) => {
const jobId = (request.params as { jobId: string }).jobId;
try {
reconcile.cancel(jobId);
} catch (error) {
if (!(error instanceof JobCancelError)) throw error;
const mapped =
error.code === 'NOT_FOUND'
? {
status: 404,
title: 'Not Found',
detail: 'The requested job does not exist.',
}
: {
status: 409,
title: 'Conflict',
detail: 'The job already reached a terminal state.',
};
return reply.code(mapped.status).type('application/problem+json').send({
type: 'about:blank',
title: mapped.title,
status: mapped.status,
code: error.code,
detail: mapped.detail,
requestId: request.id,
});
}
return reply.code(202).send(options.jobs.get(jobId));
});
}
}
@@ -0,0 +1,102 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { ConnectionLogService } from '../../application/system/connection-log-service.js';
import { LogCenterService } from '../../application/system/log-center-service.js';
import { buildApp } from '../../app.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerLogCenterRoutes } from './log-center-routes.js';
const databases: Database.Database[] = [];
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
databases.push(db);
db.prepare(
`INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at)
VALUES ('node-a','Node A','http://node-a.local',1,1,'2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`,
).run();
const connections = new ConnectionLogService({ db });
connections.record({
instanceId: 'node-a',
outcome: 'success',
state: 'fresh',
errorCode: null,
httpStatus: 200,
durationMs: 14,
observedAt: '2026-09-04T00:00:00.000Z',
});
const logs = new LogCenterService({ db, connections });
const app = buildApp({
registerRoutes: (scope) => registerLogCenterRoutes(scope, { logs, connections }),
});
return { app, db, connections };
}
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
describe('log centre routes', () => {
it('serves the runtime timeline, connection journal and diagnostics', async () => {
const { app } = fixture();
const runtime = await app.inject({ method: 'GET', url: '/api/v1/logs/runtime' });
expect(runtime.statusCode, runtime.body).toBe(200);
expect(runtime.json()).toMatchObject({
items: [],
page: { page: 1, pageSize: 50, total: 0 },
counts: { event: 0, audit: 0, schedule: 0, delivery: 0 },
});
const connections = await app.inject({ method: 'GET', url: '/api/v1/logs/connections' });
expect(connections.statusCode).toBe(200);
expect(connections.json().items[0]).toMatchObject({
instanceId: 'node-a',
outcome: 'success',
httpStatus: 200,
durationMs: 14,
});
const diagnostics = await app.inject({ method: 'GET', url: '/api/v1/logs/diagnostics' });
expect(diagnostics.statusCode).toBe(200);
expect(diagnostics.json().items).toHaveLength(1);
await app.close();
});
it('rejects unknown filters and invalid pagination with a problem document', async () => {
const { app } = fixture();
for (const url of [
'/api/v1/logs/runtime?unknown=1',
'/api/v1/logs/runtime?pageSize=9999',
'/api/v1/logs/runtime?level=verbose',
'/api/v1/logs/connections?outcome=maybe',
]) {
const response = await app.inject({ method: 'GET', url });
expect(response.statusCode, url).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({ code: 'LOG_VALIDATION_FAILED', status: 400 });
}
await app.close();
});
it('prunes connection logs and reports how many rows went away', async () => {
const { app } = fixture();
const response = await app.inject({
method: 'POST',
url: '/api/v1/logs/connections/prune',
payload: { before: '2026-09-05T00:00:00.000Z' },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json()).toEqual({ removed: 1 });
const empty = await app.inject({
method: 'POST',
url: '/api/v1/logs/connections/prune',
payload: {},
});
expect(empty.statusCode).toBe(400);
await app.close();
});
});
@@ -0,0 +1,153 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ConnectionLogError,
type ConnectionLogService,
} from '../../application/system/connection-log-service.js';
import {
LogCenterError,
type LogCenterService,
type LogLevel,
type LogSource,
} from '../../application/system/log-center-service.js';
export interface LogCenterRoutesOptions {
readonly logs: LogCenterService;
readonly connections: ConnectionLogService;
}
const OUTCOMES = ['success', 'stale', 'failed', 'unsupported'] as const;
const SOURCES = ['event', 'audit', 'schedule', 'delivery'] as const;
const LEVELS = ['info', 'warning', 'error'] as const;
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
detail: string,
): FastifyReply {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 400 ? 'Bad Request' : 'Internal Server Error',
status,
code,
detail,
requestId: request.id,
});
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
/** Rejects unknown keys so a typo in a filter cannot silently widen the result set. */
function assertKeys(value: Record<string, unknown>, allowed: readonly string[]): void {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length > 0)
throw new ConnectionLogError('VALIDATION_FAILED', `${unknown[0]} is invalid`);
}
function text(value: unknown, maximum: number): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.trim() === '' || value.length > maximum)
throw new ConnectionLogError('VALIDATION_FAILED', 'Filter value is invalid');
return value.trim();
}
function number(value: unknown, fallback: number, minimum: number, maximum: number): number {
if (value === undefined) return fallback;
const parsed = typeof value === 'string' ? Number(value) : Number(value);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum)
throw new ConnectionLogError('VALIDATION_FAILED', 'Pagination value is invalid');
return parsed;
}
function pick<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value))
throw new ConnectionLogError('VALIDATION_FAILED', 'Filter option is invalid');
return value as T;
}
async function action<T>(
request: FastifyRequest,
reply: FastifyReply,
operation: () => T | Promise<T>,
): Promise<T | FastifyReply> {
try {
return await operation();
} catch (error) {
if (error instanceof ConnectionLogError || error instanceof LogCenterError)
return problem(request, reply, 400, 'LOG_VALIDATION_FAILED', error.message);
throw error;
}
}
export function registerLogCenterRoutes(
app: FastifyInstance,
options: LogCenterRoutesOptions,
): void {
app.get('/api/v1/logs/runtime', async (request, reply) =>
action(request, reply, () => {
const query = record(request.query);
assertKeys(query, [
'page',
'pageSize',
'source',
'level',
'instanceId',
'search',
'from',
'to',
]);
return options.logs.listRuntimeLogs({
page: number(query.page, 1, 1, 100_000),
pageSize: number(query.pageSize, 50, 1, 200),
source: pick<LogSource>(query.source, SOURCES),
level: pick<LogLevel>(query.level, LEVELS),
instanceId: text(query.instanceId, 256),
search: text(query.search, 200),
from: text(query.from, 64),
to: text(query.to, 64),
});
}),
);
app.get('/api/v1/logs/connections', async (request, reply) =>
action(request, reply, () => {
const query = record(request.query);
assertKeys(query, ['page', 'pageSize', 'instanceId', 'outcome', 'search', 'from', 'to']);
return options.connections.list({
page: number(query.page, 1, 1, 100_000),
pageSize: number(query.pageSize, 50, 1, 200),
instanceId: text(query.instanceId, 256),
outcome: pick(query.outcome, OUTCOMES),
search: text(query.search, 200),
from: text(query.from, 64),
to: text(query.to, 64),
});
}),
);
app.get('/api/v1/logs/diagnostics', async (request, reply) =>
action(request, reply, () => ({ items: options.logs.diagnostics() })),
);
app.post('/api/v1/logs/connections/prune', { bodyLimit: 4_096 }, async (request, reply) =>
action(request, reply, () => {
const body = record(request.body);
assertKeys(body, ['before', 'instanceId']);
return {
removed: options.connections.prune({
before: text(body.before, 64),
instanceId: text(body.instanceId, 256),
}),
};
}),
);
}
@@ -0,0 +1,104 @@
import Database from 'better-sqlite3';
import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { DeviceOrganizationService } from '../../application/organization/device-organization-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerOrganizationRoutes } from './organization-routes.js';
let subject: { app: ReturnType<typeof Fastify>; db: Database.Database } | undefined;
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
const app = Fastify();
registerOrganizationRoutes(app, {
organization: new DeviceOrganizationService({ db, idFactory: () => 'group-1' }),
});
subject = { app, db };
return app;
}
afterEach(async () => {
await subject?.app.close();
subject?.db.close();
subject = undefined;
});
describe('organization routes', () => {
it('serves group CRUD', async () => {
const app = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/groups',
payload: { name: '总部', description: '办公区' },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ id: 'group-1', name: '总部' });
const listed = await app.inject({ method: 'GET', url: '/api/v1/groups' });
expect(listed.json()).toEqual({ items: [created.json()] });
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/groups/group-1',
payload: { name: '上海总部' },
});
expect(updated.json().name).toBe('上海总部');
const duplicate = await app.inject({
method: 'POST',
url: '/api/v1/groups',
payload: { name: '上海总部' },
});
expect(duplicate.statusCode).toBe(409);
const removed = await app.inject({ method: 'DELETE', url: '/api/v1/groups/group-1' });
expect(removed.statusCode).toBe(204);
expect((await app.inject({ method: 'GET', url: '/api/v1/groups' })).json()).toEqual({
items: [],
});
expect((await app.inject({ method: 'DELETE', url: '/api/v1/groups/gone' })).statusCode).toBe(
404,
);
});
it('serves tag CRUD with URL-encoded names', async () => {
const app = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/tags',
payload: { tag: '研发 / 一线', color: 'coral' },
});
expect(created.statusCode).toBe(201);
const listed = await app.inject({ method: 'GET', url: '/api/v1/tags' });
expect(listed.json().items).toEqual([
{
tag: '研发 / 一线',
color: 'coral',
deviceCount: 0,
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
]);
const updated = await app.inject({
method: 'PUT',
url: `/api/v1/tags/${encodeURIComponent('研发 / 一线')}`,
payload: { color: 'blue' },
});
expect(updated.json().color).toBe('blue');
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/tags/${encodeURIComponent('研发 / 一线')}`,
});
expect(removed.statusCode).toBe(204);
});
it('rejects malformed payloads with a problem document', async () => {
const app = fixture();
const response = await app.inject({ method: 'POST', url: '/api/v1/groups', payload: {} });
expect(response.statusCode).toBe(400);
expect(response.headers['content-type']).toContain('application/problem+json');
});
});
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
DeviceOrganizationError,
type DeviceOrganizationService,
} from '../../application/organization/device-organization-service.js';
import {
parseDeviceGroupInput,
parseDeviceGroupPatch,
parseDeviceTagInput,
parseDeviceTagPatch,
} from '@multi-simadmin/contracts';
export interface OrganizationRoutesOptions {
readonly organization: DeviceOrganizationService;
}
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
detail: string,
): FastifyReply {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 404 ? 'Not Found' : 'Bad Request',
status,
code,
detail,
requestId: request.id,
});
}
async function action<T>(
request: FastifyRequest,
reply: FastifyReply,
operation: () => T | Promise<T>,
): Promise<T | FastifyReply> {
try {
return await operation();
} catch (error) {
if (error instanceof DeviceOrganizationError) {
if (error.code === 'GROUP_NOT_FOUND')
return problem(request, reply, 404, 'DEVICE_GROUP_NOT_FOUND', error.message);
if (error.code === 'TAG_NOT_FOUND')
return problem(request, reply, 404, 'DEVICE_TAG_NOT_FOUND', error.message);
if (error.code === 'DUPLICATE_GROUP')
return problem(request, reply, 409, 'DEVICE_GROUP_EXISTS', error.message);
return problem(request, reply, 400, 'ORGANIZATION_VALIDATION_FAILED', error.message);
}
if (error instanceof TypeError)
return problem(request, reply, 400, 'ORGANIZATION_VALIDATION_FAILED', error.message);
throw error;
}
}
export function registerOrganizationRoutes(
app: FastifyInstance,
options: OrganizationRoutesOptions,
): void {
app.get('/api/v1/groups', async (request, reply) =>
action(request, reply, () => ({ items: options.organization.listGroups() })),
);
app.post('/api/v1/groups', { bodyLimit: 8_192 }, async (request, reply) =>
action(request, reply, () => {
const created = options.organization.createGroup(parseDeviceGroupInput(request.body));
reply.code(201);
return created;
}),
);
app.put('/api/v1/groups/:groupId', { bodyLimit: 8_192 }, async (request, reply) =>
action(request, reply, () =>
options.organization.updateGroup(
(request.params as { groupId: string }).groupId,
parseDeviceGroupPatch(request.body),
),
),
);
app.delete('/api/v1/groups/:groupId', async (request, reply) =>
action(request, reply, () => {
options.organization.deleteGroup((request.params as { groupId: string }).groupId);
reply.code(204);
return null;
}),
);
app.get('/api/v1/tags', async (request, reply) =>
action(request, reply, () => {
options.organization.synchronizeTags();
return { items: options.organization.listTags() };
}),
);
app.post('/api/v1/tags', { bodyLimit: 8_192 }, async (request, reply) =>
action(request, reply, () => {
const created = options.organization.createTag(parseDeviceTagInput(request.body));
reply.code(201);
return created;
}),
);
app.put('/api/v1/tags/:tag', { bodyLimit: 8_192 }, async (request, reply) =>
action(request, reply, () =>
options.organization.updateTag(
decodeURIComponent((request.params as { tag: string }).tag),
parseDeviceTagPatch(request.body),
),
),
);
app.delete('/api/v1/tags/:tag', async (request, reply) =>
action(request, reply, () => {
options.organization.deleteTag(decodeURIComponent((request.params as { tag: string }).tag));
reply.code(204);
return null;
}),
);
}
@@ -0,0 +1,316 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SystemMaintenanceService } from '../../application/system/system-maintenance-service.js';
import { ComponentBackupService } from '../../application/system/component-backup-service.js';
import { ConnectionSettingsService } from '../../application/connections/connection-settings-service.js';
import type { FleetHeartbeatCoordinator } from '../../application/connections/fleet-heartbeat.js';
import { buildApp } from '../../app.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { registerSystemRoutes } from './system-routes.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function fixture(heartbeat?: FleetHeartbeatCoordinator) {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const maintenance = new SystemMaintenanceService(db, {
version: 'test',
backupDirectory: join(tmpdir(), `multi-simadmin-test-${randomUUID()}`),
});
const componentBackups = new ComponentBackupService(db, {
version: 'test',
backupDirectory: join(tmpdir(), `multi-simadmin-test-${randomUUID()}`),
});
const connectionSettings = new ConnectionSettingsService({ db });
const app = buildApp({
registerRoutes: (scope) =>
registerSystemRoutes(scope, {
maintenance,
componentBackups,
connectionSettings,
...(heartbeat ? { heartbeat } : {}),
}),
});
return { app, db, componentBackups, connectionSettings };
}
describe('system maintenance routes', () => {
it('serves the native system overview', async () => {
const { app } = fixture();
const response = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance' });
expect(response.statusCode, response.body).toBe(200);
expect(response.json()).toMatchObject({
runtime: { version: 'test', platform: process.platform, arch: process.arch },
storage: { databaseBytes: expect.any(Number) },
retention: { auditEvents: { enabled: true, days: 180, maximumCount: 50_000 } },
});
await app.close();
});
it('updates retention, cleans selected components, optimizes, and creates backups', async () => {
const { app, db } = fixture();
db.prepare(
`INSERT INTO audit_events
(id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at)
VALUES ('old','test','test','R0','old','{}','success',0,'2020-01-01T00:00:00.000Z')`,
).run();
const retention = await app.inject({
method: 'PUT',
url: '/api/v1/system/maintenance/retention',
payload: { auditEvents: { enabled: true, days: 1, maximumCount: 100 } },
});
expect(retention.statusCode, retention.body).toBe(200);
const cleanup = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/cleanup',
payload: { components: ['auditEvents'] },
});
expect(cleanup.statusCode).toBe(200);
expect(cleanup.json()).toEqual({ auditEvents: 1 });
const optimize = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/optimize',
});
expect(optimize.statusCode).toBe(200);
const backup = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/backups',
});
expect(backup.statusCode).toBe(201);
expect(backup.json()).toMatchObject({
filename: expect.any(String),
sha256: expect.any(String),
});
const list = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance/backups' });
expect(list.statusCode).toBe(200);
expect(list.json().items).toHaveLength(1);
const filename = backup.json().filename as string;
const download = await app.inject({
method: 'GET',
url: `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
});
expect(download.statusCode).toBe(200);
expect(download.headers['content-disposition']).toBe(`attachment; filename="${filename}"`);
expect(download.headers['content-type']).toBe('application/octet-stream');
expect(Number(download.headers['content-length'])).toBe(download.rawPayload.length);
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
});
expect(removed.statusCode).toBe(200);
expect(removed.json()).toEqual({ filename });
expect(
(await app.inject({ method: 'GET', url: '/api/v1/system/maintenance/backups' })).json().items,
).toEqual([]);
expect(
(
await app.inject({
method: 'GET',
url: `/api/v1/system/maintenance/backups/${encodeURIComponent(filename)}`,
})
).statusCode,
).toBe(404);
await app.close();
});
it('rejects invalid maintenance actions with stable problems', async () => {
const { app } = fixture();
const traversal = await app.inject({
method: 'GET',
url: '/api/v1/system/maintenance/backups/multi-simadmin-%2e%2e%2fsecret.db',
});
expect(traversal.statusCode).toBe(400);
expect(traversal.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
const cleanup = await app.inject({
method: 'POST',
url: '/api/v1/system/maintenance/cleanup',
payload: { components: ['unknown'] },
});
expect(cleanup.statusCode).toBe(400);
expect(cleanup.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
const retention = await app.inject({
method: 'PUT',
url: '/api/v1/system/maintenance/retention',
payload: { auditEvents: { enabled: true, days: 0, maximumCount: 1 } },
});
expect(retention.statusCode).toBe(400);
expect(retention.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
await app.close();
});
it('creates, previews, restores, and deletes a component backup over HTTP', async () => {
const { app, db } = fixture();
db.prepare('INSERT INTO device_groups (id,name,created_at,updated_at) VALUES (?,?,?,?)').run(
'group-1',
'机房 A',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
const catalog = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups/catalog',
});
expect(catalog.statusCode, catalog.body).toBe(200);
expect(catalog.json().items).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'devices', rows: 1 })]),
);
const created = await app.inject({
method: 'POST',
url: '/api/v1/system/component-backups',
payload: { components: ['devices'], note: '升级前' },
});
expect(created.statusCode, created.body).toBe(201);
const filename = created.json().filename as string;
const listed = await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' });
expect(listed.json().items).toHaveLength(1);
const preview = await app.inject({
method: 'GET',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/preview`,
});
expect(preview.statusCode, preview.body).toBe(200);
expect(preview.json()).toMatchObject({ integrity: 'ok', note: '升级前' });
db.prepare('DELETE FROM device_groups').run();
const restored = await app.inject({
method: 'POST',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/restore`,
payload: { components: ['devices'] },
});
expect(restored.statusCode, restored.body).toBe(200);
expect(restored.json()).toMatchObject({ devices: 1 });
expect(
(db.prepare('SELECT COUNT(*) AS count FROM device_groups').get() as { count: number }).count,
).toBe(1);
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}`,
});
expect(removed.statusCode).toBe(200);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' })).json(),
).toEqual({
items: [],
});
await app.close();
});
it('serves the automatic backup plan and rejects a bad component list', async () => {
const { app } = fixture();
const settings = await app.inject({
method: 'PUT',
url: '/api/v1/system/component-backups/auto/settings',
payload: {
enabled: true,
components: ['sms'],
timeOfDay: '04:15',
weekday: 1,
maximumCount: 5,
},
});
expect(settings.statusCode, settings.body).toBe(200);
expect(settings.json()).toMatchObject({ enabled: true, timeOfDay: '04:15', weekday: 1 });
const read = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups/auto/settings',
});
expect(read.json()).toMatchObject({ components: ['sms'], maximumCount: 5 });
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/system/component-backups',
payload: { components: ['secrets'] },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'COMPONENT_BACKUP_VALIDATION_FAILED' });
const missing = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups/multi-simadmin-components-2026-01-01T00-00-00-000Z.json/preview',
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ code: 'COMPONENT_BACKUP_NOT_FOUND' });
await app.close();
});
});
describe('connection settings routes', () => {
it('reads the defaults, persists an update, and refuses a beat without a coordinator', async () => {
const { app, connectionSettings } = fixture();
const read = await app.inject({ method: 'GET', url: '/api/v1/system/connection' });
expect(read.statusCode, read.body).toBe(200);
expect(read.json()).toEqual({ heartbeatSeconds: 30, offlineSeconds: 90 });
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/system/connection',
payload: { heartbeatSeconds: 45, offlineSeconds: 120 },
});
expect(updated.statusCode, updated.body).toBe(200);
expect(updated.json()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(connectionSettings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(connectionSettings.snapshotTtlMs).toBe(120_000);
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
expect(refresh.statusCode).toBe(501);
expect(refresh.json()).toMatchObject({ code: 'HEARTBEAT_UNAVAILABLE' });
await app.close();
});
it('rejects out-of-range cadence with a stable problem', async () => {
const { app } = fixture();
const cases: readonly Record<string, number>[] = [
{ heartbeatSeconds: 4, offlineSeconds: 90 },
{ heartbeatSeconds: 301, offlineSeconds: 900 },
{ heartbeatSeconds: 30, offlineSeconds: 59 },
{ heartbeatSeconds: 30, offlineSeconds: 1801 },
];
for (const payload of cases) {
const response = await app.inject({
method: 'PUT',
url: '/api/v1/system/connection',
payload,
});
expect(response.statusCode, `${JSON.stringify(payload)} -> ${response.body}`).toBe(400);
expect(response.json()).toMatchObject({ code: 'MAINTENANCE_VALIDATION_FAILED' });
}
expect((await app.inject({ method: 'GET', url: '/api/v1/system/connection' })).json()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
});
await app.close();
});
it('reports the beat a coordinator just ran', async () => {
const runOnce = vi.fn(async () => ({
probed: 4,
failed: 1,
startedAt: '2026-09-05T00:00:00.000Z',
finishedAt: '2026-09-05T00:00:01.000Z',
}));
const { app } = fixture({ runOnce } as unknown as FleetHeartbeatCoordinator);
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
expect(refresh.statusCode, refresh.body).toBe(200);
expect(refresh.json()).toMatchObject({ probed: 4, failed: 1 });
expect(runOnce).toHaveBeenCalledTimes(1);
await app.close();
});
});
@@ -0,0 +1,213 @@
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() })),
);
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.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)),
);
}