feat(api): integrate identity, notification scope and connection settings into control plane

- Wire identity, metrics and update services into the assembled API.

- Add connection authorization modes and scoped console authorization.

- Preserve notification group IDs for filtered delivery.

- Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
This commit is contained in:
chick
2026-09-07 00:45:18 +08:00
parent 7168e9dba2
commit 635527dd26
22 changed files with 754 additions and 46 deletions
@@ -13,6 +13,7 @@ export interface CentralNotificationRoutesOptions {
readonly name: string;
readonly state: string;
readonly tags: readonly string[];
readonly groupId: string | null;
}[]
>;
}
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { Writable } from 'node:stream';
import { buildApp } from '../../app.js';
import { ConsoleAuthService } from '../../application/auth/console-auth-service.js';
@@ -15,18 +16,32 @@ function fixture() {
db.pragma('foreign_keys = ON');
migrateDatabase(db);
dbs.push(db);
let now = new Date('2026-07-19T00:00:00.000Z');
const auth = new ConsoleAuthService({ db, now: () => now });
const clock = { now: new Date('2026-07-19T00:00:00.000Z') };
const auth = new ConsoleAuthService({ db, now: () => clock.now });
let logs = '';
const app = buildApp({
logger: {
stream: new (class extends Writable {
override write(chunk: Buffer | string): boolean {
logs += chunk.toString();
return true;
}
})(),
},
registerRoutes: (fastify) => {
registerConsoleAuth(fastify, auth);
fastify.get('/api/v1/protected-probe', async () => ({ ok: true }));
},
});
const advance = (milliseconds: number) => {
clock.now = new Date(clock.now.getTime() + milliseconds);
};
return {
app,
db,
auth,
advance: (milliseconds: number) => (now = new Date(now.getTime() + milliseconds)),
advance,
logs: () => logs,
};
}
@@ -44,6 +59,7 @@ describe('aggregate-console password protection HTTP boundary', () => {
configured: false,
protectionEnabled: false,
authenticated: true,
sessionIdleTimeoutMinutes: 30,
});
expect(JSON.stringify(response.json())).not.toMatch(/hash|salt|password/i);
await app.close();
@@ -225,6 +241,50 @@ describe('aggregate-console password protection HTTP boundary', () => {
await app.close();
});
it('expires sessions after configurable browser-idle inactivity', async () => {
const { app, auth, advance, logs } = fixture();
const enable = await app.inject({
method: 'PUT',
url: '/api/v1/auth/settings',
payload: { enabled: true, newPassword: PASSWORD },
});
expect(enable.statusCode).toBe(200);
expect(enable.json()).toMatchObject({ sessionIdleTimeoutMinutes: 30 });
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { password: PASSWORD },
});
const cookie = String(login.headers['set-cookie']).split(';', 1)[0] ?? '';
expect(
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
).toMatchObject({ authenticated: true, sessionIdleTimeoutMinutes: 30 });
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/auth/settings',
headers: { cookie },
payload: { enabled: true, idleTimeoutMinutes: 10 },
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({ sessionIdleTimeoutMinutes: 10 });
expect(auth.status().sessionIdleTimeoutMinutes).toBe(10);
advance(9 * 60 * 1000);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/protected-probe', headers: { cookie } }))
.statusCode,
).toBe(200);
advance(11 * 60 * 1000);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
).toMatchObject({ authenticated: false });
expect(logs()).not.toContain('request failed');
await app.close();
});
it('rejects anonymous password replacement while a stored credential is disabled', async () => {
const { app } = fixture();
const enable = await app.inject({
@@ -73,6 +73,7 @@ const settingsSchema = {
properties: {
enabled: { type: 'boolean' },
newPassword: { type: 'string', minLength: 1, maxLength: 1024 },
idleTimeoutMinutes: { type: 'number', minimum: 5, maximum: 1440, multipleOf: 1 },
},
} as const;
const loginSchema = {
@@ -88,7 +89,9 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
if (!auth.isProtectedPath(pathname)) return;
try {
auth.requireSession(parseCookie(request));
const token = parseCookie(request);
auth.requireSession(token);
auth.touchSession(token);
} catch (error) {
return handle(request, reply, error);
}
@@ -133,7 +136,11 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
app.put('/api/v1/auth/settings', { schema: { body: settingsSchema } }, async (request, reply) => {
try {
const body = request.body as { enabled: boolean; newPassword?: string };
const body = request.body as {
enabled: boolean;
newPassword?: string;
idleTimeoutMinutes?: number;
};
const result = await auth.updateSettings(body, parseCookie(request));
if (result.sessionToken)
reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request)));
@@ -169,6 +169,8 @@ describe('system maintenance routes', () => {
expect(catalog.json().items).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'devices', rows: 1 })]),
);
expect(typeof catalog.json().directory).toBe('string');
expect(catalog.json().directory.length).toBeGreaterThan(0);
const created = await app.inject({
method: 'POST',
@@ -188,6 +190,15 @@ describe('system maintenance routes', () => {
expect(preview.statusCode, preview.body).toBe(200);
expect(preview.json()).toMatchObject({ integrity: 'ok', note: '升级前' });
const download = await app.inject({
method: 'GET',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/download`,
});
expect(download.statusCode, download.body).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);
db.prepare('DELETE FROM device_groups').run();
const restored = await app.inject({
method: 'POST',
@@ -195,16 +206,32 @@ describe('system maintenance routes', () => {
payload: { components: ['devices'] },
});
expect(restored.statusCode, restored.body).toBe(200);
expect(restored.json()).toMatchObject({ devices: 1 });
const restoredBody = restored.json() as {
written: Record<string, number>;
safetyBackup: string;
};
expect(restoredBody.written).toEqual({ devices: 1 });
expect(restoredBody.safetyBackup.length).toBeGreaterThan(0);
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)}`,
// The pre-restore snapshot is a normal backup file, listed and downloadable like any other.
const afterRestore = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups',
});
expect(removed.statusCode).toBe(200);
const names = (afterRestore.json().items as { filename: string; note: string }[]).map(
(item) => item.note,
);
expect(names.some((note) => note.startsWith('恢复前自动备份:'))).toBe(true);
for (const name of [filename, restoredBody.safetyBackup]) {
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/component-backups/${encodeURIComponent(name)}`,
});
expect(removed.statusCode).toBe(200);
}
expect(
(await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' })).json(),
).toEqual({
@@ -257,16 +284,28 @@ describe('connection settings routes', () => {
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 });
expect(read.json()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/system/connection',
payload: { heartbeatSeconds: 45, offlineSeconds: 120 },
payload: { heartbeatSeconds: 45, offlineSeconds: 120, authorizationMode: 'manual' },
});
expect(updated.statusCode, updated.body).toBe(200);
expect(updated.json()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(connectionSettings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(updated.json()).toEqual({
heartbeatSeconds: 45,
offlineSeconds: 120,
authorizationMode: 'manual',
});
expect(connectionSettings.get()).toEqual({
heartbeatSeconds: 45,
offlineSeconds: 120,
authorizationMode: 'manual',
});
expect(connectionSettings.snapshotTtlMs).toBe(120_000);
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
@@ -295,6 +334,7 @@ describe('connection settings routes', () => {
expect((await app.inject({ method: 'GET', url: '/api/v1/system/connection' })).json()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
await app.close();
});
+21 -1
View File
@@ -161,7 +161,7 @@ export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutes
app.get(
'/api/v1/system/component-backups/catalog',
wrap(async () => ({ items: backups().catalog() })),
wrap(async () => ({ items: backups().catalog(), directory: backups().directory() })),
);
app.get(
'/api/v1/system/component-backups/auto/settings',
@@ -196,6 +196,26 @@ export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutes
'/api/v1/system/component-backups/:filename/preview',
wrap(async (request) => backups().preview((request.params as { filename: string }).filename)),
);
app.get(
'/api/v1/system/component-backups/:filename/download',
wrap(async (request, reply) => {
const backup = await backups().backupFile((request.params as { filename: string }).filename);
if (!backup)
return reply.code(404).type('application/problem+json').send({
type: 'about:blank',
title: 'Not Found',
status: 404,
code: 'COMPONENT_BACKUP_NOT_FOUND',
detail: 'The requested component backup file no longer exists.',
requestId: request.id,
});
reply
.header('content-disposition', `attachment; filename="${backup.filename}"`)
.header('content-length', String(backup.sizeBytes))
.type('application/octet-stream');
return createReadStream(backup.path);
}),
);
app.post(
'/api/v1/system/component-backups/:filename/restore',
{ bodyLimit: 8_192 },