diff --git a/apps/api/src/application/audit/audit-query-service.ts b/apps/api/src/application/audit/audit-query-service.ts index f762556..f799e14 100644 --- a/apps/api/src/application/audit/audit-query-service.ts +++ b/apps/api/src/application/audit/audit-query-service.ts @@ -1,6 +1,7 @@ import type Database from 'better-sqlite3'; import { AUDIT_OUTCOMES, + type AuditOutcome, type AuditEvent, type AuditPage, type AuditPageQuery, @@ -19,6 +20,26 @@ export class AuditQueryError extends Error { } } +export interface ControlPlaneAuditSummary { + readonly total: number; + readonly succeeded: number; + readonly failed: number; + readonly partiallySucceeded: number; + readonly denied: number; + readonly recent: readonly ControlPlaneAuditSummaryEvent[]; +} + +export interface ControlPlaneAuditSummaryEvent { + readonly id: string; + readonly occurredAt: string; + readonly actorId: string; + readonly action: string; + readonly outcome: AuditEvent['outcome']; + readonly requestId: string; + readonly instanceId?: string; + readonly jobId?: string; +} + interface AuditRow { id: unknown; instance_id: unknown; @@ -118,6 +139,63 @@ function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem export class AuditQueryService { constructor(private readonly db: Database.Database) {} + summary(limit = 5): ControlPlaneAuditSummary { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) validation('limit is invalid'); + const totalRow = this.db.prepare('SELECT COUNT(*) AS total FROM audit_events').get() as + | { + total: unknown; + } + | undefined; + if ( + !totalRow || + typeof totalRow.total !== 'number' || + !Number.isSafeInteger(totalRow.total) || + totalRow.total < 0 + ) + fail('Persisted audit count is invalid'); + const outcomeRows = this.db + .prepare('SELECT result_code, COUNT(*) AS count FROM audit_events GROUP BY result_code') + .all() as Array<{ result_code: unknown; count: unknown }>; + const counts = new Map(); + for (const row of outcomeRows) { + const outcome = boundedString(row.result_code, 'result_code'); + if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome)) + fail('Persisted audit result_code is unrepresentable'); + if (!Number.isSafeInteger(row.count) || (row.count as number) < 0) + fail('Persisted audit outcome count is invalid'); + counts.set(outcome, row.count as number); + } + const count = (outcome: AuditOutcome): number => counts.get(outcome) ?? 0; + const recentRows = this.db + .prepare(`SELECT ${COLUMNS} FROM audit_events ORDER BY created_at DESC, id ASC LIMIT ?`) + .all(limit) as AuditRow[]; + const recent = recentRows.map((row) => { + const outcome = boundedString(row.result_code, 'result_code'); + if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome)) + fail('Persisted audit result_code is unrepresentable'); + const instanceId = optionalString(row.instance_id, 'instance_id'); + const jobId = optionalString(row.job_id, 'job_id'); + return freeze({ + id: boundedString(row.id, 'id'), + occurredAt: canonicalTimestamp(row.created_at, 'created_at'), + actorId: boundedString(row.actor, 'actor'), + action: boundedString(row.operation_id, 'operation_id'), + outcome: outcome as ControlPlaneAuditSummaryEvent['outcome'], + requestId: boundedString(row.request_id, 'request_id'), + ...(instanceId === undefined ? {} : { instanceId }), + ...(jobId === undefined ? {} : { jobId }), + }); + }); + return freeze({ + total: totalRow.total, + succeeded: count('succeeded'), + failed: count('failed'), + partiallySucceeded: count('partially-succeeded'), + denied: count('denied'), + recent: freeze(recent), + }); + } + get(id: string): AuditEvent { inputString(id, 'eventId'); const row = this.db.prepare(`SELECT ${COLUMNS} FROM audit_events WHERE id = ?`).get(id) as diff --git a/apps/api/src/application/automation/schedule-time.test.ts b/apps/api/src/application/automation/schedule-time.test.ts index c0ff8b1..1e37e4e 100644 --- a/apps/api/src/application/automation/schedule-time.test.ts +++ b/apps/api/src/application/automation/schedule-time.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { nextOccurrence, previewCron, reconcileOccurrence } from './schedule-time.js'; +import { + nextOccurrence, + nextOccurrenceFor, + previewCron, + previewTrigger, + reconcileOccurrence, +} from './schedule-time.js'; describe('schedule time', () => { it('calculates future occurrences in Beijing time across the UTC day boundary', () => { @@ -44,4 +50,69 @@ describe('schedule time', () => { nextDueAt: '2026-07-30T01:30:00.000Z', }); }); + + it('expands Hub fixed schedules over the Beijing weekday and time grid', () => { + const weekdays = { kind: 'fixed' as const, weekdays: [4, 5], times: ['09:00', '21:30'] }; + // 2026-07-30 is a Thursday; 01:25Z is 09:25 in Beijing, so the same-day 21:30 slot is next. + expect(previewTrigger(weekdays, 2, new Date('2026-07-30T01:25:00.000Z'))).toEqual([ + '2026-07-30T13:30:00.000Z', + '2026-07-31T01:00:00.000Z', + ]); + expect( + nextOccurrenceFor( + { kind: 'fixed', weekdays: [6], times: ['00:30'] }, + new Date('2026-07-30T01:25:00.000Z'), + ), + ).toBe('2026-07-31T16:30:00.000Z'); + }); + + it('keeps interval schedules anchored on the previous occurrence', () => { + const interval = { kind: 'interval' as const, value: 90, unit: 'mins' as const }; + expect(nextOccurrenceFor(interval, new Date('2026-07-30T01:00:00.000Z'))).toBe( + '2026-07-30T02:30:00.000Z', + ); + expect( + nextOccurrenceFor( + interval, + new Date('2026-07-30T05:00:00.000Z'), + new Date('2026-07-30T01:00:00.000Z'), + ), + ).toBe('2026-07-30T05:30:00.000Z'); + expect(previewTrigger(interval, 3, new Date('2026-07-30T01:00:00.000Z'))).toEqual([ + '2026-07-30T02:30:00.000Z', + '2026-07-30T04:00:00.000Z', + '2026-07-30T05:30:00.000Z', + ]); + }); + + it('reconciles fixed and interval misfires against the stored due time', () => { + expect( + reconcileOccurrence( + { + trigger: { kind: 'interval', value: 90, unit: 'mins' }, + nextDueAt: '2026-07-30T04:00:00.000Z', + misfirePolicy: 'catch-up-once', + }, + new Date('2026-07-30T05:00:00.000Z'), + ), + ).toEqual({ + action: 'run', + dueAt: '2026-07-30T04:00:00.000Z', + nextDueAt: '2026-07-30T05:30:00.000Z', + }); + expect( + reconcileOccurrence( + { + trigger: { kind: 'fixed', weekdays: [4, 5], times: ['09:00', '21:30'] }, + nextDueAt: '2026-07-30T01:00:00.000Z', + misfirePolicy: 'skip', + }, + new Date('2026-07-30T01:25:00.000Z'), + ), + ).toEqual({ + action: 'skip', + dueAt: '2026-07-30T01:00:00.000Z', + nextDueAt: '2026-07-30T13:30:00.000Z', + }); + }); }); diff --git a/apps/api/src/application/automation/schedule-time.ts b/apps/api/src/application/automation/schedule-time.ts index cefb621..be6ebf6 100644 --- a/apps/api/src/application/automation/schedule-time.ts +++ b/apps/api/src/application/automation/schedule-time.ts @@ -1,7 +1,14 @@ -import type { ScheduleMisfirePolicy } from '@multi-simadmin/contracts'; +import type { + ScheduleMisfirePolicy, + ScheduleTrigger, + ScheduledTask, +} from '@multi-simadmin/contracts'; +import { intervalMilliseconds } from '@multi-simadmin/contracts'; import { CronExpressionParser } from 'cron-parser'; const TIMEZONE = 'Asia/Shanghai'; +/** Asia/Shanghai has observed UTC+8 without DST since 1991, so fixed-offset arithmetic is exact. */ +const ZONE_OFFSET_MS = 8 * 60 * 60 * 1_000; function validateExpression(expression: string): string { const clean = expression.trim().replace(/\s+/g, ' '); @@ -33,6 +40,95 @@ export function previewCron( return Array.from({ length: count }, () => interval.next().toDate().toISOString()); } +function shanghaiParts(instant: Date) { + const shifted = new Date(instant.getTime() + ZONE_OFFSET_MS); + const weekday = shifted.getUTCDay(); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth(), + date: shifted.getUTCDate(), + hour: shifted.getUTCHours(), + minute: shifted.getUTCMinutes(), + // ISO weekday: Monday is 1 and Sunday is 7, matching the Hub weekday vocabulary. + weekday: weekday === 0 ? 7 : weekday, + }; +} + +function shanghaiInstant(year: number, month: number, date: number, hour: number, minute: number) { + return new Date(Date.UTC(year, month, date, hour, minute) - ZONE_OFFSET_MS); +} + +/** + * Minutes since local midnight in Asia/Shanghai. Shared with the notification engine so quiet + * hours and scheduled windows read from one clock. + */ +export function shanghaiMinuteOfDay(instant: Date): number { + const parts = shanghaiParts(instant); + return parts.hour * 60 + parts.minute; +} + +function fixedOccurrenceAfter(trigger: Extract, after: Date) { + const start = shanghaiParts(after); + for (let offset = 0; offset <= 8; offset += 1) { + const day = shanghaiInstant(start.year, start.month, start.date + offset, 0, 0); + const parts = shanghaiParts(day); + if (!trigger.weekdays.includes(parts.weekday)) continue; + for (const time of trigger.times) { + const [hour, minute] = time.split(':'); + const candidate = shanghaiInstant( + parts.year, + parts.month, + parts.date, + Number(hour), + Number(minute), + ); + if (candidate.getTime() > after.getTime()) return candidate; + } + } + return undefined; +} + +function intervalOccurrenceAfter( + trigger: Extract, + after: Date, + anchor?: Date, +) { + const period = intervalMilliseconds(trigger); + if (anchor && anchor.getTime() < after.getTime()) { + // Keep the period anchored on the previous occurrence instead of drifting with the clock. + const steps = Math.floor((after.getTime() - anchor.getTime()) / period) + 1; + return new Date(anchor.getTime() + steps * period); + } + return new Date(after.getTime() + period); +} + +export function nextOccurrenceFor(trigger: ScheduleTrigger, after: Date, anchor?: Date): string { + if (trigger.kind === 'cron') return nextOccurrence(trigger.expression, after); + if (trigger.kind === 'fixed') { + const next = fixedOccurrenceAfter(trigger, after); + if (!next) throw new TypeError('Fixed schedule has no occurrence within eight days'); + return next.toISOString(); + } + return intervalOccurrenceAfter(trigger, after, anchor).toISOString(); +} + +export function previewTrigger( + trigger: ScheduleTrigger, + count: number, + from = new Date(), +): readonly string[] { + if (!Number.isSafeInteger(count) || count < 1 || count > 10) + throw new TypeError('Schedule preview count must be between 1 and 10'); + const occurrences: string[] = []; + let cursor = from; + for (let index = 0; index < count; index += 1) { + const next = nextOccurrenceFor(trigger, cursor, from); + occurrences.push(next); + cursor = new Date(next); + } + return occurrences; +} + export function nextOccurrence(expression: string, after: Date): string { return parser(expression, after).next().toDate().toISOString(); } @@ -43,7 +139,8 @@ export type ReconciledOccurrence = export function reconcileOccurrence( task: { - readonly cronExpression: string; + readonly trigger?: ScheduledTask['trigger']; + readonly cronExpression?: string; readonly nextDueAt: string; readonly misfirePolicy: ScheduleMisfirePolicy; }, @@ -54,9 +151,18 @@ export function reconcileOccurrence( if (nextDue.getTime() > now.getTime()) return { action: 'wait', dueAt: task.nextDueAt, nextDueAt: task.nextDueAt }; - const interval = parser(task.cronExpression, now); - const dueAt = interval.prev().toDate().toISOString(); - const nextDueAt = nextOccurrence(task.cronExpression, now); + const trigger = task.trigger ?? { + kind: 'cron' as const, + expression: task.cronExpression ?? task.nextDueAt, + }; + let dueAt: string; + if (trigger.kind === 'cron') { + dueAt = parser(trigger.expression, now).prev().toDate().toISOString(); + } else { + // For fixed and interval triggers the stored due time is the authoritative occurrence. + dueAt = task.nextDueAt; + } + const nextDueAt = nextOccurrenceFor(trigger, now, nextDue); return { action: task.misfirePolicy === 'catch-up-once' ? 'run' : 'skip', dueAt, diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts index 4e3d39b..c564e5f 100644 --- a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts @@ -122,4 +122,51 @@ describe('ScheduledOperationDispatcher', () => { expect(sleep).toHaveBeenCalledWith(30_000); db.close(); }); + + it('dispatches a baseband task against the baseband restart operation', async () => { + const db = new Database(':memory:'); + migrateDatabase(db); + const prepare = vi.fn(async (input: { operationId: string; parameters: unknown }) => ({ + id: 'prep-a', + confirmationToken: 'token', + ...input, + })); + const dispatcher = new ScheduledOperationDispatcher({ + db, + operations: { + prepare, + execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }), + }, + messages: { send: vi.fn() }, + store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, + repository: new ScheduledTaskRepository(db), + }); + const result = await dispatcher.dispatch( + { + id: 'task-baseband', + name: 'Baseband', + operationType: 'restart-baseband', + cronExpression: '0 4 * * *', + timezone: 'Asia/Shanghai', + targetSelector: { mode: 'fixed', instanceIds: ['a'] }, + misfirePolicy: 'skip', + overlapPolicy: 'skip', + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: true, + version: 1, + createdBy: 'operator', + updatedBy: 'operator', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + }, + [{ id: 'a', revision: 1 }], + { actor: 'operator', requestId: 'request-1' }, + ); + expect(result.outcome).toBe('succeeded'); + expect(prepare.mock.calls[0]?.[0]).toMatchObject({ + operationId: 'postBasebandRestart', + parameters: { parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1' }, + }); + db.close(); + }); }); diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.ts index 91ef23f..fe3566b 100644 --- a/apps/api/src/application/automation/scheduled-operation-dispatcher.ts +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.ts @@ -28,10 +28,15 @@ interface MessageExecutor { ): Promise<{ readonly sent: true }>; } +interface BackupExecutor { + createBackup(): Promise<{ readonly filename: string; readonly sizeBytes: number }>; +} + export interface ScheduledOperationDispatcherOptions { readonly db: SqliteDatabase; readonly operations: Pick | OperationExecutor; readonly messages: Pick | MessageExecutor; + readonly maintenance?: BackupExecutor; readonly store: SecretStore; readonly repository: ScheduledTaskRepository; readonly now?: () => Date; @@ -39,6 +44,18 @@ export interface ScheduledOperationDispatcherOptions { readonly sleep?: (milliseconds: number) => Promise; } +const operationSchemas: Record = { + 'restart-service': 'simadmin.58e2204.postServiceRestart.parameters.v1', + 'reboot-system': 'simadmin.58e2204.postSystemReboot.parameters.v1', + 'restart-baseband': 'simadmin.58e2204.postBasebandRestart.parameters.v1', +}; + +const operationNames: Record = { + 'restart-service': 'postServiceRestart', + 'reboot-system': 'postSystemReboot', + 'restart-baseband': 'postBasebandRestart', +}; + function aggregate(successes: number, failures: number): DispatchResult['outcome'] { if (successes > 0 && failures > 0) return 'partially-succeeded'; return failures > 0 ? 'failed' : 'succeeded'; @@ -63,15 +80,20 @@ export class ScheduledOperationDispatcher { context: { readonly actor: string; readonly requestId: string }, ): Promise { if (task.operationType === 'send-sms') return this.dispatchSms(task, targets, context); - const operationId = - task.operationType === 'restart-service' ? 'postServiceRestart' : 'postSystemReboot'; - const schema = - task.operationType === 'restart-service' - ? 'simadmin.58e2204.postServiceRestart.parameters.v1' - : 'simadmin.58e2204.postSystemReboot.parameters.v1'; + if (task.operationType === 'backup-data') return this.dispatchBackup(task); + const operationId = operationNames[task.operationType]; + const schema = operationSchemas[task.operationType]; + if (!operationId || !schema) + return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] }; const fields = task.operationType === 'reboot-system' - ? [{ fieldId: 'delay_seconds', kind: 'number' as const, value: 3 }] + ? [ + { + fieldId: 'delay_seconds', + kind: 'number' as const, + value: task.delaySeconds ?? 3, + }, + ] : []; const jobIds: string[] = []; let successes = 0; @@ -120,7 +142,7 @@ export class ScheduledOperationDispatcher { return { outcome: 'needs-attention', reason: 'SMS secret is missing', jobIds: [] }; const secret = await this.options.store.get(reference); if (!secret) return { outcome: 'needs-attention', reason: 'SMS secret is missing', jobIds: [] }; - let payload: { recipients: string[]; content: string }; + let payload: { recipients: string[]; content: string; randomDelaySeconds?: number }; try { payload = JSON.parse(secret) as typeof payload; if ( @@ -147,7 +169,11 @@ export class ScheduledOperationDispatcher { private async recordSmsJob( instanceId: string, - payload: { recipients: readonly string[]; content: string }, + payload: { + readonly recipients: readonly string[]; + readonly content: string; + readonly randomDelaySeconds?: number; + }, retryPolicy: ScheduledTask['retryPolicy'], context: { readonly actor: string; readonly requestId: string }, ): Promise<{ readonly id: string; readonly succeeded: boolean }> { @@ -184,6 +210,9 @@ export class ScheduledOperationDispatcher { let succeeded = true; for (const recipient of payload.recipients) { let delivered = false; + // Hub parity: per-recipient jitter so bulk schedules do not fire in one burst. + const jitter = Math.max(0, Math.floor(payload.randomDelaySeconds ?? 0)); + if (jitter > 0) await this.sleep(Math.floor(Math.random() * (jitter + 1)) * 1_000); for (let attempt = 0; attempt <= retryPolicy.maxRetries; attempt += 1) { try { await this.options.messages.send(instanceId, { @@ -237,4 +266,24 @@ export class ScheduledOperationDispatcher { })(); return { id: ids.job, succeeded }; } + + private async dispatchBackup(task: ScheduledTask): Promise { + const maintenance = this.options.maintenance; + if (!maintenance) + return { + outcome: 'needs-attention', + reason: 'Backup maintenance service is unavailable', + jobIds: [], + }; + try { + const backup = await maintenance.createBackup(); + return { + outcome: 'succeeded', + reason: `Backup ${backup.filename} (${backup.sizeBytes} bytes) created for schedule ${task.name}`, + jobIds: [], + }; + } catch { + return { outcome: 'failed', reason: 'Control-plane backup failed', jobIds: [] }; + } + } } diff --git a/apps/api/src/application/automation/scheduled-task-repository.ts b/apps/api/src/application/automation/scheduled-task-repository.ts index d9b4cd2..fe10886 100644 --- a/apps/api/src/application/automation/scheduled-task-repository.ts +++ b/apps/api/src/application/automation/scheduled-task-repository.ts @@ -1,10 +1,12 @@ import type { CreateScheduledTaskRequest, + ScheduleTrigger, ScheduledRun, ScheduledRunOutcome, ScheduledRunTriggerSource, ScheduledTask, } from '@multi-simadmin/contracts'; +import { scheduleTriggerOf } from '@multi-simadmin/contracts'; import type { SqliteDatabase } from '../../infrastructure/database/database.js'; @@ -24,6 +26,8 @@ interface ScheduledTaskRow { misfire_policy: ScheduledTask['misfirePolicy']; overlap_policy: ScheduledTask['overlapPolicy']; retry_policy_json: string; + trigger_json: string | null; + delay_seconds: number | null; next_due_at: string | null; last_evaluated_at: string | null; created_by: string; @@ -127,16 +131,23 @@ function parseJson(value: string, label: string): T { } function projectTask(row: ScheduledTaskRow): ScheduledTask { + const trigger: ScheduleTrigger = row.trigger_json + ? parseJson(row.trigger_json, 'schedule trigger') + : { kind: 'cron', expression: row.cron_expression }; return { id: row.id, name: row.name, operationType: row.operation_type, + trigger, cronExpression: row.cron_expression, timezone: row.timezone, targetSelector: parseJson(row.target_selector_json, 'target selector'), ...(row.sms_secret_reference ? { sms: { configured: true, recipientCount: row.sms_recipient_count ?? 0 } } : {}), + ...(row.delay_seconds === null || row.delay_seconds === undefined + ? {} + : { delaySeconds: row.delay_seconds }), ...(row.effective_start_at ? { effectiveStartAt: row.effective_start_at } : {}), ...(row.effective_end_at ? { effectiveEndAt: row.effective_end_at } : {}), misfirePolicy: row.misfire_policy, @@ -184,8 +195,9 @@ export class ScheduledTaskRepository { `INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy, - overlap_policy,retry_policy_json,next_due_at,created_by,updated_by,created_at,updated_at) - VALUES (?,?,?,?,1,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + overlap_policy,retry_policy_json,trigger_json,delay_seconds,next_due_at,created_by, + updated_by,created_at,updated_at) + VALUES (?,?,?,?,1,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) .run( input.id, @@ -202,6 +214,8 @@ export class ScheduledTaskRepository { task.misfirePolicy, task.overlapPolicy, JSON.stringify(task.retryPolicy), + JSON.stringify(scheduleTriggerOf(task)), + task.delaySeconds ?? null, input.nextDueAt ?? null, input.createdBy, input.createdBy, @@ -222,6 +236,7 @@ export class ScheduledTaskRepository { cron_expression = ?, timezone = ?, target_selector_json = ?, sms_secret_reference = ?, sms_recipient_count = ?, effective_start_at = ?, effective_end_at = ?, misfire_policy = ?, overlap_policy = ?, retry_policy_json = ?, + trigger_json = ?, delay_seconds = ?, next_due_at = ?, last_evaluated_at = NULL, updated_by = ?, updated_at = ? WHERE id = ? AND version = ? AND deleted_at IS NULL`, ) @@ -239,6 +254,8 @@ export class ScheduledTaskRepository { task.misfirePolicy, task.overlapPolicy, JSON.stringify(task.retryPolicy), + JSON.stringify(scheduleTriggerOf(task)), + task.delaySeconds ?? null, input.nextDueAt ?? null, input.updatedBy, input.now, diff --git a/apps/api/src/application/automation/scheduled-task-service.ts b/apps/api/src/application/automation/scheduled-task-service.ts index 7bfc6c7..4fcea48 100644 --- a/apps/api/src/application/automation/scheduled-task-service.ts +++ b/apps/api/src/application/automation/scheduled-task-service.ts @@ -1,14 +1,17 @@ import { randomUUID } from 'node:crypto'; import { parseCreateScheduledTaskRequest, + parseScheduleTrigger, parseUpdateScheduledTaskRequest, + scheduleTriggerOf, type CreateScheduledTaskRequest, + type ScheduleTrigger, type ScheduledTask, type ScheduledSmsInput, } from '@multi-simadmin/contracts'; import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; -import { nextOccurrence, previewCron } from './schedule-time.js'; +import { nextOccurrenceFor, previewTrigger } from './schedule-time.js'; import { ScheduledTaskRepository } from './scheduled-task-repository.js'; export interface ScheduledTaskServiceOptions { @@ -37,7 +40,7 @@ export class ScheduledTaskService { async create(actor: string, value: unknown): Promise { const task = parseCreateScheduledTaskRequest(value); const now = this.now(); - previewCron(task.cronExpression, 1, now); + previewTrigger(scheduleTriggerOf(task), 1, now); const id = this.id(); let smsSecretReference: string | undefined; if (task.sms) { @@ -82,10 +85,22 @@ export class ScheduledTaskService { const merged = parseCreateScheduledTaskRequest({ name: change.name ?? current.name, operationType, + trigger: + (change.trigger as ScheduleTrigger | undefined) ?? + (change.cronExpression + ? ({ kind: 'cron', expression: change.cronExpression } as ScheduleTrigger) + : current.trigger), cronExpression: change.cronExpression ?? current.cronExpression, timezone: change.timezone ?? current.timezone, targetSelector: change.targetSelector ?? current.targetSelector, ...(smsPayload ? { sms: smsPayload } : {}), + ...(change.delaySeconds === null + ? {} + : change.delaySeconds !== undefined + ? { delaySeconds: change.delaySeconds } + : current.delaySeconds !== undefined + ? { delaySeconds: current.delaySeconds } + : {}), ...(change.effectiveStartAt === null ? {} : change.effectiveStartAt @@ -106,7 +121,7 @@ export class ScheduledTaskService { enabled: change.enabled ?? current.enabled, }); const now = this.now(); - previewCron(merged.cronExpression, 1, now); + previewTrigger(scheduleTriggerOf(merged), 1, now); let nextReference = operationType === 'send-sms' ? oldReference : undefined; let wroteReference: string | undefined; @@ -151,10 +166,12 @@ export class ScheduledTaskService { return this.create(actor, { name: `${current.name.slice(0, 120 - suffix.length)}${suffix}`, operationType: current.operationType, + trigger: current.trigger, cronExpression: current.cronExpression, timezone: current.timezone, targetSelector: current.targetSelector, ...(smsPayload ? { sms: smsPayload } : {}), + ...(current.delaySeconds === undefined ? {} : { delaySeconds: current.delaySeconds }), ...(current.effectiveStartAt ? { effectiveStartAt: current.effectiveStartAt } : {}), ...(current.effectiveEndAt ? { effectiveEndAt: current.effectiveEndAt } : {}), misfirePolicy: current.misfirePolicy, @@ -172,8 +189,12 @@ export class ScheduledTaskService { return this.repository.list(); } - preview(cronExpression: string, count = 5, from = this.now()): readonly string[] { - return previewCron(cronExpression, count, from); + preview(value: unknown, count = 5, from = this.now()): readonly string[] { + const trigger = + typeof value === 'string' + ? ({ kind: 'cron', expression: value } as ScheduleTrigger) + : parseScheduleTrigger(value); + return previewTrigger(trigger, count, from); } setEnabled(actor: string, id: string, version: number, enabled: boolean): ScheduledTask { @@ -215,6 +236,6 @@ export class ScheduledTaskService { const anchor = task.effectiveStartAt ? new Date(Math.max(now.getTime(), Date.parse(task.effectiveStartAt))) : now; - return nextOccurrence(task.cronExpression, anchor); + return nextOccurrenceFor(scheduleTriggerOf(task), anchor); } } diff --git a/apps/api/src/application/automation/scheduler-coordinator.ts b/apps/api/src/application/automation/scheduler-coordinator.ts index 02ced99..521a958 100644 --- a/apps/api/src/application/automation/scheduler-coordinator.ts +++ b/apps/api/src/application/automation/scheduler-coordinator.ts @@ -5,9 +5,10 @@ import type { ScheduledRunOutcome, ScheduledTask, } from '@multi-simadmin/contracts'; +import { scheduleTriggerOf } from '@multi-simadmin/contracts'; import type { SqliteDatabase } from '../../infrastructure/database/database.js'; -import { nextOccurrence, reconcileOccurrence } from './schedule-time.js'; +import { nextOccurrenceFor, reconcileOccurrence } from './schedule-time.js'; import { ScheduledTaskRepository } from './scheduled-task-repository.js'; import { resolveOperationTargets, type ResolvedTarget } from './target-resolver.js'; @@ -37,9 +38,11 @@ function snapshot(task: ScheduledTask): CreateScheduledTaskRequest { return { name: task.name, operationType: task.operationType, + trigger: scheduleTriggerOf(task), cronExpression: task.cronExpression, timezone: task.timezone, targetSelector: task.targetSelector, + ...(task.delaySeconds === undefined ? {} : { delaySeconds: task.delaySeconds }), ...(task.effectiveStartAt ? { effectiveStartAt: task.effectiveStartAt } : {}), ...(task.effectiveEndAt ? { effectiveEndAt: task.effectiveEndAt } : {}), misfirePolicy: task.misfirePolicy, @@ -154,7 +157,7 @@ export class SchedulerCoordinator { task.effectiveStartAt && nowIso < task.effectiveStartAt ? new Date(task.effectiveStartAt) : now; - const nextDueAt = nextOccurrence(task.cronExpression, anchor); + const nextDueAt = nextOccurrenceFor(scheduleTriggerOf(task), anchor); const claimed = this.options.repository.claimScheduledOccurrence({ id: this.id(), scheduledTaskId: task.id, @@ -179,11 +182,11 @@ export class SchedulerCoordinator { ? { action: 'run' as const, dueAt: task.nextDueAt, - nextDueAt: nextOccurrence(task.cronExpression, now), + nextDueAt: nextOccurrenceFor(scheduleTriggerOf(task), now, new Date(task.nextDueAt)), } : reconcileOccurrence( { - cronExpression: task.cronExpression, + trigger: task.trigger, nextDueAt: task.nextDueAt, misfirePolicy: task.misfirePolicy, }, diff --git a/apps/api/src/application/automation/target-resolver.test.ts b/apps/api/src/application/automation/target-resolver.test.ts index a8bdef4..339069b 100644 --- a/apps/api/src/application/automation/target-resolver.test.ts +++ b/apps/api/src/application/automation/target-resolver.test.ts @@ -64,6 +64,46 @@ describe('resolveTargets', () => { db.close(); }); + it('resolves the Hub all-devices and single-group selectors', () => { + const db = fixture(); + const now = '2026-07-30T00:00:00.000Z'; + db.prepare( + 'INSERT INTO device_groups (id,name,description,created_at,updated_at) VALUES (?,?,?,?,?)', + ).run('g-lab', '实验室', '', now, now); + db.prepare('UPDATE instances SET group_id = ? WHERE id IN (?,?)').run('g-lab', 'a', 'c'); + expect(resolveTargets(db, { mode: 'all' })).toEqual([ + { id: 'a', revision: 3 }, + { id: 'b', revision: 2 }, + ]); + expect(resolveTargets(db, { mode: 'group', groupId: 'g-lab' })).toEqual([ + { id: 'a', revision: 3 }, + ]); + db.close(); + }); + + it('dispatches control-plane backups without resolving device targets', () => { + const db = fixture(); + expect(resolveOperationTargets(db, { mode: 'all' }, 'backup-data')).toEqual({ + targets: [{ id: 'control-plane', revision: 1 }], + targetSnapshot: [], + unavailableInstanceIds: [], + }); + db.close(); + }); + + it('dispatches devices whose capability was never probed', () => { + const db = fixture(); + expect(resolveOperationTargets(db, { mode: 'all' }, 'restart-baseband')).toEqual({ + targets: [ + { id: 'a', revision: 3 }, + { id: 'b', revision: 2 }, + ], + targetSnapshot: ['a', 'b'], + unavailableInstanceIds: [], + }); + db.close(); + }); + it('separates targets whose required operation capability is unavailable', () => { const db = fixture(); const now = '2026-07-30T00:00:00.000Z'; diff --git a/apps/api/src/application/automation/target-resolver.ts b/apps/api/src/application/automation/target-resolver.ts index 67371f3..029bb18 100644 --- a/apps/api/src/application/automation/target-resolver.ts +++ b/apps/api/src/application/automation/target-resolver.ts @@ -18,10 +18,15 @@ export interface OperationTargetResolution { readonly unavailableInstanceIds: readonly string[]; } +/** Sentinel target for control-plane-local actions; it is never an instance id. */ +export const LOCAL_TARGET: ResolvedTarget = { id: 'control-plane', revision: 1 }; + const operationIds: Record = { 'restart-service': 'postServiceRestart', 'reboot-system': 'postSystemReboot', + 'restart-baseband': 'postBasebandRestart', 'send-sms': 'postSmsSend', + 'backup-data': 'localBackup', }; export function resolveTargets( @@ -43,6 +48,22 @@ export function resolveTargets( }); } + if (selector.mode === 'all') { + const rows = db + .prepare('SELECT id, config_revision FROM instances WHERE enabled = 1 ORDER BY id ASC') + .all() as TargetRow[]; + return rows.map((row) => ({ id: row.id, revision: row.config_revision })); + } + + if (selector.mode === 'group') { + const rows = db + .prepare( + 'SELECT id, config_revision FROM instances WHERE enabled = 1 AND group_id = ? ORDER BY id ASC', + ) + .all(selector.groupId) as TargetRow[]; + return rows.map((row) => ({ id: row.id, revision: row.config_revision })); + } + const placeholders = selector.tags.map(() => '?').join(','); const comparison = selector.match === 'all' ? '= ?' : '> 0'; const parameters: Array = [...selector.tags]; @@ -66,6 +87,8 @@ export function resolveOperationTargets( selector: ScheduleTargetSelector, operationType: ScheduledOperationType, ): OperationTargetResolution { + if (operationType === 'backup-data') + return { targets: [LOCAL_TARGET], targetSnapshot: [], unavailableInstanceIds: [] }; const candidates = resolveTargets(db, selector); if (candidates.length === 0) return { targets: [], targetSnapshot: [], unavailableInstanceIds: [] }; @@ -80,12 +103,15 @@ export function resolveOperationTargets( state: string; }>; const states = new Map(rows.map((row) => [row.instance_id, row.state])); - const availableStates = new Set(['supported', 'auth-required', 'degraded']); + // Only a probed "unsupported" verdict blocks a device. Capability rows are written by active + // probes, so an absent row means "never observed", which must dispatch optimistically and let + // the job result report the truth; otherwise every scheduled task stalls on missing evidence. + const isBlocked = (target: ResolvedTarget): boolean => states.get(target.id) === 'unsupported'; return { - targets: candidates.filter((target) => availableStates.has(states.get(target.id) ?? 'unknown')), + targets: candidates.filter((target) => !isBlocked(target)), targetSnapshot: candidates.map((target) => target.id), unavailableInstanceIds: candidates - .filter((target) => !availableStates.has(states.get(target.id) ?? 'unknown')) + .filter((target) => isBlocked(target)) .map((target) => target.id), }; } diff --git a/apps/api/src/application/connections/connection-probe.test.ts b/apps/api/src/application/connections/connection-probe.test.ts index 1188c3c..5ac43f7 100644 --- a/apps/api/src/application/connections/connection-probe.test.ts +++ b/apps/api/src/application/connections/connection-probe.test.ts @@ -5,6 +5,7 @@ import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js'; import { ConnectionProbe } from './connection-probe.js'; +import { ConnectionLogService } from '../system/connection-log-service.js'; class Store implements SecretStore { async set(key: { instanceId: string; purpose: string; slot?: string }) { @@ -21,7 +22,7 @@ const dbs: Database.Database[] = []; afterEach(() => { for (const db of dbs.splice(0)) db.close(); }); -const fixture = (response: TransportResponse) => { +const fixture = (response: TransportResponse, options: { logs?: boolean } = {}) => { const db = new Database(':memory:'); db.pragma('foreign_keys=ON'); migrateDatabase(db); @@ -32,6 +33,7 @@ const fixture = (response: TransportResponse) => { idFactory: () => 'instance-1', now: () => new Date('2026-07-16T12:00:00.000Z'), }); + const connectionLogs = options.logs ? new ConnectionLogService({ db }) : undefined; const transport = { get: async (url: string) => { expect(url).toBe('http://192.168.1.20:3000/api/health'); @@ -40,12 +42,14 @@ const fixture = (response: TransportResponse) => { }; return { db, + connectionLogs, instances, probe: new ConnectionProbe({ db, instances, transport, now: () => new Date('2026-07-16T12:00:00.000Z'), + ...(connectionLogs ? { connectionLogs } : {}), }), }; }; @@ -92,4 +96,116 @@ describe('ConnectionProbe', () => { const { probe } = fixture({ status: 200, headers: {}, body: '{}' }); await expect(probe.test('missing')).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); + + it('journals a successful probe and marks an unauthenticated one as stale', async () => { + const { db, instances, probe } = fixture( + { status: 401, headers: {}, body: '{}' }, + { logs: true }, + ); + await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' }); + await probe.test('instance-1'); + const rows = db + .prepare('SELECT outcome, state, error_code, http_status, duration_ms FROM connection_logs') + .all() as { + outcome: string; + state: string; + error_code: string | null; + http_status: number; + duration_ms: number; + }[]; + expect(rows).toEqual([ + { + outcome: 'stale', + state: 'stale', + error_code: 'HTTP_401', + http_status: 401, + duration_ms: 0, + }, + ]); + }); + + it('journals a transport failure before rethrowing it', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const instances = new InstanceService({ + db, + store: new Store(), + idFactory: () => 'instance-1', + now: () => new Date('2026-07-16T12:00:00.000Z'), + }); + await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' }); + const connectionLogs = new ConnectionLogService({ db }); + const probe = new ConnectionProbe({ + db, + instances, + connectionLogs, + transport: { + get: async () => { + throw Object.assign(new Error('boom'), { code: 'ECONNREFUSED' }); + }, + }, + }); + await expect(probe.test('instance-1')).rejects.toThrow('boom'); + expect(connectionLogs.list().items[0]).toMatchObject({ + outcome: 'failed', + state: 'unknown', + errorCode: 'ECONNREFUSED', + httpStatus: null, + }); + }); + + it('honours a configured snapshot lifetime so the offline window drives expiry', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const instances = new InstanceService({ + db, + store: new Store(), + idFactory: () => 'instance-1', + now: () => new Date('2026-07-16T12:00:00.000Z'), + }); + await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' }); + let clock = new Date('2026-07-16T12:00:00.000Z'); + const probe = new ConnectionProbe({ + db, + instances, + transport: { get: async () => ({ status: 200, headers: {}, body: '{}' }) }, + now: () => clock, + snapshotTtlMs: () => 120_000, + }); + await probe.test('instance-1'); + expect(probe.reachability().get('instance-1')).toEqual({ + reachable: true, + authenticated: true, + checkedAt: '2026-07-16T12:00:00.000Z', + }); + + clock = new Date('2026-07-16T12:01:30.000Z'); + expect(probe.reachability().get('instance-1')).toMatchObject({ reachable: true }); + + clock = new Date('2026-07-16T12:02:01.000Z'); + expect(probe.reachability().get('instance-1')).toEqual({ + reachable: false, + authenticated: false, + checkedAt: '2026-07-16T12:00:00.000Z', + }); + }); + + it('reports an expired authenticated snapshot as offline and never as authenticated', async () => { + const { db, instances, probe } = fixture({ status: 200, headers: {}, body: '{}' }); + await instances.create({ name: 'LAN', origin: 'http://192.168.1.20:3000' }); + expect(probe.reachability().size).toBe(0); + await probe.test('instance-1'); + db.prepare( + `UPDATE status_snapshots SET expires_at = '2020-01-01T00:00:00.000Z' WHERE instance_id = 'instance-1'`, + ).run(); + expect(probe.reachability().get('instance-1')).toEqual({ + reachable: false, + authenticated: false, + checkedAt: '2026-07-16T12:00:00.000Z', + }); + }); }); diff --git a/apps/api/src/application/connections/connection-probe.ts b/apps/api/src/application/connections/connection-probe.ts index 326bb1a..d52e8d0 100644 --- a/apps/api/src/application/connections/connection-probe.ts +++ b/apps/api/src/application/connections/connection-probe.ts @@ -3,6 +3,7 @@ import type Database from 'better-sqlite3'; import type { SessionMetadata } from '@multi-simadmin/contracts'; import { InstanceService, InstanceServiceError } from '../instances/instance-service.js'; import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js'; +import type { ConnectionLogService } from '../system/connection-log-service.js'; export interface ConnectionTransport { readonly get: (url: string) => Promise; @@ -12,21 +13,59 @@ export interface ConnectionProbeOptions { readonly instances: InstanceService; readonly transport: ConnectionTransport; readonly now?: () => Date; + /** Optional journal of every reachability attempt, used by the log centre. */ + readonly connectionLogs?: ConnectionLogService; + /** + * How long a probe stays authoritative. Defaults to one heartbeat period; the fleet heartbeat + * passes the configured offline window so a lost probe is tolerated exactly as long as the + * operator asked for. + */ + readonly snapshotTtlMs?: number | (() => number); } const CONNECTION_SNAPSHOT_TTL_MS = 30_000; +/** Last known reachability for one instance, read from the snapshot journal without probing. */ +export interface ConnectionState { + readonly reachable: boolean; + readonly authenticated: boolean; + readonly checkedAt: string | null; +} + +interface SnapshotRow { + readonly instance_id: string; + readonly payload_json: string | null; + readonly observed_at: string; + readonly expires_at: string | null; +} + export class ConnectionProbe { private readonly now: () => Date; constructor(private readonly options: ConnectionProbeOptions) { this.now = options.now ?? (() => new Date()); } + + #ttlMs(): number { + const value = this.options.snapshotTtlMs; + const resolved = typeof value === 'function' ? value() : value; + return Number.isFinite(resolved) && (resolved ?? 0) > 0 + ? (resolved as number) + : CONNECTION_SNAPSHOT_TTL_MS; + } + async test(instanceId: string): Promise { const instance = await this.options.instances.get(instanceId); if (!instance) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found'); - const response = await this.options.transport.get(`${instance.origin}/api/health`); + const startedAt = this.now().getTime(); + let response: TransportResponse; + try { + response = await this.options.transport.get(`${instance.origin}/api/health`); + } catch (error) { + this.#record(instanceId, null, 'failed', startedAt, errorCode(error)); + throw error; + } const observed = this.now(); const observedAt = observed.toISOString(); - const expiresAt = new Date(observed.getTime() + CONNECTION_SNAPSHOT_TTL_MS).toISOString(); + const expiresAt = new Date(observed.getTime() + this.#ttlMs()).toISOString(); const authenticated = response.status >= 200 && response.status < 300; this.options.db .prepare( @@ -48,6 +87,68 @@ export class ConnectionProbe { expiresAt, observedAt, ); + this.#record( + instanceId, + response.status, + authenticated ? 'success' : 'stale', + startedAt, + authenticated ? null : `HTTP_${response.status}`, + ); return { instanceId, authenticated, checkedAt: observedAt }; } + + /** + * Reachability for every instance that has ever been probed. Anything missing, or whose + * snapshot outlived the offline window, is reported as unreachable. + */ + reachability(): ReadonlyMap { + const rows = this.options.db + .prepare( + `SELECT instance_id, payload_json, observed_at, expires_at + FROM status_snapshots WHERE category = 'connection'`, + ) + .all() as unknown as readonly SnapshotRow[]; + const now = this.now().toISOString(); + const states = new Map(); + for (const row of rows) { + let authenticated = false; + try { + const payload = JSON.parse(row.payload_json ?? '{}') as { authenticated?: unknown }; + authenticated = payload.authenticated === true; + } catch { + authenticated = false; + } + const reachable = Boolean(row.expires_at) && (row.expires_at as string) > now; + states.set(row.instance_id, { + reachable, + authenticated: reachable ? authenticated : false, + checkedAt: row.observed_at, + }); + } + return states; + } + + /** Best-effort: the journal is diagnostics, never a reason to fail a probe. */ + #record( + instanceId: string, + httpStatus: number | null, + outcome: 'success' | 'stale' | 'failed', + startedAt: number, + errorCode: string | null, + ): void { + this.options.connectionLogs?.record({ + instanceId, + outcome, + state: outcome === 'success' ? 'fresh' : outcome === 'stale' ? 'stale' : 'unknown', + errorCode, + httpStatus, + durationMs: Math.max(0, this.now().getTime() - startedAt), + observedAt: this.now().toISOString(), + }); + } +} + +function errorCode(error: unknown): string { + const code = (error as { code?: unknown })?.code; + return typeof code === 'string' ? code.slice(0, 64) : 'UPSTREAM_UNAVAILABLE'; } diff --git a/apps/api/src/application/connections/connection-settings-service.test.ts b/apps/api/src/application/connections/connection-settings-service.test.ts new file mode 100644 index 0000000..e26627e --- /dev/null +++ b/apps/api/src/application/connections/connection-settings-service.test.ts @@ -0,0 +1,127 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { + ConnectionSettingsService, + DEFAULT_CONNECTION_SETTINGS, + validateConnectionSettings, +} from './connection-settings-service.js'; + +const dbs: Database.Database[] = []; +afterEach(() => { + for (const db of dbs.splice(0)) db.close(); +}); + +function service() { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + dbs.push(db); + return { + db, + settings: new ConnectionSettingsService({ + db, + now: () => new Date('2026-09-05T00:00:00.000Z'), + }), + }; +} + +function stored(db: Database.Database): string | undefined { + const row = db + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get('connection.settings') as { value_json: string } | undefined; + return row?.value_json; +} + +describe('validateConnectionSettings', () => { + it('fills the missing field from the defaults', () => { + expect(validateConnectionSettings({ heartbeatSeconds: 60 })).toEqual({ + heartbeatSeconds: 60, + offlineSeconds: 120, + }); + expect(validateConnectionSettings({ offlineSeconds: 300 })).toEqual({ + heartbeatSeconds: 30, + offlineSeconds: 300, + }); + }); + + it('rejects payloads that are not objects or carry unknown keys', () => { + expect(() => validateConnectionSettings(null)).toThrow(TypeError); + expect(() => validateConnectionSettings([])).toThrow(TypeError); + expect(() => validateConnectionSettings('30')).toThrow(TypeError); + expect(() => validateConnectionSettings({ heartbeat_interval: 30 })).toThrow(TypeError); + }); + + it('rejects non-integer or out-of-range cadence', () => { + expect(() => validateConnectionSettings({ heartbeatSeconds: 30.5 })).toThrow(RangeError); + expect(() => validateConnectionSettings({ heartbeatSeconds: 4 })).toThrow(RangeError); + expect(() => validateConnectionSettings({ heartbeatSeconds: 301 })).toThrow(RangeError); + expect(() => validateConnectionSettings({ offlineSeconds: 1_801 })).toThrow(RangeError); + }); + + it('requires the offline window to tolerate at least two missed beats', () => { + expect(() => validateConnectionSettings({ heartbeatSeconds: 30, offlineSeconds: 59 })).toThrow( + RangeError, + ); + expect(validateConnectionSettings({ heartbeatSeconds: 30, offlineSeconds: 60 })).toEqual({ + heartbeatSeconds: 30, + offlineSeconds: 60, + }); + }); +}); + +describe('ConnectionSettingsService', () => { + it('starts from the shipped defaults', () => { + const { settings } = service(); + expect(settings.get()).toEqual({ heartbeatSeconds: 30, offlineSeconds: 90 }); + expect(settings.heartbeatMs).toBe(30_000); + expect(settings.snapshotTtlMs).toBe(90_000); + }); + + it('persists an update and reads it back', () => { + const { db, settings } = service(); + expect(settings.update({ heartbeatSeconds: 45, offlineSeconds: 180 })).toEqual({ + heartbeatSeconds: 45, + offlineSeconds: 180, + }); + expect(stored(db)).toBe('{"heartbeatSeconds":45,"offlineSeconds":180}'); + expect(settings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 180 }); + expect(settings.heartbeatMs).toBe(45_000); + expect(settings.snapshotTtlMs).toBe(180_000); + }); + + it('rewrites the same row instead of inserting a second one', () => { + const { db, settings } = service(); + settings.update({ heartbeatSeconds: 10, offlineSeconds: 20 }); + settings.update({ heartbeatSeconds: 20, offlineSeconds: 40 }); + const rows = db.prepare('SELECT COUNT(*) AS count FROM app_settings').get() as { + count: number; + }; + expect(rows.count).toBe(1); + expect(settings.get()).toEqual({ heartbeatSeconds: 20, offlineSeconds: 40 }); + }); + + it('falls back to the defaults when the stored row is corrupt', () => { + const { db, settings } = service(); + const now = new Date('2026-09-05T00:00:00.000Z').toISOString(); + db.prepare( + 'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)', + ).run('connection.settings', 'not json', now, now); + expect(settings.get()).toEqual(DEFAULT_CONNECTION_SETTINGS); + + db.prepare('UPDATE app_settings SET value_json = ? WHERE key = ?').run( + '{"heartbeatSeconds":9999,"offlineSeconds":10}', + 'connection.settings', + ); + expect(settings.get()).toEqual(DEFAULT_CONNECTION_SETTINGS); + }); + + it('keeps a rejected update from touching the stored row', () => { + const { db, settings } = service(); + settings.update({ heartbeatSeconds: 60, offlineSeconds: 120 }); + expect(() => settings.update({ heartbeatSeconds: 60, offlineSeconds: 61 })).toThrow(RangeError); + expect(settings.get()).toEqual({ heartbeatSeconds: 60, offlineSeconds: 120 }); + expect(stored(db)).toBe('{"heartbeatSeconds":60,"offlineSeconds":120}'); + }); +}); diff --git a/apps/api/src/application/connections/connection-settings-service.ts b/apps/api/src/application/connections/connection-settings-service.ts new file mode 100644 index 0000000..b196902 --- /dev/null +++ b/apps/api/src/application/connections/connection-settings-service.ts @@ -0,0 +1,113 @@ +import type Database from 'better-sqlite3'; + +/** + * Reachability cadence, the Hub "connection settings" panel rebuilt on the control plane's own + * snapshot journal. The heartbeat decides how often every instance is probed; the offline window + * decides how long a lost probe is tolerated before the fleet view calls the device offline. + */ +export interface ConnectionSettings { + readonly heartbeatSeconds: number; + readonly offlineSeconds: number; +} + +export const MIN_HEARTBEAT_SECONDS = 5; +export const MAX_HEARTBEAT_SECONDS = 300; +export const MAX_OFFLINE_SECONDS = 1_800; + +export const DEFAULT_CONNECTION_SETTINGS: Readonly = Object.freeze({ + heartbeatSeconds: 30, + offlineSeconds: 90, +}); + +const SETTING_KEY = 'connection.settings'; + +const FIELDS: readonly (keyof ConnectionSettings)[] = ['heartbeatSeconds', 'offlineSeconds']; + +function safeInteger(value: unknown, name: string): number { + if (!Number.isSafeInteger(value)) + throw new RangeError(`${name} must be a whole number of seconds`); + return value as number; +} + +export function validateConnectionSettings(value: unknown): ConnectionSettings { + const source = + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + if (!source) throw new TypeError('The connection settings payload is invalid'); + const unknown = Object.keys(source).filter( + (key) => !FIELDS.includes(key as keyof ConnectionSettings), + ); + if (unknown.length > 0) throw new TypeError(`Unknown connection setting: ${unknown[0]}`); + + const heartbeatSeconds = safeInteger( + source.heartbeatSeconds ?? DEFAULT_CONNECTION_SETTINGS.heartbeatSeconds, + 'heartbeatSeconds', + ); + if (heartbeatSeconds < MIN_HEARTBEAT_SECONDS || heartbeatSeconds > MAX_HEARTBEAT_SECONDS) + throw new RangeError('heartbeatSeconds must be between 5 and 300'); + + // An omitted window scales with the heartbeat so a partial payload stays valid; an explicit one + // is checked strictly below. + const fallbackOffline = Math.min( + MAX_OFFLINE_SECONDS, + Math.max(DEFAULT_CONNECTION_SETTINGS.offlineSeconds, heartbeatSeconds * 2), + ); + const offlineSeconds = safeInteger(source.offlineSeconds ?? fallbackOffline, 'offlineSeconds'); + if (offlineSeconds < heartbeatSeconds * 2) + throw new RangeError('offlineSeconds must be at least twice the heartbeat interval'); + if (offlineSeconds > MAX_OFFLINE_SECONDS) + throw new RangeError(`offlineSeconds must not exceed ${MAX_OFFLINE_SECONDS}`); + + return { heartbeatSeconds, offlineSeconds }; +} + +interface SettingsRow { + readonly value_json?: string | null; +} + +export class ConnectionSettingsService { + readonly #db: Database.Database; + readonly #now: () => Date; + + constructor(options: { readonly db: Database.Database; readonly now?: () => Date }) { + this.#db = options.db; + this.#now = options.now ?? (() => new Date()); + } + + get(): ConnectionSettings { + const row = this.#db + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get(SETTING_KEY) as SettingsRow | undefined; + if (!row?.value_json) return { ...DEFAULT_CONNECTION_SETTINGS }; + try { + return validateConnectionSettings(JSON.parse(row.value_json)); + } catch { + // A hand-edited row must never take the heartbeat loop down. + return { ...DEFAULT_CONNECTION_SETTINGS }; + } + } + + update(value: unknown): ConnectionSettings { + const settings = validateConnectionSettings(value); + const now = this.#now().toISOString(); + this.#db + .prepare( + `INSERT INTO app_settings (key,value_json,created_at,updated_at) + VALUES (?,?,?,?) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, + updated_at = excluded.updated_at`, + ) + .run(SETTING_KEY, JSON.stringify(settings), now, now); + return settings; + } + + get heartbeatMs(): number { + return this.get().heartbeatSeconds * 1_000; + } + + /** Snapshot lifetime: a probe stays authoritative for one full offline window. */ + get snapshotTtlMs(): number { + return this.get().offlineSeconds * 1_000; + } +} diff --git a/apps/api/src/application/connections/fleet-heartbeat.test.ts b/apps/api/src/application/connections/fleet-heartbeat.test.ts new file mode 100644 index 0000000..58e7d3f --- /dev/null +++ b/apps/api/src/application/connections/fleet-heartbeat.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Instance, InstancePage } from '@multi-simadmin/contracts'; + +import type { InstanceService } from '../instances/instance-service.js'; +import type { ConnectionProbe } from './connection-probe.js'; +import type { ConnectionSettingsService } from './connection-settings-service.js'; +import { FleetHeartbeatCoordinator } from './fleet-heartbeat.js'; + +function instance(id: string): Instance { + return { + id, + name: id, + origin: `http://10.0.0.1:3000`, + authMode: 'none', + configRevision: 1, + createdAt: '2026-09-05T00:00:00.000Z', + updatedAt: '2026-09-05T00:00:00.000Z', + } as unknown as Instance; +} + +function page(items: readonly Instance[], pageSize: number): InstancePage { + return { items: [...items], page: { page: 1, pageSize, total: items.length } }; +} + +function coordinator(options: { + readonly pages: ReadonlyMap; + readonly pageSize: number; + readonly probe?: (id: string) => Promise; + readonly heartbeatMs?: number; + readonly concurrency?: number; +}) { + const listed: string[] = []; + const instances = { + async list(query: { page?: number; pageSize?: number }) { + const pageSize = options.pageSize; + const current = query.page ?? 1; + listed.push(String(current)); + return page(options.pages.get(current) ?? [], pageSize); + }, + } as unknown as InstanceService; + const probed: string[] = []; + const probe = { + async test(id: string) { + probed.push(id); + await options.probe?.(id); + return { instanceId: id, authenticated: true, checkedAt: '2026-09-05T00:00:00.000Z' }; + }, + } as unknown as ConnectionProbe; + const settings = { + heartbeatMs: options.heartbeatMs ?? 30_000, + } as unknown as ConnectionSettingsService; + const beat = new FleetHeartbeatCoordinator({ + instances, + probe, + settings, + pageSize: options.pageSize, + now: () => new Date('2026-09-05T00:00:00.000Z'), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + }); + return { beat, listed, probed }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('FleetHeartbeatCoordinator', () => { + it('probes every instance once across pages', async () => { + const { beat, listed, probed } = coordinator({ + pageSize: 2, + pages: new Map([ + [1, [instance('a'), instance('b')]], + [2, [instance('c')]], + ]), + }); + await expect(beat.runOnce()).resolves.toEqual({ + probed: 3, + failed: 0, + startedAt: '2026-09-05T00:00:00.000Z', + finishedAt: '2026-09-05T00:00:00.000Z', + }); + expect(listed).toEqual(['1', '2']); + expect([...probed].sort()).toEqual(['a', 'b', 'c']); + }); + + it('stops paging on a short page and probes a duplicated id once', async () => { + const { beat, listed, probed } = coordinator({ + pageSize: 2, + pages: new Map([[1, [instance('a'), instance('a')]]]), + }); + await beat.runOnce(); + expect(listed).toEqual(['1', '2']); + expect(probed).toEqual(['a']); + }); + + it('counts a failing device without abandoning the rest of the fleet', async () => { + const { beat } = coordinator({ + pageSize: 10, + concurrency: 1, + pages: new Map([[1, [instance('a'), instance('b'), instance('c')]]]), + probe: async (id) => { + if (id === 'b') throw new Error('ECONNREFUSED'); + }, + }); + await expect(beat.runOnce()).resolves.toMatchObject({ probed: 2, failed: 1 }); + }); + + it('shares one pass between concurrent refresh requests', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { beat, probed } = coordinator({ + pageSize: 10, + concurrency: 1, + pages: new Map([[1, [instance('a')]]]), + probe: () => gate, + }); + const first = beat.runOnce(); + const second = beat.runOnce(); + release?.(); + await Promise.all([first, second]); + expect(probed).toEqual(['a']); + }); + + it('runs a beat on the configured cadence and stops cleanly', async () => { + vi.useFakeTimers(); + const { beat, probed } = coordinator({ + pageSize: 10, + pages: new Map([[1, [instance('a')]]]), + heartbeatMs: 5_000, + }); + beat.start(); + beat.start(); + await vi.advanceTimersByTimeAsync(5_000); + expect(probed).toEqual(['a']); + await vi.advanceTimersByTimeAsync(5_000); + expect(probed).toEqual(['a', 'a']); + beat.stop(); + await vi.advanceTimersByTimeAsync(60_000); + expect(probed).toHaveLength(2); + }); + + it('refuses an unusable worker or page size', () => { + const base = { + instances: {} as unknown as InstanceService, + probe: {} as unknown as ConnectionProbe, + settings: {} as unknown as ConnectionSettingsService, + }; + expect(() => new FleetHeartbeatCoordinator({ ...base, concurrency: 0 })).toThrow(RangeError); + expect(() => new FleetHeartbeatCoordinator({ ...base, concurrency: 17 })).toThrow(RangeError); + expect(() => new FleetHeartbeatCoordinator({ ...base, pageSize: 0 })).toThrow(RangeError); + expect(() => new FleetHeartbeatCoordinator({ ...base, pageSize: 101 })).toThrow(RangeError); + }); +}); diff --git a/apps/api/src/application/connections/fleet-heartbeat.ts b/apps/api/src/application/connections/fleet-heartbeat.ts new file mode 100644 index 0000000..e5ca780 --- /dev/null +++ b/apps/api/src/application/connections/fleet-heartbeat.ts @@ -0,0 +1,120 @@ +import type { ConnectionProbe } from './connection-probe.js'; +import type { ConnectionSettingsService } from './connection-settings-service.js'; +import type { InstanceService } from '../instances/instance-service.js'; + +export interface FleetHeartbeatSummary { + readonly probed: number; + readonly failed: number; + readonly startedAt: string; + readonly finishedAt: string; +} + +export interface FleetHeartbeatOptions { + readonly instances: InstanceService; + readonly probe: ConnectionProbe; + readonly settings: ConnectionSettingsService; + readonly now?: () => Date; + /** Devices touched at the same time; the control plane talks to LAN hosts, not the internet. */ + readonly concurrency?: number; + readonly pageSize?: number; +} + +const DEFAULT_CONCURRENCY = 6; +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGES = 100; + +/** + * Keeps device online state honest without waiting for an operator to open a page: every beat + * probes each instance once and lets the snapshot journal carry the result until it expires. + */ +export class FleetHeartbeatCoordinator { + readonly #instances: InstanceService; + readonly #probe: ConnectionProbe; + readonly #settings: ConnectionSettingsService; + readonly #now: () => Date; + readonly #concurrency: number; + readonly #pageSize: number; + #timer: ReturnType | undefined; + #running: Promise | undefined; + #stopped = false; + + constructor(options: FleetHeartbeatOptions) { + this.#instances = options.instances; + this.#probe = options.probe; + this.#settings = options.settings; + this.#now = options.now ?? (() => new Date()); + const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY; + if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 16) + throw new RangeError('concurrency must be between 1 and 16'); + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) + throw new RangeError('pageSize must be between 1 and 100'); + this.#concurrency = concurrency; + this.#pageSize = pageSize; + } + + start(): void { + this.#stopped = false; + if (this.#timer) return; + this.#schedule(); + } + + stop(): void { + this.#stopped = true; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = undefined; + } + + /** Runs a beat on demand; concurrent callers share the same pass. */ + runOnce(): Promise { + if (this.#running) return this.#running; + const pending = this.#beat().finally(() => { + if (this.#running === pending) this.#running = undefined; + }); + this.#running = pending; + return pending; + } + + #schedule(): void { + if (this.#stopped) return; + const delay = this.#settings.heartbeatMs; + this.#timer = setTimeout(() => { + this.#timer = undefined; + void this.runOnce() + .catch(() => undefined) + .finally(() => this.#schedule()); + }, delay); + this.#timer.unref?.(); + } + + async #beat(): Promise { + const startedAt = this.#now().toISOString(); + const ids: string[] = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const current = await this.#instances.list({ page, pageSize: this.#pageSize }); + for (const instance of current.items) if (!ids.includes(instance.id)) ids.push(instance.id); + if (current.items.length < this.#pageSize) break; + } + let probed = 0; + let failed = 0; + let cursor = 0; + const workers = Array.from( + { length: Math.min(this.#concurrency, ids.length) }, + async (): Promise => { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= ids.length) return; + try { + await this.#probe.test(ids[index] as string); + probed += 1; + } catch { + failed += 1; + } + } + }, + ); + await Promise.all(workers); + return { probed, failed, startedAt, finishedAt: this.#now().toISOString() }; + } +} diff --git a/apps/api/src/application/connections/upstream-session-client.ts b/apps/api/src/application/connections/upstream-session-client.ts index ab517b0..0edcfa5 100644 --- a/apps/api/src/application/connections/upstream-session-client.ts +++ b/apps/api/src/application/connections/upstream-session-client.ts @@ -1,6 +1,6 @@ export interface UpstreamRequest { readonly url: string; - readonly method: 'GET' | 'POST'; + readonly method: 'GET' | 'POST' | 'DELETE'; readonly headers: Readonly>; /** One-shot secret. Request implementations must not log or persist this field. */ readonly secret?: string; @@ -8,6 +8,13 @@ export interface UpstreamRequest { readonly body?: '[REDACTED]'; /** Explicit one-shot SMS action payload. Implementations must not log or persist it. */ readonly sms?: { readonly phoneNumber: string; readonly content: string }; + /** Explicit one-shot SMS batch-delete payload. Implementations must not log or persist it. */ + readonly smsBatchDelete?: { readonly ids: readonly number[] }; + /** + * Explicit one-shot device action payload for an allowlisted device mutation. The gateway + * re-validates and canonically serializes it; implementations must not log or persist it. + */ + readonly deviceAction?: { readonly body: Readonly> | undefined }; } export interface UpstreamResponse { readonly status: number; diff --git a/apps/api/src/application/events/event-journal.test.ts b/apps/api/src/application/events/event-journal.test.ts index 31f4732..fc31e1c 100644 --- a/apps/api/src/application/events/event-journal.test.ts +++ b/apps/api/src/application/events/event-journal.test.ts @@ -4,7 +4,8 @@ import { describe, expect, it, vi } from 'vitest'; import { EventJournal, formatServerSentEvent, type EventEnvelope } from './event-journal.js'; import { migrateDatabase } from '../../infrastructure/database/migrations.js'; -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(); function event(kind: EventEnvelope['kind'], id = `${kind}-1`): EventEnvelope { const common = { diff --git a/apps/api/src/application/instances/device-action-catalog.test.ts b/apps/api/src/application/instances/device-action-catalog.test.ts new file mode 100644 index 0000000..8820bfd --- /dev/null +++ b/apps/api/src/application/instances/device-action-catalog.test.ts @@ -0,0 +1,107 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { SafeUpstreamGateway } from '../../infrastructure/transport/safe-upstream-gateway.js'; +import { + InstanceSessionStore, + type UpstreamRequest, +} from '../connections/upstream-session-client.js'; +import type { InstanceService } from '../instances/instance-service.js'; +import { + DEVICE_ACTIONS, + type DeviceActionDefinition, + type DeviceActionField, +} from './device-action-catalog.js'; +import { DeviceActionService } from './device-action-service.js'; + +const ORIGIN = 'http://192.168.1.20:8080'; + +/** A value that satisfies the catalog's own validator for every declared field kind. */ +function sampleValue(field: DeviceActionField): unknown { + if (field.id === 'phone_number' || field.id === 'sms_center') return '+15550001111'; + if (field.id === 'mccmnc') return '00101'; + if (field.id === 'iccid') return '89882020202220963176'; + switch (field.kind) { + case 'boolean': + return true; + case 'number': + return field.min ?? 1; + case 'string-list': + return ['1']; + case 'choice': + return field.choices?.[0]?.value ?? 'auto'; + default: + return 'sample'; + } +} + +function paramsFor(action: DeviceActionDefinition): Record { + const params: Record = {}; + for (const field of action.fields ?? []) params[field.id] = sampleValue(field); + return params; +} + +function harness() { + 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',?,1,?,?)`, + ).run(ORIGIN, '2026-09-04T00:00:00.000Z', '2026-09-04T00:00:00.000Z'); + + const sessions = new InstanceSessionStore(); + sessions.set('node-a', ORIGIN, 'simadmin_session=opaque'); + const dispatched: { url: string; method: string; body: string }[] = []; + const gateway = new SafeUpstreamGateway({ + transport: { + get: async (url, headers) => { + void headers; + dispatched.push({ url, method: 'GET', body: '' }); + return { status: 200, headers: {}, body: '{}' }; + }, + post: async (url, headers, body) => { + void headers; + dispatched.push({ url, method: 'POST', body }); + return { status: 200, headers: {}, body: '{"status":"success"}' }; + }, + delete: async (url, headers) => { + void headers; + dispatched.push({ url, method: 'DELETE', body: '' }); + return { status: 200, headers: {}, body: '{"status":"success"}' }; + }, + }, + }); + const instances = { + get: async (id: string) => + id === 'node-a' ? { id: 'node-a', name: 'Node A', origin: ORIGIN } : undefined, + } as unknown as InstanceService; + const service = new DeviceActionService({ + instances, + sessions, + db, + request: (request: UpstreamRequest) => gateway.request(request), + now: () => new Date('2026-09-04T00:00:00.000Z'), + id: () => 'audit-row-1', + }); + return { service, db, dispatched }; +} + +const context = { actor: 'loopback-control-plane', requestId: 'req-1', confirm: true }; + +describe('device action catalog transport parity', () => { + it('covers every module the console can render', () => { + expect(DEVICE_ACTIONS.length).toBeGreaterThan(40); + }); + + for (const action of DEVICE_ACTIONS) { + it(`dispatches ${action.id} through the pinned allowlist`, async () => { + const { service, dispatched } = harness(); + const result = await service.execute('node-a', action.id, paramsFor(action), context); + expect(result.ok).toBe(true); + expect(dispatched).toHaveLength(1); + expect(dispatched[0]?.url.startsWith(`${ORIGIN}/api/`)).toBe(true); + }); + } +}); diff --git a/apps/api/src/application/instances/device-action-catalog.ts b/apps/api/src/application/instances/device-action-catalog.ts new file mode 100644 index 0000000..b8cf331 --- /dev/null +++ b/apps/api/src/application/instances/device-action-catalog.ts @@ -0,0 +1,946 @@ +import type { InstanceModuleKey } from './instance-module-catalog.js'; + +export type DeviceActionRisk = 'R1' | 'R2' | 'R3'; + +export type DeviceActionFieldKind = + | 'boolean' + | 'number' + | 'string' + | 'secret' + | 'choice' + | 'string-list'; + +export interface DeviceActionChoice { + readonly value: string; + readonly label: string; +} + +export interface DeviceActionField { + /** Device payload key, or the name of a `{placeholder}` in the action path. */ + readonly id: string; + readonly label: string; + readonly kind: DeviceActionFieldKind; + readonly in?: 'body' | 'path' | 'query'; + readonly required?: boolean; + readonly choices?: readonly DeviceActionChoice[]; + readonly min?: number; + readonly max?: number; + readonly maxLength?: number; + readonly pattern?: string; + readonly hint?: string; +} + +export interface DeviceActionDefinition { + readonly id: string; + readonly module: InstanceModuleKey | 'messages'; + readonly title: string; + readonly description: string; + readonly risk: DeviceActionRisk; + readonly method: 'POST' | 'DELETE'; + /** Device path appended to `/api`; may contain `{field}` placeholders filled from path fields. */ + readonly path: string; + /** Constant payload merged before any user-supplied body fields. */ + readonly fixed?: Readonly>; + /** Sends a literal `{}` document when no field value survives, matching endpoints that still want JSON. */ + readonly emptyJsonObject?: boolean; + readonly fields?: readonly DeviceActionField[]; + readonly timeoutMs?: number; +} + +const BOOL = (id: string, label: string, hint?: string): DeviceActionField => ({ + id, + label, + kind: 'boolean', + required: true, + ...(hint ? { hint } : {}), +}); +const TEXT = ( + id: string, + label: string, + options: Partial = {}, +): DeviceActionField => ({ id, label, kind: 'string', required: true, maxLength: 128, ...options }); + +const RADIO_MODES: readonly DeviceActionChoice[] = [ + { value: 'auto', label: '自动' }, + { value: 'lte', label: '仅 LTE' }, + { value: 'nr', label: '5G NR' }, +]; +const WORK_MODES: readonly DeviceActionChoice[] = [ + { value: 'sim', label: '本机 SIM 管理' }, + { value: 'sim_overseas', label: '海外卡模式' }, + { value: 'esim', label: 'eSIM 模式' }, +]; +const APN_PROTOCOLS: readonly DeviceActionChoice[] = [ + { value: 'ipv4', label: 'IPv4' }, + { value: 'ipv6', label: 'IPv6' }, + { value: 'ipv4v6', label: 'IPv4/IPv6' }, +]; +const APN_AUTH: readonly DeviceActionChoice[] = [ + { value: 'none', label: '不认证' }, + { value: 'pap', label: 'PAP' }, + { value: 'chap', label: 'CHAP' }, +]; +const CELL_RATS: readonly DeviceActionChoice[] = [ + { value: 'lte', label: 'LTE' }, + { value: 'nr', label: 'NR' }, + { value: 'wcdma', label: 'WCDMA' }, + { value: 'gsm', label: 'GSM' }, +]; +const EMPTY_BODY = {}; + +/** + * Every device mutation the console is allowed to perform. Paths and payload keys mirror the + * SimAdmin device API one for one; anything not listed here cannot be reached from the UI. + */ +export const DEVICE_ACTIONS: readonly DeviceActionDefinition[] = Object.freeze([ + { + id: 'sim.refresh-details', + module: 'sim', + title: '刷新 SIM 详情', + description: '要求设备重新读取 SIM 卡详细信息并刷新缓存。', + risk: 'R1', + method: 'POST', + path: '/sim/details/refresh', + fixed: EMPTY_BODY, + timeoutMs: 8_000, + }, + { + id: 'sim.cache-phone', + module: 'sim', + title: '登记本机号码', + description: '把本机号码写入设备缓存,供短信与通话界面显示。', + risk: 'R1', + method: 'POST', + path: '/sim/cache', + fields: [TEXT('phone_number', '本机号码', { maxLength: 32, pattern: '^\\+?[0-9 ()-]{3,32}$' })], + }, + { + id: 'sim.cache-sms-center', + module: 'sim', + title: '登记短信中心号码', + description: '写入短信中心(SMSC)号码。', + risk: 'R1', + method: 'POST', + path: '/sim/cache', + fields: [ + TEXT('sms_center', '短信中心号码', { maxLength: 32, pattern: '^\\+?[0-9 ()-]{3,32}$' }), + ], + }, + { + id: 'apn.save', + module: 'sim', + title: '保存 APN 配置', + description: '修改指定 PDN 上下文的 APN、协议与认证方式。', + risk: 'R2', + method: 'POST', + path: '/apn', + fields: [ + TEXT('context_path', 'PDN 上下文路径', { maxLength: 128 }), + { id: 'apn', label: 'APN 名称', kind: 'string', maxLength: 128 }, + { id: 'protocol', label: '协议', kind: 'choice', choices: APN_PROTOCOLS }, + { id: 'username', label: '用户名', kind: 'string', maxLength: 128 }, + { id: 'password', label: '密码', kind: 'secret', maxLength: 128 }, + { id: 'auth_method', label: '认证方式', kind: 'choice', choices: APN_AUTH }, + ], + }, + { + id: 'band-lock.apply', + module: 'sim', + title: '锁定频段', + description: '按制式提交允许使用的频段列表,设备只会驻留这些频段。', + risk: 'R2', + method: 'POST', + path: '/band-lock', + fields: [ + { id: 'lte_fdd_bands', label: 'LTE FDD 频段', kind: 'string-list' }, + { id: 'lte_tdd_bands', label: 'LTE TDD 频段', kind: 'string-list' }, + { id: 'nr_fdd_bands', label: 'NR FDD 频段', kind: 'string-list' }, + { id: 'nr_tdd_bands', label: 'NR TDD 频段', kind: 'string-list' }, + ], + }, + { + id: 'band-lock.clear', + module: 'sim', + title: '解除频段锁定', + description: '提交空频段列表,恢复设备默认选网。', + risk: 'R2', + method: 'POST', + path: '/band-lock', + fixed: { lte_fdd_bands: [], lte_tdd_bands: [], nr_fdd_bands: [], nr_tdd_bands: [] }, + }, + { + id: 'cell-lock.apply', + module: 'sim', + title: '锁定小区', + description: '按频点与 PCI 锁定到指定小区,用于信号排查。', + risk: 'R2', + method: 'POST', + path: '/cell-lock', + fields: [ + { id: 'rat', label: '制式', kind: 'choice', required: true, choices: CELL_RATS }, + BOOL('enable', '启用小区锁定'), + { + id: 'arfcn', + label: 'ARFCN 频点', + kind: 'number', + required: true, + min: 0, + max: 2_684_354_555, + }, + { id: 'pci', label: 'PCI 物理小区号', kind: 'number', required: true, min: 0, max: 1_007 }, + ], + }, + { + id: 'cell-lock.unlock-all', + module: 'sim', + title: '解除小区锁定', + description: '释放全部已锁定的小区。', + risk: 'R2', + method: 'POST', + path: '/cell-lock/unlock-all', + fixed: EMPTY_BODY, + }, + { + id: 'data.set', + module: 'cellular', + title: '数据开关', + description: '开启或关闭设备的蜂窝数据连接。', + risk: 'R1', + method: 'POST', + path: '/data', + fields: [BOOL('active', '启用蜂窝数据')], + }, + { + id: 'network.scan', + module: 'cellular', + title: '扫描运营商', + description: '让设备重新扫描可见运营商列表。', + risk: 'R1', + method: 'POST', + path: '/network/operators/scan', + fixed: EMPTY_BODY, + timeoutMs: 30_000, + }, + { + id: 'network.register-manual', + module: 'cellular', + title: '手动注册网络', + description: '按 MCCMNC 强制注册到指定运营商。', + risk: 'R2', + method: 'POST', + path: '/network/register-manual', + fields: [TEXT('mccmnc', '运营商 MCCMNC', { maxLength: 6, pattern: '^[0-9]{5,6}$' })], + timeoutMs: 30_000, + }, + { + id: 'radio-mode.set', + module: 'cellular', + title: '切换网络模式', + description: '切换首选无线制式,切换期间会短暂断网。', + risk: 'R2', + method: 'POST', + path: '/radio-mode', + fields: [ + { id: 'mode', label: '网络模式', kind: 'choice', required: true, choices: RADIO_MODES }, + ], + timeoutMs: 20_000, + }, + { + id: 'roaming.set', + module: 'cellular', + title: '数据漫游开关', + description: '允许或禁止在漫游网络上注册。', + risk: 'R2', + method: 'POST', + path: '/roaming', + fields: [BOOL('allowed', '允许漫游')], + }, + { + id: 'airplane-mode.set', + module: 'cellular', + title: '飞行模式', + description: '开启飞行模式会立即切断全部无线连接。', + risk: 'R3', + method: 'POST', + path: '/airplane-mode', + fields: [BOOL('enabled', '开启飞行模式')], + }, + { + id: 'cell-monitor.start', + module: 'cellular', + title: '启动小区监视', + description: '开始持续采集服务小区与邻区数据。', + risk: 'R1', + method: 'POST', + path: '/cell-monitor/start', + fixed: EMPTY_BODY, + }, + { + id: 'cell-monitor.stop', + module: 'cellular', + title: '停止小区监视', + description: '停止后台小区采样,降低设备负载。', + risk: 'R1', + method: 'POST', + path: '/cell-monitor/stop', + fixed: EMPTY_BODY, + }, + { + id: 'baseband.restart', + module: 'cellular', + title: '重启基带', + description: '重启调制解调器,期间设备会完全离线约一分钟。', + risk: 'R3', + method: 'POST', + path: '/baseband/restart', + fixed: EMPTY_BODY, + timeoutMs: 60_000, + }, + { + id: 'wlan.enabled', + module: 'device-network', + title: 'WLAN 开关', + description: '开启或关闭设备无线网卡。', + risk: 'R1', + method: 'POST', + path: '/device-network/wlan/enabled', + fields: [BOOL('enabled', '启用 WLAN')], + }, + { + id: 'wlan.scan', + module: 'device-network', + title: '扫描 WLAN', + description: '触发一次无线网络扫描并刷新热点列表。', + risk: 'R1', + method: 'POST', + path: '/device-network/wlan/scan', + fixed: EMPTY_BODY, + timeoutMs: 30_000, + }, + { + id: 'wlan.connect', + module: 'device-network', + title: '连接 WLAN', + description: '加入指定热点,密码留空表示使用已保存的凭据。', + risk: 'R2', + method: 'POST', + path: '/device-network/wlan/connect', + fields: [ + TEXT('ssid', '网络名称(SSID)', { maxLength: 64 }), + { id: 'password', label: '密码', kind: 'secret', maxLength: 128 }, + { id: 'auto_join', label: '自动加入', kind: 'boolean' }, + ], + timeoutMs: 30_000, + }, + { + id: 'wlan.disconnect', + module: 'device-network', + title: '断开 WLAN', + description: '断开当前无线连接,不会删除已保存配置。', + risk: 'R1', + method: 'POST', + path: '/device-network/wlan/disconnect', + fixed: EMPTY_BODY, + }, + { + id: 'wlan.forget', + module: 'device-network', + title: '忽略 WLAN 网络', + description: '删除已保存的热点配置。', + risk: 'R2', + method: 'POST', + path: '/device-network/wlan/forget', + fields: [ + TEXT('uuid', '配置 UUID', { maxLength: 64 }), + TEXT('connection_id', '连接标识', { maxLength: 64 }), + ], + }, + { + id: 'ddns.sync', + module: 'device-network', + title: '立即同步 DDNS', + description: '强制把当前公网地址推送到 DDNS 服务商。', + risk: 'R1', + method: 'POST', + path: '/device-network/ddns/sync', + fixed: EMPTY_BODY, + timeoutMs: 30_000, + }, + { + id: 'ddns.logs-clear', + module: 'device-network', + title: '清空 DDNS 日志', + description: '删除设备上的 DDNS 更新记录。', + risk: 'R2', + method: 'POST', + path: '/device-network/ddns/logs/clear', + fixed: EMPTY_BODY, + emptyJsonObject: true, + }, + { + id: 'esim.enable-profile', + module: 'esim', + title: '切换 eSIM 配置', + description: '启用指定 ICCID 的 Profile,设备会重新注网。', + risk: 'R2', + method: 'POST', + path: '/esim/profiles/{iccid}/enable', + fields: [ + TEXT('iccid', 'ICCID', { in: 'path', maxLength: 32, pattern: '^[A-Za-z0-9_.\\-]{1,32}$' }), + ], + timeoutMs: 60_000, + }, + { + id: 'esim.rename-profile', + module: 'esim', + title: '重命名 eSIM 配置', + description: '修改 Profile 的显示名称。', + risk: 'R1', + method: 'POST', + path: '/esim/profiles/{iccid}/rename', + fields: [ + TEXT('iccid', 'ICCID', { in: 'path', maxLength: 32, pattern: '^[A-Za-z0-9_.\\-]{1,32}$' }), + TEXT('name', '新名称', { maxLength: 64 }), + ], + timeoutMs: 60_000, + }, + { + id: 'esim.delete-profile', + module: 'esim', + title: '删除 eSIM 配置', + description: '从 eUICC 移除 Profile,操作不可撤销。', + risk: 'R3', + method: 'DELETE', + path: '/esim/profiles/{iccid}', + fields: [ + TEXT('iccid', 'ICCID', { in: 'path', maxLength: 32, pattern: '^[A-Za-z0-9_.\\-]{1,32}$' }), + ], + timeoutMs: 60_000, + }, + { + id: 'esim.download-profile', + module: 'esim', + title: '下载 eSIM 配置', + description: '通过 SM-DP+ 服务器向 eUICC 写入新的 Profile。', + risk: 'R3', + method: 'POST', + path: '/esim/profiles', + fields: [ + TEXT('smdp', 'SM-DP+ 地址', { maxLength: 128 }), + TEXT('matching_id', 'Matching ID', { maxLength: 64 }), + { id: 'confirmation_code', label: '确认码', kind: 'secret', maxLength: 64 }, + { id: 'imei', label: 'IMEI', kind: 'string', maxLength: 32 }, + ], + timeoutMs: 180_000, + }, + { + id: 'esim.lpac-repair', + module: 'esim', + title: '修复 lpac', + description: '重新初始化设备的 lpac 组件。', + risk: 'R2', + method: 'POST', + path: '/esim/lpac/repair', + fields: [{ id: 'proxy_prefix', label: '代理前缀', kind: 'string', maxLength: 128 }], + timeoutMs: 120_000, + }, + { + id: 'call.dial', + module: 'calls', + title: '拨号', + description: '让设备拨打指定号码。', + risk: 'R2', + method: 'POST', + path: '/call/dial', + fields: [TEXT('phone_number', '被叫号码', { maxLength: 32, pattern: '^\\+?[0-9 ()-]{3,32}$' })], + }, + { + id: 'call.answer', + module: 'calls', + title: '接听通话', + description: '接听指定通话对象。', + risk: 'R1', + method: 'POST', + path: '/call/answer', + fields: [TEXT('path', '通话对象路径', { maxLength: 128 })], + }, + { + id: 'call.hangup', + module: 'calls', + title: '挂断通话', + description: '挂断指定通话对象。', + risk: 'R1', + method: 'POST', + path: '/call/hangup', + fields: [TEXT('path', '通话对象路径', { maxLength: 128 })], + }, + { + id: 'call.hangup-all', + module: 'calls', + title: '挂断全部通话', + description: '结束设备上所有进行中的通话。', + risk: 'R2', + method: 'POST', + path: '/call/hangup-all', + fixed: EMPTY_BODY, + }, + { + id: 'call.waiting', + module: 'calls', + title: '呼叫等待', + description: '开启或关闭网络侧呼叫等待业务。', + risk: 'R2', + method: 'POST', + path: '/call/settings', + fixed: { property: 'VoiceCallWaiting' }, + fields: [ + { + id: 'value', + label: '呼叫等待', + kind: 'choice', + required: true, + choices: [ + { value: 'enabled', label: '开启' }, + { value: 'disabled', label: '关闭' }, + ], + }, + ], + }, + { + id: 'call.history-clear', + module: 'calls', + title: '清空通话记录', + description: '删除设备上的全部通话历史。', + risk: 'R2', + method: 'POST', + path: '/call/history/clear', + fixed: EMPTY_BODY, + }, + { + id: 'sms.clear', + module: 'messages', + title: '清空全部短信', + description: '删除设备上保存的所有短信记录。', + risk: 'R3', + method: 'POST', + path: '/sms/clear', + fixed: EMPTY_BODY, + }, + { + id: 'work-mode.set', + module: 'configuration', + title: '切换工作模式', + description: '切换设备的管理模式,设备会重启相关服务。', + risk: 'R3', + method: 'POST', + path: '/work-mode', + fixed: { confirm: true }, + fields: [ + { id: 'mode', label: '工作模式', kind: 'choice', required: true, choices: WORK_MODES }, + ], + timeoutMs: 20_000, + }, + { + id: 'hub.configure', + module: 'configuration', + title: '配置设备回连', + description: '设置设备回连中心平台的地址与本地兜底策略。', + risk: 'R2', + method: 'POST', + path: '/hub', + fields: [ + BOOL('enabled', '启用回连'), + { id: 'url', label: '回连地址', kind: 'string', maxLength: 256 }, + { id: 'local_fallback_enabled', label: '启用本地兜底', kind: 'boolean' }, + { + id: 'local_fallback_timeout_seconds', + label: '本地兜底超时(秒)', + kind: 'number', + min: 10, + max: 86_400, + }, + ], + }, + { + id: 'hub.unbind', + module: 'configuration', + title: '解除回连绑定', + description: '清除设备的中心平台绑定,改由本机管理。', + risk: 'R3', + method: 'POST', + path: '/hub/unbind', + fixed: EMPTY_BODY, + }, + { + id: 'notifications.test-channel', + module: 'notifications', + title: '发送测试通知', + description: '通过指定渠道发送一条测试消息。', + risk: 'R1', + method: 'POST', + path: '/notifications/test/{channel}', + fields: [TEXT('channel', '渠道标识', { in: 'path', maxLength: 64 })], + timeoutMs: 30_000, + }, + { + id: 'notifications.logs-clear', + module: 'notifications', + title: '清空通知日志', + description: '删除设备上的通知发送记录。', + risk: 'R2', + method: 'POST', + path: '/notifications/logs/clear', + fixed: EMPTY_BODY, + emptyJsonObject: true, + }, + { + id: 'notifications.queue-retry-all', + module: 'notifications', + title: '重投全部待办通知', + description: '让设备立即重试通知队列中所有待发送与失败的任务。', + risk: 'R2', + method: 'POST', + path: '/notifications/queue/retry-all', + fixed: EMPTY_BODY, + timeoutMs: 30_000, + }, + { + id: 'notifications.queue-clear', + module: 'notifications', + title: '清空通知队列', + description: '丢弃设备上尚未发送与已处理的通知队列条目。', + risk: 'R2', + method: 'POST', + path: '/notifications/queue/clear', + fixed: EMPTY_BODY, + }, + { + id: 'automation.test-task', + module: 'automation', + title: '试运行自动化任务', + description: '立即执行一次指定的设备自动化任务。', + risk: 'R1', + method: 'POST', + path: '/automation/test/{taskId}', + fields: [TEXT('taskId', '任务标识', { in: 'path', maxLength: 64 })], + timeoutMs: 60_000, + }, + { + id: 'automation.logs-clear', + module: 'automation', + title: '清空自动化日志', + description: '删除设备上的自动化执行记录。', + risk: 'R2', + method: 'POST', + path: '/automation/logs/clear', + fixed: EMPTY_BODY, + emptyJsonObject: true, + }, + { + id: 'ota.check-release', + module: 'ota', + title: '检查最新版本', + description: '向发布源查询最新固件版本信息。', + risk: 'R1', + method: 'POST', + path: '/ota/latest-release', + fixed: { include_variants: true }, + fields: [{ id: 'proxy_prefix', label: '加速节点前缀', kind: 'string', maxLength: 256 }], + timeoutMs: 60_000, + }, + { + id: 'ota.online-prepare', + module: 'ota', + title: '在线下载更新包', + description: '让设备下载并暂存指定的升级包。', + risk: 'R2', + method: 'POST', + path: '/ota/online-prepare', + fields: [ + TEXT('asset_name', '升级包名称', { maxLength: 128 }), + { id: 'proxy_prefix', label: '加速节点前缀', kind: 'string', maxLength: 256 }, + ], + timeoutMs: 300_000, + }, + { + id: 'ota.apply', + module: 'ota', + title: '应用更新', + description: '安装已暂存的升级包,设备可能自动重启。', + risk: 'R3', + method: 'POST', + path: '/ota/apply', + fields: [BOOL('restart_now', '升级后立即重启')], + timeoutMs: 300_000, + }, + { + id: 'ota.cancel', + module: 'ota', + title: '取消更新', + description: '丢弃已暂存的升级包。', + risk: 'R1', + method: 'POST', + path: '/ota/cancel', + fixed: EMPTY_BODY, + }, + { + id: 'vowifi.feature', + module: 'vowifi', + title: 'VoWiFi 功能开关', + description: '启用或停用设备的 VoWiFi 能力。', + risk: 'R2', + method: 'POST', + path: '/vowifi/feature', + fields: [BOOL('enabled', '启用 VoWiFi')], + timeoutMs: 20_000, + }, + { + id: 'vowifi.connection', + module: 'vowifi', + title: 'VoWiFi 连接开关', + description: '建立或断开 VoWiFi IMS 连接。', + risk: 'R2', + method: 'POST', + path: '/vowifi/connection', + fields: [BOOL('enabled', '保持连接')], + timeoutMs: 120_000, + }, + { + id: 'vowifi.connect', + module: 'vowifi', + title: '发起 VoWiFi 注册', + description: '立即尝试一次 IMS 注册。', + risk: 'R2', + method: 'POST', + path: '/vowifi/connect', + fixed: EMPTY_BODY, + timeoutMs: 120_000, + }, + { + id: 'backup.export-local', + module: 'device-backup', + title: '本机生成备份', + description: '在设备上生成所选组件的备份文件。', + risk: 'R2', + method: 'POST', + path: '/backup/export-local', + fields: [ + { + id: 'components', + label: '备份组件', + kind: 'string-list', + required: true, + hint: '留空表示全部', + }, + ], + timeoutMs: 120_000, + }, + { + id: 'backup.data-clear', + module: 'device-backup', + title: '清除设备数据', + description: '按组件清除设备本地数据,操作不可撤销。', + risk: 'R3', + method: 'POST', + path: '/backup/data/clear', + fields: [{ id: 'components', label: '要清除的组件', kind: 'string-list', required: true }], + timeoutMs: 120_000, + }, + { + id: 'backup.delete-file', + module: 'device-backup', + title: '删除备份文件', + description: '删除设备上的一个备份文件。', + risk: 'R3', + method: 'DELETE', + path: '/backup/files/{name}', + fields: [TEXT('name', '备份文件名', { in: 'path', maxLength: 128 })], + }, + { + id: 'backup.apply-file', + module: 'device-backup', + title: '恢复备份文件', + description: '用设备上的备份文件恢复所选组件。', + risk: 'R3', + method: 'POST', + path: '/backup/files/{name}/apply', + fields: [ + TEXT('name', '备份文件名', { in: 'path', maxLength: 128 }), + { + id: 'mode', + label: '恢复模式', + kind: 'choice', + in: 'query', + required: true, + choices: [ + { value: 'replace', label: '整体替换' }, + { value: 'merge', label: '合并' }, + ], + }, + { id: 'components', label: '恢复组件', kind: 'string-list', in: 'query', required: true }, + ], + timeoutMs: 120_000, + }, + { + id: 'network.register-auto', + module: 'cellular', + title: '自动注册网络', + description: '让设备重新向网络发起自动注册,通常用于掉网后恢复驻网。', + risk: 'R2', + method: 'POST', + path: '/network/register-auto', + fixed: EMPTY_BODY, + timeoutMs: 30_000, + }, + { + id: 'call.volume.set', + module: 'calls', + title: '调整通话音量', + description: '设置通话的扬声器与麦克风音量,部分固件未开放该能力。', + risk: 'R1', + method: 'POST', + path: '/call/volume', + fields: [ + { id: 'speaker_volume', label: '扬声器音量', kind: 'number', min: 0, max: 100 }, + { id: 'microphone_volume', label: '麦克风音量', kind: 'number', min: 0, max: 100 }, + { id: 'muted', label: '静音', kind: 'boolean' }, + ], + }, + { + id: 'call.forwarding.set', + module: 'calls', + title: '设置呼叫转移', + description: '按转移类型登记或清除呼转号码,部分固件未开放该能力。', + risk: 'R2', + method: 'POST', + path: '/call/forwarding', + fields: [ + { + id: 'forward_type', + label: '转移类型', + kind: 'choice', + required: true, + choices: [ + { value: 'unconditional', label: '无条件转移' }, + { value: 'busy', label: '遇忙转移' }, + { value: 'no_reply', label: '无应答转移' }, + { value: 'not_reachable', label: '不可达转移' }, + ], + }, + TEXT('number', '转移目标号码', { maxLength: 32 }), + { id: 'timeout', label: '无应答等待(秒)', kind: 'number', min: 0, max: 300 }, + ], + }, + { + id: 'esim.config.save', + module: 'esim', + title: '保存 eSIM 配置', + description: '设置设备侧 lpac 可执行文件路径与自定义 eUICC 可用内存。', + risk: 'R2', + method: 'POST', + path: '/esim/config', + fields: [ + { id: 'lpac_path', label: 'lpac 路径', kind: 'string', maxLength: 256 }, + { + id: 'custom_memory_total_kb', + label: '自定义内存总量(KB)', + kind: 'number', + min: 1, + max: 1_000_000, + }, + ], + }, + { + id: 'wlan.profile.save', + module: 'device-network', + title: '保存 WLAN 配置', + description: '修改已保存热点的自动加入与 IPv4 获取方式。', + risk: 'R2', + method: 'POST', + path: '/device-network/wlan/profile', + fields: [ + TEXT('connection_id', '连接标识', { maxLength: 64 }), + { id: 'auto_join', label: '自动加入', kind: 'boolean' }, + { + id: 'ipv4_mode', + label: 'IPv4 模式', + kind: 'choice', + choices: [ + { value: 'dhcp', label: 'DHCP 自动获取' }, + { value: 'manual', label: '手动指定' }, + ], + }, + { id: 'ipv4_address', label: 'IPv4 地址', kind: 'string', maxLength: 45 }, + { id: 'ipv4_prefix', label: 'IPv4 前缀长度', kind: 'number', min: 0, max: 128 }, + { id: 'ipv4_gateway', label: 'IPv4 网关', kind: 'string', maxLength: 45 }, + ], + }, + { + id: 'auth.settings.save', + module: 'configuration', + title: '保存安全设置', + description: '调整设备后台的密码策略与会话有效期。', + risk: 'R2', + method: 'POST', + path: '/auth/settings', + fields: [ + { id: 'password_protection_enabled', label: '启用密码保护', kind: 'boolean' }, + { id: 'password_min_length', label: '密码最小长度', kind: 'number', min: 1, max: 32 }, + { id: 'password_require_letters', label: '密码须含字母', kind: 'boolean' }, + { id: 'password_require_digits', label: '密码须含数字', kind: 'boolean' }, + { id: 'password_require_symbols', label: '密码须含符号', kind: 'boolean' }, + { + id: 'session_ttl_seconds', + label: '会话有效期(秒)', + kind: 'number', + min: 60, + max: 2_592_000, + }, + { + id: 'idle_timeout_seconds', + label: '空闲超时(秒)', + kind: 'number', + min: 60, + max: 2_592_000, + }, + ], + }, + { + id: 'auth.password.set', + module: 'configuration', + title: '修改设备管理密码', + description: '设置设备后台的登录密码,修改后需要重新登录设备。', + risk: 'R3', + method: 'POST', + path: '/auth/password', + fields: [ + { id: 'new_password', label: '新密码', kind: 'secret', required: true, maxLength: 128 }, + ], + }, +]); + +export const DEVICE_ACTION_MODULES: readonly (InstanceModuleKey | 'messages')[] = [ + 'overview', + 'sim', + 'cellular', + 'device-network', + 'esim', + 'calls', + 'messages', + 'configuration', + 'device-backup', + 'notifications', + 'automation', + 'ota', + 'vowifi', +]; + +export function findDeviceAction(id: string): DeviceActionDefinition | undefined { + return DEVICE_ACTIONS.find((action) => action.id === id); +} + +export function deviceActionsFor( + module: InstanceModuleKey | 'messages', +): readonly DeviceActionDefinition[] { + return DEVICE_ACTIONS.filter((action) => action.module === module); +} diff --git a/apps/api/src/application/instances/device-action-service.test.ts b/apps/api/src/application/instances/device-action-service.test.ts new file mode 100644 index 0000000..54cc4e9 --- /dev/null +++ b/apps/api/src/application/instances/device-action-service.test.ts @@ -0,0 +1,200 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { + InstanceSessionStore, + type UpstreamRequest, +} from '../connections/upstream-session-client.js'; +import type { InstanceService } from '../instances/instance-service.js'; +import { DeviceActionError, DeviceActionService } from './device-action-service.js'; + +interface Reply { + readonly status: number; + readonly body?: unknown; +} + +function fixture( + replies: readonly Reply[] = [{ status: 200, body: { status: 'success' } }], + extra: { + readonly ensureSession?: (id: string, origin: string, force?: boolean) => Promise; + } = {}, +) { + 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 instances = { + get: async (id: string) => + id === 'node-a' ? { id: 'node-a', name: 'Node A', origin: 'http://node-a.local' } : undefined, + } as unknown as InstanceService; + const service = new DeviceActionService({ + instances, + sessions, + db, + request: async (request) => { + calls.push(request); + const reply = replies[Math.min(cursor, replies.length - 1)] as Reply; + cursor += 1; + return { + status: reply.status, + headers: {}, + body: reply.body === undefined ? '' : JSON.stringify(reply.body), + }; + }, + ...(extra.ensureSession ? { ensureSession: extra.ensureSession } : {}), + now: () => new Date('2026-09-04T00:00:00.000Z'), + id: () => 'audit-row-1', + }); + return { service, db, calls, sessions }; +} + +const context = { actor: 'loopback-control-plane', requestId: 'req-1', confirm: true }; + +describe('DeviceActionService', () => { + it('exposes the catalog so the console can render controls without knowing device paths', () => { + const { service } = fixture(); + const actions = service.list(); + expect(actions.length).toBeGreaterThan(40); + expect(actions.every((action) => !('path' in action))).toBe(true); + expect(actions.find((action) => action.id === 'band-lock.apply')?.fields).toHaveLength(4); + }); + + it('dispatches a zero-body action with only an accept header', async () => { + const { service, calls } = fixture(); + const result = await service.execute('node-a', 'cell-lock.unlock-all', {}, context); + expect(result.ok).toBe(true); + expect(calls[0]).toMatchObject({ + url: 'http://node-a.local/api/cell-lock/unlock-all', + method: 'POST', + headers: { accept: 'application/json', cookie: 'simadmin_session=opaque' }, + deviceAction: { body: undefined }, + }); + }); + + it('merges fixed payload keys with validated body fields', async () => { + const { service, calls } = fixture(); + await service.execute('node-a', 'call.waiting', { value: 'enabled' }, context); + expect(calls[0]?.deviceAction?.body).toEqual({ + property: 'VoiceCallWaiting', + value: 'enabled', + }); + }); + + it('substitutes path parameters and builds the restore query in order', async () => { + const { service, calls } = fixture(); + await service.execute( + 'node-a', + 'backup.apply-file', + { name: 'backup-2026.tar.gz', mode: 'merge', components: ['instances', 'jobs'] }, + context, + ); + expect(calls[0]).toMatchObject({ + url: 'http://node-a.local/api/backup/files/backup-2026.tar.gz/apply?mode=merge&components=instances,jobs', + method: 'POST', + }); + }); + + it('rejects a risky action without an explicit confirmation', async () => { + const { service, calls } = fixture(); + await expect( + service.execute( + 'node-a', + 'airplane-mode.set', + { enabled: true }, + { actor: 'loopback-control-plane', requestId: 'req-1' }, + ), + ).rejects.toThrow(DeviceActionError); + expect(calls).toHaveLength(0); + }); + + it('rejects unknown params, out-of-range numbers and values outside a choice set', async () => { + const { service, calls } = fixture(); + await expect( + service.execute('node-a', 'data.set', { active: true, extra: 1 }, context), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', fieldId: 'extra' }); + await expect( + service.execute( + 'node-a', + 'cell-lock.apply', + { rat: 'lte', enable: true, arfcn: -1, pci: 5 }, + context, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', fieldId: 'arfcn' }); + await expect( + service.execute('node-a', 'radio-mode.set', { mode: 'cdma' }, context), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', fieldId: 'mode' }); + await expect( + service.execute('node-a', 'sim.cache-phone', { phone_number: 'abc' }, context), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', fieldId: 'phone_number' }); + expect(calls).toHaveLength(0); + }); + + it('refuses a path parameter that could escape the device path', async () => { + const { service, calls } = fixture(); + await expect( + service.execute('node-a', 'esim.delete-profile', { iccid: '../../admin' }, context), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED', fieldId: 'iccid' }); + expect(calls).toHaveLength(0); + }); + + it('refreshes an expired device session once and retries', async () => { + const { service, calls, sessions } = fixture( + [ + { status: 401, body: { status: 'error', msg: 'unauthorized' } }, + { status: 200, body: { status: 'success', data: { applied: true } } }, + ], + { + ensureSession: async () => { + sessions.set('node-a', 'http://node-a.local', 'simadmin_session=rotated'); + }, + }, + ); + const result = await service.execute('node-a', 'ota.cancel', {}, context); + expect(calls).toHaveLength(2); + expect(result.ok).toBe(true); + expect(calls[0]?.headers.cookie).toBe('simadmin_session=opaque'); + expect(calls.at(-1)?.headers.cookie).toBe('simadmin_session=rotated'); + }); + + it('reports a device failure without pretending it succeeded', async () => { + const { service } = fixture([{ status: 200, body: { status: 'error', msg: 'SIM 卡未就绪' } }]); + const result = await service.execute('node-a', 'sim.refresh-details', {}, context); + expect(result.ok).toBe(false); + expect(result.message).toBe('SIM 卡未就绪'); + }); + + it('writes a fully redacted audit record for every dispatch', async () => { + const { service, db } = fixture(); + await service.execute( + 'node-a', + 'wlan.connect', + { ssid: 'office', password: 'sup3r-secret', auto_join: true }, + context, + ); + const row = db + .prepare( + 'SELECT actor,operation_id,risk_level,result_code,parameters_summary_json,duration_ms FROM audit_events WHERE id=?', + ) + .get('audit-row-1') as Record; + expect(row).toMatchObject({ + actor: 'loopback-control-plane', + operation_id: 'wlan.connect', + risk_level: 'R2', + result_code: 'succeeded', + }); + const summary = JSON.parse(String(row.parameters_summary_json)) as Record[]; + expect(summary.map((item) => item.fieldId)).toEqual(['ssid', 'password', 'auto_join']); + expect(JSON.stringify(summary)).not.toContain('sup3r-secret'); + expect(JSON.stringify(summary)).not.toContain('office'); + expect(summary.every((item) => item.redacted === true)).toBe(true); + }); +}); diff --git a/apps/api/src/application/instances/device-action-service.ts b/apps/api/src/application/instances/device-action-service.ts new file mode 100644 index 0000000..12d8fe2 --- /dev/null +++ b/apps/api/src/application/instances/device-action-service.ts @@ -0,0 +1,449 @@ +import { randomUUID } from 'node:crypto'; + +import type { InstanceService } from './instance-service.js'; +import { + DEVICE_ACTIONS, + findDeviceAction, + type DeviceActionDefinition, + type DeviceActionField, + type DeviceActionRisk, +} from './device-action-catalog.js'; +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; +import type { + InstanceSessionStore, + UpstreamResponse, + UpstreamSessionClientOptions, +} from '../connections/upstream-session-client.js'; + +export type DeviceActionErrorCode = + | 'NOT_FOUND' + | 'VALIDATION_FAILED' + | 'SESSION_INVALID' + | 'UPSTREAM_FAILED' + | 'NOT_DISPATCHED'; + +export class DeviceActionError extends Error { + constructor( + readonly code: DeviceActionErrorCode, + readonly fieldId?: string, + ) { + super(code); + this.name = 'DeviceActionError'; + } +} + +export interface DeviceActionDescriptor { + readonly id: string; + readonly module: DeviceActionDefinition['module']; + readonly title: string; + readonly description: string; + readonly risk: DeviceActionRisk; + readonly method: DeviceActionDefinition['method']; + readonly fields: readonly DeviceActionField[]; +} + +export interface DeviceActionResult { + readonly actionId: string; + readonly title: string; + readonly status: number; + readonly ok: boolean; + readonly data: unknown; + readonly message: string | null; + readonly durationMs: number; +} + +export interface DeviceActionContext { + readonly actor: string; + readonly requestId: string; + /** R2 and R3 actions only dispatch when the caller repeats an explicit confirmation. */ + readonly confirm?: boolean; +} + +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; +const SECRET_KEY = + /(password|passwd|secret|token|cookie|authorization|apikey|api_key|privatekey|private_key|session|confirmation_code)/iu; +const MAX_RESPONSE_BYTES = 32_768; +const MAX_DEPTH = 5; +const MAX_ARRAY_ENTRIES = 64; +const MAX_STRING_LENGTH = 512; +const DEFAULT_TIMEOUT_MS = 15_000; +const TIMEOUT_SENTINEL: UpstreamResponse = { status: 0, headers: {}, body: '' }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sanitize(value: unknown, depth = 0): unknown { + if (value === null) return null; + switch (typeof value) { + case 'string': + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}...` + : CONTROL_CHARACTERS.test(value) + ? value.replace(CONTROL_CHARACTERS, '') + : value; + case 'number': + return Number.isFinite(value) ? value : null; + case 'boolean': + return value; + default: + break; + } + if (depth >= MAX_DEPTH) return null; + if (Array.isArray(value)) + return value.slice(0, MAX_ARRAY_ENTRIES).map((item) => sanitize(item, depth + 1)); + if (isRecord(value)) { + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (SECRET_KEY.test(key)) continue; + output[key] = sanitize(entry, depth + 1); + } + return output; + } + return null; +} + +async function withTimeout( + operation: Promise, + timeoutMs: number, +): Promise { + let timer: ReturnType | undefined; + const guard = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs); + }); + try { + return await Promise.race([operation, guard]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function validateBoolean(field: DeviceActionField, raw: unknown): boolean { + if (typeof raw !== 'boolean') throw new DeviceActionError('VALIDATION_FAILED', field.id); + return raw; +} + +function validateNumber(field: DeviceActionField, raw: unknown): number { + if (typeof raw !== 'number' || !Number.isSafeInteger(raw)) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + if (field.min !== undefined && raw < field.min) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + if (field.max !== undefined && raw > field.max) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + return raw; +} + +function validateText(field: DeviceActionField, raw: unknown): string { + if (typeof raw !== 'string') throw new DeviceActionError('VALIDATION_FAILED', field.id); + const value = raw.trim(); + if (value.length === 0) { + if (field.required) throw new DeviceActionError('VALIDATION_FAILED', field.id); + return ''; + } + if (value.length > (field.maxLength ?? 128)) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + if (CONTROL_CHARACTERS.test(value)) throw new DeviceActionError('VALIDATION_FAILED', field.id); + if (field.pattern !== undefined && !new RegExp(field.pattern, 'u').test(value)) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + return value; +} + +function validateChoice(field: DeviceActionField, raw: unknown): string { + const value = validateText(field, raw); + if (!value) return ''; + if (!(field.choices ?? []).some((choice) => choice.value === value)) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + return value; +} + +function validateList(field: DeviceActionField, raw: unknown): string[] { + if (!Array.isArray(raw) || raw.length > 128) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + const output: string[] = []; + for (const item of raw) { + if (typeof item !== 'string' || item.length > 64 || CONTROL_CHARACTERS.test(item)) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + const value = item.trim(); + if (value) output.push(value); + } + if (field.required && output.length === 0) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + return output; +} + +/** + * The audit reader only accepts fully redacted summaries, so a submitted value is never stored: + * the record says which fields were supplied and leaves the content out. + */ +function summarize( + action: DeviceActionDefinition, + values: Readonly>, +): readonly { fieldId: string; displayValue: string; redacted: true }[] { + return (action.fields ?? []) + .filter((field) => values[field.id] !== undefined) + .map((field) => ({ fieldId: field.id, displayValue: '[REDACTED]', redacted: true as const })); +} + +export interface DeviceActionServiceOptions { + readonly instances: InstanceService; + readonly sessions: InstanceSessionStore; + readonly request: UpstreamSessionClientOptions['request']; + readonly db: SqliteDatabase; + readonly ensureSession?: (instanceId: string, origin: string, force?: boolean) => Promise; + readonly now?: () => Date; + readonly id?: () => string; +} + +/** + * Runs an allowlisted device mutation on behalf of the console. Every dispatch is validated + * against the catalog, audited before the response leaves the process, and re-authorized once + * when the device session has expired. + */ +export class DeviceActionService { + private readonly now: () => Date; + private readonly id: () => string; + + constructor(private readonly options: DeviceActionServiceOptions) { + this.now = options.now ?? (() => new Date()); + this.id = options.id ?? randomUUID; + } + + list(): readonly DeviceActionDescriptor[] { + return DEVICE_ACTIONS.map((action) => + Object.freeze({ + id: action.id, + module: action.module, + title: action.title, + description: action.description, + risk: action.risk, + method: action.method, + fields: Object.freeze( + [...(action.fields ?? [])].map((field) => Object.freeze({ ...field })), + ), + }), + ); + } + + async execute( + instanceId: string, + actionId: string, + params: Readonly>, + context: DeviceActionContext, + ): Promise { + const action = findDeviceAction(actionId); + if (!action) throw new DeviceActionError('NOT_FOUND'); + if (action.risk !== 'R1' && context.confirm !== true) + throw new DeviceActionError('VALIDATION_FAILED', 'confirm'); + const instance = await this.options.instances.get(instanceId); + if (!instance) throw new DeviceActionError('NOT_FOUND'); + const session = this.options.sessions.sessionFor(instanceId); + if (session && session.origin !== instance.origin) + throw new DeviceActionError('SESSION_INVALID'); + + const values = this.#validate(action, params); + const { path, query, body } = this.#assemble(action, values); + if (!session && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin); + } catch { + // Passwordless devices accept anonymous mutations; otherwise the probe reports below. + } + } + + const timeoutMs = action.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const target = `${instance.origin}/api${path}${query}`; + const send = async (cookie: string | undefined): Promise => { + const headers: Record = {}; + if (body === undefined) headers.accept = 'application/json'; + else { + headers.accept = 'application/json'; + headers['content-type'] = 'application/json'; + } + if (cookie) headers.cookie = cookie; + return withTimeout( + this.options.request({ + url: target, + method: action.method, + headers, + deviceAction: { body }, + }), + timeoutMs, + ); + }; + + const startedAt = this.now().getTime(); + let token = this.options.sessions.sessionFor(instanceId)?.cookie; + let response: UpstreamResponse; + try { + response = await send(token); + if ((response.status === 401 || response.status === 403) && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin, true); + } catch { + // Keep the device answer; the result below reports the failure. + } + const refreshed = this.options.sessions.sessionFor(instanceId)?.cookie; + if (refreshed && refreshed !== token) { + token = refreshed; + response = await send(token); + } + } + } catch { + // The pinned transport refused or could not reach the device: nothing left this process. + const durationMs = Math.max(0, this.now().getTime() - startedAt); + this.#audit(action, instanceId, context, values, 0, false, durationMs); + throw new DeviceActionError('UPSTREAM_FAILED'); + } + const durationMs = Math.max(0, this.now().getTime() - startedAt); + const parsed = this.#interpret(response); + this.#audit(action, instanceId, context, values, response.status, parsed.ok, durationMs); + return { + actionId: action.id, + title: action.title, + status: response.status, + ok: parsed.ok, + data: parsed.data, + message: parsed.message, + durationMs, + }; + } + + #validate( + action: DeviceActionDefinition, + params: Readonly>, + ): Record { + const values: Record = {}; + for (const field of action.fields ?? []) { + const raw = params[field.id]; + if (raw === undefined || raw === null) { + if (field.required) throw new DeviceActionError('VALIDATION_FAILED', field.id); + continue; + } + switch (field.kind) { + case 'boolean': + values[field.id] = validateBoolean(field, raw); + break; + case 'number': + values[field.id] = validateNumber(field, raw); + break; + case 'string': + case 'secret': { + const text = validateText(field, raw); + if (text) values[field.id] = text; + break; + } + case 'choice': { + const choice = validateChoice(field, raw); + if (choice) values[field.id] = choice; + break; + } + case 'string-list': { + const list = validateList(field, raw); + if (list.length > 0 || field.required) values[field.id] = list; + break; + } + default: + throw new DeviceActionError('VALIDATION_FAILED', field.id); + } + } + const declared = new Set((action.fields ?? []).map((field) => field.id)); + for (const key of Object.keys(params)) + if (!declared.has(key)) throw new DeviceActionError('VALIDATION_FAILED', key); + return values; + } + + #assemble( + action: DeviceActionDefinition, + values: Record, + ): { path: string; query: string; body: Readonly> | undefined } { + let path = action.path; + const queryParts: string[] = []; + const body: Record = { ...(action.fixed ?? {}) }; + for (const field of action.fields ?? []) { + const value = values[field.id]; + if (value === undefined) continue; + const place = field.in ?? 'body'; + if (place === 'path') { + if (!/^[A-Za-z0-9_.\-]{1,128}$/u.test(String(value))) + throw new DeviceActionError('VALIDATION_FAILED', field.id); + const token = `{${field.id}}`; + if (!path.includes(token)) throw new DeviceActionError('VALIDATION_FAILED', field.id); + path = path.replace(token, String(value)); + } else if (place === 'query') { + const rendered = Array.isArray(value) + ? value.map((item) => encodeURIComponent(item)).join(',') + : encodeURIComponent(String(value)); + queryParts.push(`${field.id}=${rendered}`); + } else body[field.id] = value; + } + if (path.includes('{')) throw new DeviceActionError('VALIDATION_FAILED'); + const query = queryParts.length ? `?${queryParts.join('&')}` : ''; + const empty = action.emptyJsonObject === true; + return { + path, + query, + body: Object.keys(body).length || empty ? body : undefined, + }; + } + + #interpret(response: UpstreamResponse): { + ok: boolean; + data: unknown; + message: string | null; + } { + if (response.status === 0) return { ok: false, data: null, message: '设备未在限定时间内响应' }; + if (Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES) + return { ok: false, data: null, message: '设备响应超出长度限制' }; + let root: unknown = null; + if (response.body.trim().length > 0) { + try { + root = JSON.parse(response.body); + } catch { + root = null; + } + } + const record = isRecord(root) ? root : undefined; + const status = typeof record?.status === 'string' ? record.status : undefined; + const ok = + response.status >= 200 && + response.status < 300 && + (status === undefined || status === 'success' || status === 'ok'); + const rawMessage = + (typeof record?.message === 'string' && record.message) || + (typeof record?.msg === 'string' && record.msg) || + null; + const message = rawMessage ? rawMessage.slice(0, MAX_STRING_LENGTH) : null; + return { ok, data: sanitize(record?.data ?? record ?? null), message }; + } + + #audit( + action: DeviceActionDefinition, + instanceId: string, + context: DeviceActionContext, + values: Record, + status: number, + ok: boolean, + durationMs: number, + ): void { + const created_at = this.now().toISOString(); + this.options.db + .prepare( + `INSERT INTO audit_events + (id,instance_id,job_id,actor,operation_id,risk_level,request_id,parameters_summary_json,body_digest,result_code,duration_ms,created_at) + VALUES (?,?,NULL,?,?,?,?,?,?,?,?,?)`, + ) + .run( + this.id(), + instanceId, + context.actor, + action.id, + action.risk, + context.requestId, + JSON.stringify(summarize(action, values)), + null, + ok ? 'succeeded' : 'failed', + durationMs, + created_at, + ); + } +} diff --git a/apps/api/src/application/instances/device-discovery-service.test.ts b/apps/api/src/application/instances/device-discovery-service.test.ts new file mode 100644 index 0000000..385cca5 --- /dev/null +++ b/apps/api/src/application/instances/device-discovery-service.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import type { networkInterfaces } from 'node:os'; +import type { InstancePage } from '@multi-simadmin/contracts'; + +import { + DeviceDiscoveryService, + DiscoveryError, + localRanges, + type DiscoveryTransport, +} from './device-discovery-service.js'; + +const INTERFACES: ReturnType = { + en0: [ + { + address: '192.168.1.23', + family: 'IPv4', + internal: false, + netmask: '255.255.255.0', + cidr: '192.168.1.23/24', + mac: '', + }, + { + address: '127.0.0.1', + family: 'IPv4', + internal: true, + netmask: '255.0.0.0', + cidr: '127.0.0.1/8', + mac: '', + }, + { + address: 'fe80::1', + family: 'IPv6', + internal: false, + netmask: '', + scopeid: 0, + cidr: 'fe80::1/64', + mac: '', + }, + ], + en1: [ + { + address: '10.20.30.40', + family: 'IPv4', + internal: false, + netmask: '255.255.255.0', + cidr: '10.20.30.40/24', + mac: '', + }, + ], + en2: [ + { + address: '203.0.113.9', + family: 'IPv4', + internal: false, + netmask: '255.255.255.0', + cidr: '203.0.113.9/24', + mac: '', + }, + ], +}; + +function page(items: InstancePage['items'], pageSize = 200): InstancePage { + return { items, page: { page: 1, pageSize, total: items.length } }; +} + +const instances = { + list: async () => + page([ + { + id: 'inst-known', + name: '已知设备', + origin: 'http://192.168.1.50:3000', + tags: [], + groupId: null, + revision: 1, + capabilityStatus: 'unknown', + freshness: 'unknown', + credentialConfigured: false, + }, + ]), +}; + +function transportFor( + responses: Readonly>, +): DiscoveryTransport & { urls: string[] } { + const urls: string[] = []; + return { + urls, + async get(url) { + urls.push(url); + const hit = responses[url]; + if (hit) return hit; + throw new Error('ECONNREFUSED'); + }, + }; +} + +const DEVICE_BODY = JSON.stringify({ + model: 'UFI-003', + manufacturer: 'Comtrade', + firmware_version: '2.4.1', + imei: '490154203237518', + phone_number: '13800000000', + oversized: 'x'.repeat(200), +}); + +function service(options: { + readonly transport: DiscoveryTransport; + readonly now?: () => Date; + readonly maxSessions?: number; +}) { + return new DeviceDiscoveryService({ + transport: options.transport, + instances, + interfaces: () => INTERFACES, + ports: [3000], + concurrency: 8, + maxTargets: 600, + ...(options.now ? { now: options.now } : {}), + ...(options.maxSessions === undefined ? {} : { maxSessions: options.maxSessions }), + }); +} + +async function settled( + discovery: DeviceDiscoveryService, + sessionId: string, +): Promise>> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const state = await discovery.status(sessionId); + if (state.status === 'completed') return state; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('scan did not settle'); +} + +describe('localRanges', () => { + it('keeps only non-internal private IPv4 subnets', () => { + expect(localRanges(INTERFACES)).toEqual(['10.20.30.0/24', '192.168.1.0/24']); + }); +}); + +describe('DeviceDiscoveryService', () => { + it('reports reachable devices with whitelisted identity and known instances', async () => { + const transport = transportFor({ + 'http://192.168.1.50:3000/api/health': { status: 200, body: '{"ok":true}' }, + 'http://192.168.1.50:3000/api/device': { status: 200, body: DEVICE_BODY }, + 'http://10.20.30.77:3000/api/health': { status: 401, body: '' }, + }); + const discovery = service({ transport }); + const started = await discovery.start(); + expect(started.ranges).toEqual(['10.20.30.0/24', '192.168.1.0/24']); + expect(started.total).toBe(508); + + const state = await settled(discovery, started.sessionId); + expect(state.scanned).toBe(508); + expect(state.devices.map((device) => device.origin)).toEqual([ + 'http://10.20.30.77:3000', + 'http://192.168.1.50:3000', + ]); + const known = state.devices.find((device) => device.origin === 'http://192.168.1.50:3000'); + expect(known?.knownInstanceId).toBe('inst-known'); + expect(known?.identity).toEqual({ + model: 'UFI-003', + manufacturer: 'Comtrade', + firmware_version: '2.4.1', + }); + const anonymous = state.devices.find((device) => device.origin === 'http://10.20.30.77:3000'); + expect(anonymous?.httpStatus).toBe(401); + expect(anonymous?.knownInstanceId).toBeNull(); + expect(anonymous?.identity).toEqual({}); + expect(transport.urls.every((url) => /\/api\/(health|device)$/u.test(url))).toBe(true); + }); + + it('drops an expired lease for good and closes a live session on demand', async () => { + let now = new Date('2026-09-04T10:00:00.000Z'); + const transport = transportFor({}); + const discovery = service({ transport, now: () => now }); + const started = await discovery.start(); + now = new Date('2026-09-04T11:00:00.000Z'); + await expect(discovery.renew(started.sessionId)).rejects.toBeInstanceOf(DiscoveryError); + // A lease that lapsed is never resurrected, even if the clock comes back. + now = new Date('2026-09-04T10:00:30.000Z'); + await expect(discovery.status(started.sessionId)).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + + const live = await discovery.start(); + const renewed = await discovery.renew(live.sessionId); + expect(renewed.expiresAt).toBe('2026-09-04T10:01:30.000Z'); + expect((await discovery.status(live.sessionId)).sessionId).toBe(live.sessionId); + await discovery.stop(live.sessionId); + await expect(discovery.status(live.sessionId)).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(discovery.stop(live.sessionId)).resolves.toBeUndefined(); + }); + + it('refuses more sessions than the configured ceiling', async () => { + const discovery = service({ transport: transportFor({}), maxSessions: 1 }); + await discovery.start(); + await expect(discovery.start()).rejects.toMatchObject({ code: 'TOO_MANY_SESSIONS' }); + }); + + it('probes a manually entered address and normalises the origin', async () => { + const transport = transportFor({ + 'http://192.168.68.1:3000/api/health': { status: 200, body: '{}' }, + 'http://192.168.68.1:3000/api/device': { status: 200, body: DEVICE_BODY }, + }); + const discovery = service({ transport }); + const probe = await discovery.probe('192.168.68.1:3000'); + expect(probe).toMatchObject({ + origin: 'http://192.168.68.1:3000', + reachable: true, + httpStatus: 200, + knownInstanceId: null, + }); + expect(probe.identity.model).toBe('UFI-003'); + expect('imei' in probe.identity).toBe(false); + expect('phone_number' in probe.identity).toBe(false); + expect('oversized' in probe.identity).toBe(false); + }); + + it('marks an unreachable address without failing the request', async () => { + const probe = await service({ transport: transportFor({}) }).probe('http://10.0.0.9:3000'); + expect(probe).toMatchObject({ reachable: false, httpStatus: null, identity: {} }); + }); + + it('rejects addresses that are not a private LAN root URL', async () => { + const discovery = service({ transport: transportFor({}) }); + const rejected = [ + undefined, + 'https://example.com', + 'http://8.8.8.8:3000', + 'http://admin:p@ss@192.168.1.5:3000', + 'http://192.168.1.5:3000/api/device', + 'http://192.168.1.5:3000/?next=x', + 'ftp://192.168.1.5:3000', + 'http://192.168.1.5:99999', + ]; + for (const value of rejected) { + await expect(discovery.probe(value)).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + } + }); +}); diff --git a/apps/api/src/application/instances/device-discovery-service.ts b/apps/api/src/application/instances/device-discovery-service.ts new file mode 100644 index 0000000..bb8b031 --- /dev/null +++ b/apps/api/src/application/instances/device-discovery-service.ts @@ -0,0 +1,392 @@ +/** + * LAN discovery for SimAdmin devices, fused from the Hub "局域网发现 + 地址接入" onboarding flow. + * + * The scan is deliberately narrow: it only ever dials literal private IPv4 addresses taken from + * the host's own network interfaces, on a fixed port set, with one read-only probe path. Nothing + * here accepts a caller-supplied host, so the endpoint cannot be turned into an SSRF oracle. + */ +import { networkInterfaces } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import type { InstancePage, InstancePageQuery } from '@multi-simadmin/contracts'; + +export interface DiscoveryTransport { + get(url: string): Promise<{ readonly status: number; readonly body: string }>; +} + +export interface KnownInstanceOrigin { + list(query: InstancePageQuery): Promise; +} + +export type DiscoveredDevice = Readonly<{ + origin: string; + address: string; + port: number; + secure: boolean; + httpStatus: number; + identity: Readonly>; + knownInstanceId: string | null; +}>; + +export type DiscoverySession = Readonly<{ + sessionId: string; + status: 'scanning' | 'completed'; + createdAt: string; + expiresAt: string; + scanned: number; + total: number; + ranges: readonly string[]; + devices: readonly DiscoveredDevice[]; +}>; + +export type DeviceProbe = Readonly<{ + origin: string; + reachable: boolean; + httpStatus: number | null; + identity: Readonly>; + knownInstanceId: string | null; +}>; + +export type DiscoveryErrorCode = 'VALIDATION_FAILED' | 'NOT_FOUND' | 'TOO_MANY_SESSIONS'; + +export class DiscoveryError extends Error { + constructor( + readonly code: DiscoveryErrorCode, + message: string, + ) { + super(message); + this.name = 'DiscoveryError'; + } +} + +export interface DeviceDiscoveryServiceOptions { + readonly transport: DiscoveryTransport; + readonly instances: KnownInstanceOrigin; + readonly interfaces?: () => ReturnType; + readonly now?: () => Date; + readonly idFactory?: () => string; + readonly ports?: readonly number[]; + readonly leaseMs?: number; + readonly concurrency?: number; + readonly maxTargets?: number; + readonly maxSessions?: number; +} + +/** SimAdmin serves its console on 3000; 8080/8443 cover the usual reverse-proxy setups. */ +export const DEFAULT_DISCOVERY_PORTS: readonly number[] = [3000, 8080, 8443]; + +const DEFAULT_LEASE_MS = 60_000; +const DEFAULT_CONCURRENCY = 48; +const DEFAULT_MAX_TARGETS = 1_024; +const DEFAULT_MAX_SESSIONS = 4; +const HEALTH_PATH = '/api/health'; +const DEVICE_PATH = '/api/device'; +/** Only non-identifying fields cross the boundary; IMEI, ICCI and phone numbers never do. */ +const IDENTITY_FIELDS: readonly string[] = [ + 'model', + 'manufacturer', + 'brand', + 'firmware_version', + 'os_version', + 'version', +]; +const MAX_IDENTITY_VALUE_LENGTH = 80; +const MAX_IDENTITY_FIELDS = 6; + +type Target = Readonly<{ origin: string; address: string; port: number; secure: boolean }>; + +interface SessionRecord { + readonly sessionId: string; + readonly createdAt: Date; + expiresAt: Date; + status: 'scanning' | 'completed'; + closed: boolean; + scanned: number; + readonly total: number; + readonly ranges: readonly string[]; + readonly devices: DiscoveredDevice[]; +} + +function privateV4(address: string): boolean { + const parts = address.split('.'); + if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false; + const octets = parts.map(Number); + if (octets.some((part) => part > 255)) return false; + const [a, b] = octets as [number, number]; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +function securePort(port: number): boolean { + return port === 443 || port === 8443; +} + +function originFor(address: string, port: number): string { + return `${securePort(port) ? 'https' : 'http'}://${address}:${port}`; +} + +/** Every /24 the host itself sits on, plus an optional caller-supplied /24 in dotted form. */ +export function localRanges(interfaces: ReturnType): readonly string[] { + const ranges = new Set(); + for (const entries of Object.values(interfaces)) { + for (const entry of entries ?? []) { + if (entry.internal || entry.family !== 'IPv4') continue; + if (!privateV4(entry.address)) continue; + const [a, b, c] = entry.address.split('.'); + if (!a || !b || !c) continue; + ranges.add(`${a}.${b}.${c}.0/24`); + } + } + return [...ranges].sort(); +} + +function expand(ranges: readonly string[], ports: readonly number[], limit: number): Target[] { + const targets: Target[] = []; + const seen = new Set(); + for (const range of ranges) { + const prefix = range.replace(/\.0\/24$/u, ''); + for (let host = 1; host <= 254 && targets.length < limit; host += 1) { + const address = `${prefix}.${host}`; + for (const port of ports) { + if (targets.length >= limit) break; + const key = `${address}:${port}`; + if (seen.has(key)) continue; + seen.add(key); + targets.push({ origin: originFor(address, port), address, port, secure: securePort(port) }); + } + } + } + return targets; +} + +function identity(payload: unknown): Readonly> { + if (typeof payload !== 'object' || payload === null) return {}; + const source = payload as Record; + const result: Record = {}; + for (const field of IDENTITY_FIELDS) { + if (Object.keys(result).length >= MAX_IDENTITY_FIELDS) break; + const value = source[field]; + if (typeof value !== 'string' && typeof value !== 'number') continue; + const text = String(value).trim(); + if (!text || text.length > MAX_IDENTITY_VALUE_LENGTH || /[\x00-\x1F\x7F]/u.test(text)) continue; + result[field] = text; + } + return result; +} + +function parseJson(body: string): unknown { + try { + return JSON.parse(body) as unknown; + } catch { + return null; + } +} + +function normalizeDeviceUrl(raw: unknown): Target { + if (typeof raw !== 'string') + throw new DiscoveryError('VALIDATION_FAILED', '设备地址必须是字符串'); + const candidate = raw.trim(); + let url: URL; + try { + url = new URL(candidate.includes('://') ? candidate : `http://${candidate}`); + } catch { + throw new DiscoveryError('VALIDATION_FAILED', '设备地址格式不正确'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new DiscoveryError('VALIDATION_FAILED', '设备地址必须使用 HTTP 或 HTTPS'); + if (url.username || url.password || url.search || url.hash) + throw new DiscoveryError('VALIDATION_FAILED', '设备地址不能包含凭据、查询或片段'); + if (url.pathname !== '/' && url.pathname !== '') + throw new DiscoveryError('VALIDATION_FAILED', '设备地址只能是根地址'); + const address = url.hostname.replace(/^\[|\]$/g, ''); + if (!privateV4(address)) + throw new DiscoveryError('VALIDATION_FAILED', '设备地址必须是私有局域网 IPv4 地址'); + const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80; + if (!Number.isInteger(port) || port < 1 || port > 65_535) + throw new DiscoveryError('VALIDATION_FAILED', '设备地址端口不正确'); + return { origin: url.origin, address, port, secure: url.protocol === 'https:' }; +} + +export class DeviceDiscoveryService { + private readonly sessions = new Map(); + private readonly ranges: () => ReturnType; + private readonly clock: () => Date; + private readonly id: () => string; + private readonly ports: readonly number[]; + private readonly leaseMs: number; + private readonly concurrency: number; + private readonly maxTargets: number; + private readonly maxSessions: number; + + constructor(private readonly options: DeviceDiscoveryServiceOptions) { + this.ranges = options.interfaces ?? networkInterfaces; + this.clock = options.now ?? (() => new Date()); + this.id = options.idFactory ?? randomUUID; + this.ports = options.ports ?? DEFAULT_DISCOVERY_PORTS; + this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS; + this.concurrency = options.concurrency ?? DEFAULT_CONCURRENCY; + this.maxTargets = options.maxTargets ?? DEFAULT_MAX_TARGETS; + this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; + } + + /** Opens a short-lease scan; the caller renews it while the wizard stays open. */ + async start(): Promise { + this.#evict(); + if (this.sessions.size >= this.maxSessions) + throw new DiscoveryError('TOO_MANY_SESSIONS', '设备发现会话数量已达上限'); + const ranges = localRanges(this.ranges()); + const targets = expand(ranges, this.ports, this.maxTargets); + const now = this.clock(); + const record: SessionRecord = { + sessionId: this.id(), + createdAt: now, + expiresAt: new Date(now.getTime() + this.leaseMs), + status: 'scanning', + closed: false, + scanned: 0, + total: targets.length, + ranges, + devices: [], + }; + this.sessions.set(record.sessionId, record); + void this.#sweep(record, targets); + return this.#view(record); + } + + async renew(sessionId: string): Promise { + const record = this.#require(sessionId); + record.expiresAt = new Date(this.clock().getTime() + this.leaseMs); + return this.#view(record); + } + + async status(sessionId: string): Promise { + return this.#view(this.#require(sessionId)); + } + + async stop(sessionId: string): Promise { + const record = this.#sessions().get(sessionId); + if (!record) return; + record.closed = true; + this.sessions.delete(sessionId); + } + + /** Single-address check used by the manual "输入设备地址" step of the wizard. */ + async probe(rawUrl: unknown): Promise { + const target = normalizeDeviceUrl(rawUrl); + const known = await this.#knownOrigins(); + const knownInstanceId = known.get(target.origin) ?? null; + const response = await this.options.transport + .get(`${target.origin}${HEALTH_PATH}`) + .catch(() => null); + if (!response) + return { + origin: target.origin, + reachable: false, + httpStatus: null, + identity: {}, + knownInstanceId, + }; + return { + origin: target.origin, + reachable: true, + httpStatus: response.status, + identity: await this.#identify(target), + knownInstanceId, + }; + } + + async #sweep(record: SessionRecord, targets: readonly Target[]): Promise { + const known = await this.#knownOrigins().catch(() => new Map()); + let cursor = 0; + const worker = async (): Promise => { + for (;;) { + if (record.closed || this.clock() >= record.expiresAt) return; + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const target = targets[index]!; + const device = await this.#inspect(target, known); + record.scanned += 1; + if (device && !record.closed) record.devices.push(device); + } + }; + const lanes = Math.max(1, Math.min(this.concurrency, targets.length || 1)); + await Promise.all(Array.from({ length: lanes }, () => worker())); + if (!record.closed) record.status = 'completed'; + } + + async #inspect( + target: Target, + known: ReadonlyMap, + ): Promise { + const response = await this.options.transport + .get(`${target.origin}${HEALTH_PATH}`) + .catch(() => null); + if (!response) return null; + return { + origin: target.origin, + address: target.address, + port: target.port, + secure: target.secure, + httpStatus: response.status, + identity: await this.#identify(target), + knownInstanceId: known.get(target.origin) ?? null, + }; + } + + async #identify(target: Target): Promise>> { + const response = await this.options.transport + .get(`${target.origin}${DEVICE_PATH}`) + .catch(() => null); + if (!response || response.status >= 400) return {}; + return identity(parseJson(response.body)); + } + + async #knownOrigins(): Promise> { + const map = new Map(); + let page = 1; + while (page <= 10) { + const result = await this.options.instances.list({ page, pageSize: 200 }); + for (const instance of result.items) map.set(instance.origin, instance.id); + const seen = page * result.page.pageSize; + if (seen >= result.page.total || result.items.length === 0) break; + page += 1; + } + return map; + } + + #require(sessionId: string): SessionRecord { + const record = this.#sessions().get(sessionId); + if (!record) throw new DiscoveryError('NOT_FOUND', '设备发现会话不存在或已过期'); + return record; + } + + /** + * Leases are dropped lazily: a session lives exactly as long as its lease, so a wizard that + * stops renewing simply loses its scan and has to start a new one. Nothing outlives the lease. + */ + #sessions(): Map { + this.#evict(); + return this.sessions; + } + + #evict(): void { + const now = this.clock(); + for (const [key, record] of this.sessions) { + if (record.expiresAt.getTime() <= now.getTime()) this.sessions.delete(key); + } + } + + #view(record: SessionRecord): DiscoverySession { + return { + sessionId: record.sessionId, + status: record.closed ? 'completed' : record.status, + createdAt: record.createdAt.toISOString(), + expiresAt: record.expiresAt.toISOString(), + scanned: record.scanned, + total: record.total, + ranges: record.ranges, + devices: [...record.devices].sort((left, right) => + left.address.localeCompare(right.address, 'en', { numeric: true }), + ), + }; + } +} diff --git a/apps/api/src/application/instances/instance-module-catalog.ts b/apps/api/src/application/instances/instance-module-catalog.ts new file mode 100644 index 0000000..e08989a --- /dev/null +++ b/apps/api/src/application/instances/instance-module-catalog.ts @@ -0,0 +1,133 @@ +/** + * Read-only catalog of SimAdmin device endpoints, grouped by the console module that renders + * them. Every entry is a GET-safe probe: the control plane never mutates a device through this + * catalog, so a module read is always safe to retry and safe to run on a schedule. + */ +export type InstanceModuleKey = + | 'overview' + | 'sim' + | 'cellular' + | 'device-network' + | 'esim' + | 'calls' + | 'configuration' + | 'device-backup' + | 'notifications' + | 'automation' + | 'ota' + | 'vowifi'; + +export interface ModuleProbe { + /** Stable identifier the console renders; never a device-controlled value. */ + readonly key: string; + /** Path appended to the instance origin, always rooted at the device /api namespace. */ + readonly path: string; + readonly timeoutMs?: number; +} + +export const INSTANCE_MODULE_KEYS: readonly InstanceModuleKey[] = [ + 'overview', + 'sim', + 'cellular', + 'device-network', + 'esim', + 'calls', + 'configuration', + 'device-backup', + 'notifications', + 'automation', + 'ota', + 'vowifi', +]; + +export const INSTANCE_MODULE_PROBES: Readonly> = { + overview: [ + { key: 'device', path: '/device' }, + { key: 'sim', path: '/sim' }, + { key: 'network', path: '/network' }, + { key: 'stats', path: '/stats' }, + { key: 'connectivity', path: '/connectivity' }, + { key: 'data', path: '/data' }, + { key: 'cpu', path: '/stats/cpu' }, + { key: 'smsStats', path: '/sms/stats' }, + ], + sim: [ + { key: 'sim', path: '/sim' }, + { key: 'apn', path: '/apn' }, + { key: 'bandLock', path: '/band-lock' }, + { key: 'cellLock', path: '/cell-lock' }, + ], + cellular: [ + { key: 'network', path: '/network' }, + { key: 'signalStrength', path: '/network/signal-strength' }, + { key: 'cells', path: '/cells' }, + { key: 'cellLocation', path: '/location/cell-info' }, + { key: 'operators', path: '/network/operators' }, + { key: 'roaming', path: '/roaming' }, + { key: 'radioMode', path: '/radio-mode' }, + { key: 'airplaneMode', path: '/airplane-mode' }, + { key: 'basebandRestart', path: '/baseband/restart/status' }, + { key: 'cellMonitor', path: '/cell-monitor/status' }, + ], + 'device-network': [ + { key: 'interfaces', path: '/network/interfaces' }, + { key: 'addresses', path: '/network/connection-addresses' }, + { key: 'wlanStatus', path: '/device-network/wlan/status' }, + { key: 'wlanProfiles', path: '/device-network/wlan/profiles' }, + { key: 'ddnsStatus', path: '/device-network/ddns/status' }, + { key: 'ddnsConfig', path: '/device-network/ddns/config' }, + { key: 'ddnsLogs', path: '/device-network/ddns/logs' }, + ], + esim: [ + { key: 'euicc', path: '/esim/euicc', timeoutMs: 30_000 }, + { key: 'profiles', path: '/esim/profiles?cached=1' }, + { key: 'config', path: '/esim/config' }, + { key: 'lpacStatus', path: '/esim/lpac/status' }, + ], + calls: [ + { key: 'calls', path: '/calls' }, + { key: 'history', path: '/call/history?limit=50' }, + { key: 'settings', path: '/call/settings' }, + { key: 'forwarding', path: '/call/forwarding' }, + { key: 'volume', path: '/call/volume' }, + { key: 'voicemail', path: '/voicemail/status' }, + { key: 'ims', path: '/ims/status' }, + ], + configuration: [ + { key: 'workMode', path: '/work-mode' }, + { key: 'authSettings', path: '/auth/settings' }, + { key: 'authStatus', path: '/auth/status' }, + { key: 'hub', path: '/hub' }, + ], + 'device-backup': [ + { key: 'files', path: '/backup/files' }, + { key: 'config', path: '/backup/config' }, + { key: 'options', path: '/backup/options' }, + ], + notifications: [ + { key: 'config', path: '/notifications/config' }, + { key: 'queue', path: '/notifications/queue?limit=50' }, + { key: 'logs', path: '/notifications/logs?limit=50' }, + ], + automation: [ + { key: 'config', path: '/automation/config' }, + { key: 'logs', path: '/automation/logs' }, + ], + ota: [{ key: 'status', path: '/ota/status' }], + vowifi: [ + { key: 'status', path: '/vowifi/status' }, + { key: 'control', path: '/vowifi/control' }, + { key: 'profile', path: '/vowifi/profile' }, + { key: 'profiles', path: '/vowifi/profiles' }, + // The aggregate diagnostics feed carries the registration timeline the Hub shows. + { key: 'diagnostics', path: '/vowifi/diagnostics?limit=50', timeoutMs: 30_000 }, + { key: 'events', path: '/vowifi/events?limit=50', timeoutMs: 10_000 }, + { key: 'smsDeliveries', path: '/vowifi/sms/delivery?limit=20', timeoutMs: 10_000 }, + { key: 'soakRuns', path: '/vowifi/soak?limit=20', timeoutMs: 10_000 }, + { key: 'restore', path: '/vowifi/esim-restore/status', timeoutMs: 10_000 }, + ], +}; + +export function isInstanceModuleKey(value: unknown): value is InstanceModuleKey { + return typeof value === 'string' && (INSTANCE_MODULE_KEYS as readonly string[]).includes(value); +} diff --git a/apps/api/src/application/instances/instance-module-service.test.ts b/apps/api/src/application/instances/instance-module-service.test.ts new file mode 100644 index 0000000..fded9b4 --- /dev/null +++ b/apps/api/src/application/instances/instance-module-service.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import { + InstanceSessionStore, + type UpstreamRequest, +} from '../connections/upstream-session-client.js'; +import type { InstanceService } from '../instances/instance-service.js'; +import { InstanceModuleService } from './instance-module-service.js'; + +interface Reply { + readonly status: number; + readonly body?: unknown; +} + +function fixture(probes: Readonly>) { + const sessions = new InstanceSessionStore(); + const calls: UpstreamRequest[] = []; + const instances = { + get: async (id: string) => + id === 'node-a' + ? { + id: 'node-a', + name: 'Node A', + origin: 'http://node-a.local', + tags: [], + groupId: null, + revision: 1, + capabilityStatus: 'unknown', + freshness: 'unknown', + credentialConfigured: false, + } + : undefined, + } as unknown as InstanceService; + const service = new InstanceModuleService({ + instances, + sessions, + request: async (request) => { + calls.push(request); + const path = request.url.slice('http://node-a.local/api'.length); + const reply = probes[path] ?? { status: 404 }; + return { + status: reply.status, + headers: {}, + body: reply.body === undefined ? '' : JSON.stringify(reply.body), + }; + }, + now: () => new Date('2026-09-04T00:00:00.000Z'), + }); + return { service, sessions, calls }; +} + +describe('InstanceModuleService', () => { + it('reads every probe for a module and classifies each section', async () => { + const { service } = fixture({ + '/device': { status: 200, body: { model: 'LPAX', android_version: '13' } }, + '/stats': { status: 200, body: { cpu_percent: 12 } }, + '/connectivity': { status: 200, body: {} }, + }); + const snapshot = await service.read('node-a', 'overview'); + expect(snapshot.observedAt).toBe('2026-09-04T00:00:00.000Z'); + const byKey = Object.fromEntries(snapshot.sections.map((section) => [section.key, section])); + expect(byKey.device?.state).toBe('ok'); + expect(byKey.device?.data).toMatchObject({ model: 'LPAX' }); + expect(byKey.stats?.state).toBe('ok'); + expect(byKey.connectivity?.state).toBe('empty'); + expect(byKey.data?.state).toBe('unsupported'); + }); + + it('reports auth-required when the device rejects an anonymous read', async () => { + const { service } = fixture({ + '/sim': { status: 401 }, + '/apn': { status: 200, body: { apns: [] } }, + '/band-lock': { status: 200, body: { locked: false } }, + '/cell-lock': { status: 200, body: { locked: false } }, + }); + const snapshot = await service.read('node-a', 'sim'); + const sim = snapshot.sections.find((section) => section.key === 'sim'); + expect(sim?.state).toBe('auth-required'); + expect(sim?.status).toBe(401); + }); + + it('retries once with a refreshed session and keeps the authenticated answer', async () => { + const sessions = new InstanceSessionStore(); + let attempts = 0; + const service = new InstanceModuleService({ + instances: { + get: async () => ({ id: 'node-a', origin: 'http://node-a.local' }), + } as unknown as InstanceService, + sessions, + request: async () => { + attempts += 1; + return attempts === 1 + ? { status: 401, headers: {}, body: '' } + : { status: 200, headers: {}, body: JSON.stringify({ imei: '123' }) }; + }, + ensureSession: async () => { + sessions.set('node-a', 'http://node-a.local', 'simadmin_session=abc'); + }, + }); + const snapshot = await service.read('node-a', 'sim'); + expect(snapshot.authenticated).toBe(true); + expect(snapshot.sections.find((section) => section.key === 'sim')?.state).toBe('ok'); + expect(attempts).toBeGreaterThan(1); + }); + + it('redacts credential-looking fields and bounds oversized payloads', async () => { + const { service } = fixture({ + '/ota/status': { + status: 200, + body: { + version: '1.2.3', + admin_password: 'hunter2', + session_token: 'nope', + nested: { log: 'x'.repeat(5_000) }, + }, + }, + }); + const snapshot = await service.read('node-a', 'ota'); + const raw = JSON.stringify(snapshot); + expect(raw).not.toContain('hunter2'); + expect(raw).not.toContain('nope'); + const section = snapshot.sections[0]; + const data = section?.data as { nested?: { log?: string } }; + expect((data.nested?.log ?? '').length).toBeLessThanOrEqual(2_004); + }); + + it('turns a hanging probe into a failed section instead of blocking the module', async () => { + const hanging = new InstanceModuleService({ + instances: { + get: async () => ({ id: 'node-a', origin: 'http://node-a.local' }), + } as unknown as InstanceService, + sessions: new InstanceSessionStore(), + request: async (request) => { + if (request.url.endsWith('/device')) return new Promise(() => {}); + return { status: 200, headers: {}, body: '{}' }; + }, + defaultTimeoutMs: 100, + }); + const snapshot = await hanging.read('node-a', 'overview'); + expect(snapshot.sections.find((section) => section.key === 'device')?.state).toBe('failed'); + expect(snapshot.sections.find((section) => section.key === 'stats')?.state).toBe('empty'); + }); + + it('unwraps the device response envelope so panels see the real payload', async () => { + const { service } = fixture({ + '/device': { + status: 200, + body: { status: 'ok', message: 'Success', data: { model: 'LPAX', imei: '123' } }, + }, + '/stats': { status: 200, body: { status: 'ok', message: 'Success', data: {} } }, + '/connectivity': { + status: 200, + body: { status: 'error', message: 'Connectivity is not exposed by this backend' }, + }, + }); + const snapshot = await service.read('node-a', 'overview'); + const byKey = Object.fromEntries(snapshot.sections.map((section) => [section.key, section])); + expect(byKey.device?.state).toBe('ok'); + expect(byKey.device?.data).toEqual({ model: 'LPAX', imei: '123' }); + expect(byKey.stats?.state).toBe('empty'); + expect(byKey.connectivity?.state).toBe('unsupported'); + }); + + it('rejects unknown modules and unknown instances', async () => { + const { service } = fixture({}); + await expect(service.read('node-a', 'nope' as 'overview')).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + await expect(service.read('missing', 'overview')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/apps/api/src/application/instances/instance-module-service.ts b/apps/api/src/application/instances/instance-module-service.ts new file mode 100644 index 0000000..11630c2 --- /dev/null +++ b/apps/api/src/application/instances/instance-module-service.ts @@ -0,0 +1,321 @@ +import type { InstanceService } from './instance-service.js'; +import { + INSTANCE_MODULE_PROBES, + type InstanceModuleKey, + type ModuleProbe, +} from './instance-module-catalog.js'; +import type { + InstanceSessionStore, + UpstreamResponse, + UpstreamSessionClientOptions, +} from '../connections/upstream-session-client.js'; + +export type InstanceModuleErrorCode = 'NOT_FOUND' | 'VALIDATION_FAILED'; + +export class InstanceModuleError extends Error { + constructor( + readonly code: InstanceModuleErrorCode, + message: string = code, + ) { + super(message); + this.name = 'InstanceModuleError'; + } +} + +export type ModuleSectionState = 'ok' | 'empty' | 'auth-required' | 'unsupported' | 'failed'; + +export interface ModuleSection { + readonly key: string; + readonly path: string; + readonly state: ModuleSectionState; + readonly status: number | null; + readonly data: unknown; +} + +export interface ModuleSnapshot { + readonly instanceId: string; + readonly module: InstanceModuleKey; + readonly observedAt: string; + readonly authenticated: boolean; + readonly sections: readonly ModuleSection[]; +} + +const MAX_SECTION_BYTES = 64_000; +const MAX_SNAPSHOT_BYTES = 220_000; +const MAX_DEPTH = 6; +const MAX_ARRAY_ENTRIES = 200; +const MAX_STRING_LENGTH = 2_000; +const CONCURRENCY = 4; +const DEFAULT_PROBE_TIMEOUT_MS = 6_000; +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; +const SECRET_KEY = + /(password|passwd|secret|token|cookie|authorization|apikey|api_key|privatekey|private_key|session)/iu; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Device payloads are untrusted. Depth, breadth and string length are bounded and anything that + * looks like a credential is dropped before the snapshot leaves the control plane. + */ +function sanitize(value: unknown, depth = 0): unknown { + if (value === null) return null; + switch (typeof value) { + case 'string': + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}...` + : CONTROL_CHARACTERS.test(value) + ? value.replace(CONTROL_CHARACTERS, '') + : value; + case 'number': + return Number.isFinite(value) ? value : null; + case 'boolean': + return value; + default: + break; + } + if (depth >= MAX_DEPTH) return null; + if (Array.isArray(value)) + return value.slice(0, MAX_ARRAY_ENTRIES).map((item) => sanitize(item, depth + 1)); + if (isRecord(value)) { + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (SECRET_KEY.test(key)) continue; + output[key] = sanitize(entry, depth + 1); + } + return output; + } + return null; +} + +function isEmpty(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (Array.isArray(value)) return value.length === 0; + if (isRecord(value)) return Object.keys(value).length === 0; + return false; +} + +interface DecodedBody { + readonly data: unknown; + readonly failed: boolean; + readonly envelope: ModuleSectionState | undefined; +} + +/** + * Device routes answer inside a `{status, message, data}` envelope. The payload the console renders + * lives one level deeper, so the envelope is inspected for a capability verdict and then removed. + * Error bodies are left whole because the message is the only useful part of them. + */ +function decodeBody(response: UpstreamResponse): DecodedBody { + if (Buffer.byteLength(response.body, 'utf8') > MAX_SECTION_BYTES) + return { data: null, failed: true, envelope: undefined }; + try { + const decoded = sanitize(JSON.parse(response.body)); + const envelope = envelopeState(decoded); + if (envelope) return { data: decoded, failed: false, envelope }; + return { data: unwrapEnvelope(decoded), failed: false, envelope }; + } catch { + return { data: null, failed: true, envelope: undefined }; + } +} + +function unwrapEnvelope(value: unknown): unknown { + if (!isRecord(value) || typeof value.status !== 'string' || value.status !== 'ok') return value; + return 'data' in value ? value.data : value; +} + +/** + * Devices answer 200 with an error envelope when a feature is compiled out of the firmware, for + * example "Call forwarding is not exposed by ModemManager on this backend". That is a capability + * verdict, not a read failure, so it must not render as data or count against the device. + */ +function envelopeState(data: unknown): ModuleSectionState | undefined { + if (!isRecord(data) || data.status !== 'error') return undefined; + const message = typeof data.message === 'string' ? data.message.toLocaleLowerCase() : ''; + if (/not exposed|not supported|unsupported|not implemented|no such|disabled by/u.test(message)) + return 'unsupported'; + return 'failed'; +} + +function classify(status: number, body: DecodedBody): ModuleSectionState { + if (status === 401 || status === 403) return 'auth-required'; + if (status === 404 || status === 405 || status === 501) return 'unsupported'; + if (body.failed || status < 200 || status >= 300) return 'failed'; + return body.envelope ?? (isEmpty(body.data) ? 'empty' : 'ok'); +} + +async function withTimeout( + operation: Promise, + timeoutMs: number, + onTimeout: () => T, +): Promise { + let timer: ReturnType | undefined; + const guard = new Promise((resolve) => { + timer = setTimeout(() => resolve(onTimeout()), timeoutMs); + }); + try { + return await Promise.race([operation, guard]); + } finally { + if (timer) clearTimeout(timer); + } +} + +const TIMEOUT_SENTINEL: UpstreamResponse = { status: 0, headers: {}, body: '' }; + +export interface InstanceModuleServiceOptions { + readonly instances: InstanceService; + readonly sessions: InstanceSessionStore; + readonly request: UpstreamSessionClientOptions['request']; + readonly ensureSession?: (instanceId: string, origin: string, force?: boolean) => Promise; + readonly now?: () => Date; + /** Fallback for catalog entries without an explicit budget; keeps a dead device from stalling. */ + readonly defaultTimeoutMs?: number; +} + +/** + * Reads a whole console module from a device in one call. Individual probes fail independently so + * a device that only implements part of the API still renders the sections it supports. + */ +/** Shared across the probes of one read so a device that needs a login is logged in once. */ +interface ProbeContext { + cookie: string | undefined; + refreshAttempted: boolean; +} + +export class InstanceModuleService { + private readonly now: () => Date; + private readonly defaultTimeoutMs: number; + + constructor(private readonly options: InstanceModuleServiceOptions) { + this.now = options.now ?? (() => new Date()); + this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + } + + async read(instanceId: string, module: InstanceModuleKey): Promise { + const probes = INSTANCE_MODULE_PROBES[module]; + if (!probes) throw new InstanceModuleError('VALIDATION_FAILED', 'Unknown instance module'); + const instance = await this.options.instances.get(instanceId); + if (!instance) throw new InstanceModuleError('NOT_FOUND', 'Instance was not found'); + + const session = this.options.sessions.sessionFor(instanceId); + if (session && session.origin !== instance.origin) + throw new InstanceModuleError('VALIDATION_FAILED', 'Instance session does not match origin'); + if (!session && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin); + } catch { + // Passwordless devices are readable anonymously; probes report auth-required otherwise. + } + } + + const context: ProbeContext = { + cookie: this.options.sessions.sessionFor(instanceId)?.cookie, + refreshAttempted: false, + }; + const sections = await this.#probeAll(instanceId, instance.origin, probes, context); + return { + instanceId, + module, + observedAt: this.now().toISOString(), + authenticated: Boolean(context.cookie), + sections: trimToBudget(sections), + }; + } + + async #probeAll( + instanceId: string, + origin: string, + probes: readonly ModuleProbe[], + context: ProbeContext, + ): Promise { + const results: ModuleSection[] = new Array(probes.length); + const queue = probes.map((probe, index) => ({ probe, index })); + const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => { + for (;;) { + const next = queue.shift(); + if (!next) return; + results[next.index] = await this.#probeOne(instanceId, origin, next.probe, context); + } + }); + await Promise.all(workers); + return results; + } + + async #probeOne( + instanceId: string, + origin: string, + probe: ModuleProbe, + context: ProbeContext, + ): Promise { + const timeoutMs = probe.timeoutMs ?? this.defaultTimeoutMs; + const send = (token: string | undefined): Promise => + this.options.request({ + url: `${origin}/api${probe.path}`, + method: 'GET', + headers: { accept: 'application/json', ...(token ? { cookie: token } : {}) }, + }); + let response: UpstreamResponse; + try { + response = await withTimeout(send(context.cookie), timeoutMs, () => TIMEOUT_SENTINEL); + const rejected = response.status === 401 || response.status === 403; + if (rejected && this.options.ensureSession && !context.refreshAttempted) { + context.refreshAttempted = true; + try { + await this.options.ensureSession(instanceId, origin, true); + } catch { + // Keep the unauthenticated answer; the section reports auth-required below. + } + const refreshed = this.options.sessions.sessionFor(instanceId)?.cookie; + if (refreshed) { + context.cookie = refreshed; + response = await withTimeout(send(refreshed), timeoutMs, () => TIMEOUT_SENTINEL); + } + } + } catch { + return { + key: probe.key, + path: probe.path, + state: 'failed', + status: null, + data: null, + }; + } + const body = decodeBody(response); + return { + key: probe.key, + path: probe.path, + state: classify(response.status, body), + status: response.status === 0 ? null : response.status, + data: body.data, + }; + } +} + +/** Drops the tail of the largest payloads until the whole snapshot fits the response budget. */ +function trimToBudget(sections: readonly ModuleSection[]): readonly ModuleSection[] { + const sizes = sections.map((section) => + section.state === 'ok' || section.state === 'empty' + ? Buffer.byteLength(JSON.stringify(section.data ?? null), 'utf8') + : 0, + ); + let total = sizes.reduce((sum, size) => sum + size, 0); + if (total <= MAX_SNAPSHOT_BYTES) return sections; + const trimmed = sections.map((section, index) => ({ section, size: sizes[index] ?? 0 })); + for (;;) { + const largest = trimmed.reduce( + (best, item) => (item.size > (best?.size ?? 0) ? item : best), + trimmed[0] as { section: ModuleSection; size: number } | undefined, + ); + if (!largest || largest.size === 0 || total <= MAX_SNAPSHOT_BYTES) break; + const cut = Math.min(largest.size, total - MAX_SNAPSHOT_BYTES) + 1_024; + largest.section = { + ...largest.section, + state: 'ok', + data: { truncated: true, note: 'payload exceeded the response budget' }, + }; + total -= cut; + largest.size = 0; + } + return trimmed.map((item) => item.section); +} diff --git a/apps/api/src/application/instances/instance-service.test.ts b/apps/api/src/application/instances/instance-service.test.ts index 2e08b49..9f7c48c 100644 --- a/apps/api/src/application/instances/instance-service.test.ts +++ b/apps/api/src/application/instances/instance-service.test.ts @@ -109,6 +109,7 @@ describe('InstanceService', () => { name: 'Alpha', origin: 'http://192.168.1.10:8080', tags: ['a', 'z'], + groupId: null, revision: 1, capabilityStatus: 'unknown', freshness: 'unknown', diff --git a/apps/api/src/application/instances/instance-service.ts b/apps/api/src/application/instances/instance-service.ts index 9163e6e..a02466c 100644 --- a/apps/api/src/application/instances/instance-service.ts +++ b/apps/api/src/application/instances/instance-service.ts @@ -53,6 +53,7 @@ interface InstanceRow { base_url: string; config_revision: number; updated_at: string; + group_id: string | null; } interface SecretRow { id: string; @@ -161,6 +162,7 @@ export class InstanceService { const name = normalizeName(input.name); const origin = normalizeOrigin(input.origin); const tags = normalizeTags(input.tags); + const groupId = this.normalizeGroupId(input.groupId); validatePassword(input.password); const instanceId = this.id(); let newSecret: { id: string; external: string } | undefined; @@ -171,9 +173,9 @@ export class InstanceService { this.db.transaction(() => { this.db .prepare( - 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?, ?,1,1,?,?)', + 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at,group_id) VALUES (?,?,?, ?,1,1,?,?,?)', ) - .run(instanceId, name, origin, newSecret ? 'password' : 'none', now, now); + .run(instanceId, name, origin, newSecret ? 'password' : 'none', now, now, groupId); this.replaceTags(instanceId, tags, now); if (newSecret) this.insertReference(newSecret.id, instanceId, newSecret.external, now); })(); @@ -217,6 +219,8 @@ export class InstanceService { ); if (query.tag !== undefined) values = values.filter(({ value }) => value.tags.includes(query.tag!.trim())); + if (query.groupId !== undefined) + values = values.filter(({ value }) => value.groupId === query.groupId!.trim()); if (query.credentialConfigured !== undefined) values = values.filter( ({ value }) => value.credentialConfigured === query.credentialConfigured, @@ -257,6 +261,8 @@ export class InstanceService { const name = patch.name === undefined ? current.name : normalizeName(patch.name); const origin = patch.origin === undefined ? current.base_url : normalizeOrigin(patch.origin); const tags = patch.tags === undefined ? undefined : normalizeTags(patch.tags); + const groupId = + patch.groupId === undefined ? current.group_id : this.normalizeGroupId(patch.groupId); let committedOldSecret: SecretRow | undefined; let newSecret: { id: string; external: string } | undefined; if (patch.password?.action === 'set') @@ -272,7 +278,7 @@ export class InstanceService { const transactionOldSecret = this.secret(instanceId); const changed = this.db .prepare( - 'UPDATE instances SET name=?,base_url=?,auth_mode=?,config_revision=config_revision+1,updated_at=? WHERE id=? AND config_revision=?', + 'UPDATE instances SET name=?,base_url=?,auth_mode=?,config_revision=config_revision+1,updated_at=?,group_id=? WHERE id=? AND config_revision=?', ) .run( name, @@ -281,6 +287,7 @@ export class InstanceService { ? 'password' : 'none', now, + groupId, instanceId, revision, ); @@ -542,13 +549,23 @@ export class InstanceService { private loadRows(where: string, parameters: unknown[]): AggregateRow[] { return this.db .prepare( - `SELECT i.id,i.name,i.base_url,i.config_revision,i.updated_at, + `SELECT i.id,i.name,i.base_url,i.config_revision,i.updated_at,i.group_id, group_concat(t.tag, char(31)) tags, CASE WHEN EXISTS(SELECT 1 FROM secret_references r WHERE r.instance_id=i.id AND r.purpose=?) THEN 1 ELSE 0 END credential FROM instances i LEFT JOIN instance_tags t ON t.instance_id=i.id ${where} GROUP BY i.id`, ) .all(PURPOSE, ...parameters) as AggregateRow[]; } + + private normalizeGroupId(value: string | null | undefined): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== 'string') validation('groupId must be a string or null'); + const trimmed = value.trim(); + if (!trimmed) return null; + const exists = this.db.prepare('SELECT 1 FROM device_groups WHERE id=?').get(trimmed); + if (!exists) throw new InstanceServiceError('NOT_FOUND', 'Device group was not found'); + return trimmed; + } private toInstance(row: AggregateRow): Instance { const capabilityRows = this.db .prepare('SELECT state FROM capabilities WHERE instance_id=?') @@ -577,6 +594,7 @@ export class InstanceService { name: row.name, origin: row.base_url, tags: row.tags ? row.tags.split(String.fromCharCode(31)).sort(codePointCompare) : [], + groupId: row.group_id ?? null, revision: row.config_revision, capabilityStatus, freshness, diff --git a/apps/api/src/application/jobs/job-query-service.ts b/apps/api/src/application/jobs/job-query-service.ts index b0a7a76..f9b52c4 100644 --- a/apps/api/src/application/jobs/job-query-service.ts +++ b/apps/api/src/application/jobs/job-query-service.ts @@ -23,6 +23,22 @@ export class JobQueryError extends Error { } } +export interface ControlPlaneJobSummary { + readonly total: number; + readonly active: number; + readonly succeeded: number; + readonly failed: number; + readonly attention: number; + readonly recent: readonly ControlPlaneJobSummaryItem[]; +} + +export interface ControlPlaneJobSummaryItem { + readonly id: string; + readonly operationId: string; + readonly status: Job['status']; + readonly createdAt: string; +} + interface JobRow { id: unknown; operation_id: unknown; @@ -121,6 +137,55 @@ function safeError(serialized: unknown): ProblemDetails | undefined { export class JobQueryService { constructor(private readonly db: Database.Database) {} + summary(limit = 5): ControlPlaneJobSummary { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) validation('limit is invalid'); + const totalRow = this.db.prepare('SELECT COUNT(*) AS total FROM jobs').get() as + | { + total: unknown; + } + | undefined; + if (!totalRow || typeof totalRow.total !== 'number' || !Number.isSafeInteger(totalRow.total)) + fail('Persisted job count is invalid'); + const statusRows = this.db + .prepare('SELECT status, COUNT(*) AS count FROM jobs GROUP BY status') + .all() as Array<{ status: unknown; count: unknown }>; + const counts = new Map(); + for (const row of statusRows) { + const status = requiredString(row.status, 'job status'); + if (!(JOB_STATUSES as readonly string[]).includes(status)) + fail('Persisted job status is invalid'); + if (!Number.isSafeInteger(row.count) || (row.count as number) < 0) + fail('Persisted job status count is invalid'); + counts.set(status, row.count as number); + } + const count = (status: Job['status']): number => counts.get(status) ?? 0; + const active = count('queued') + count('running') + count('cancelling'); + const failed = count('failed'); + const attention = failed + count('partially-succeeded') + count('unknown-result') + active; + const recentRows = this.db + .prepare(`SELECT ${JOB_COLUMNS} FROM jobs ORDER BY created_at DESC, id ASC LIMIT ?`) + .all(limit) as JobRow[]; + const recent = recentRows.map((row) => { + const jobStatus = requiredString(row.status, 'status'); + if (!(JOB_STATUSES as readonly string[]).includes(jobStatus)) + fail('Persisted job status is invalid'); + return freeze({ + id: requiredString(row.id, 'id'), + operationId: requiredString(row.operation_id, 'operation_id'), + status: jobStatus as Job['status'], + createdAt: timestamp(row.created_at, 'created_at'), + }); + }); + return freeze({ + total: totalRow.total, + active, + succeeded: count('succeeded'), + failed, + attention, + recent: freeze(recent), + }); + } + get(id: string): Job { if (typeof id !== 'string' || id.length === 0) validation('id must be a non-empty string'); const row = this.db.prepare(`SELECT ${JOB_COLUMNS} FROM jobs WHERE id = ?`).get(id) as diff --git a/apps/api/src/application/jobs/job-reconcile-service.test.ts b/apps/api/src/application/jobs/job-reconcile-service.test.ts new file mode 100644 index 0000000..69f105e --- /dev/null +++ b/apps/api/src/application/jobs/job-reconcile-service.test.ts @@ -0,0 +1,108 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { DEFAULT_RECONCILE_GRACE_MS, JobReconcileService } from './job-reconcile-service.js'; + +const NOW = new Date('2026-07-20T12:00:00.000Z'); +const minutesAgo = (minutes: number): string => + new Date(NOW.getTime() - minutes * 60_000).toISOString(); + +function setup(): { db: Database.Database; subject: JobReconcileService } { + const db = new Database(':memory:'); + migrateDatabase(db); + return { + db, + subject: new JobReconcileService({ db, now: () => NOW }), + }; +} + +function insertJob(db: Database.Database, id: string, status: string, createdAt: string): void { + db.prepare( + `INSERT INTO jobs + (id, root_job_id, operation_id, risk_level, status, requested_by, request_id, + parameters_digest, created_at, updated_at) + VALUES (?, ?, 'op.one', 'R2', ?, 'actor', 'request-1', 'digest', ?, ?)`, + ).run(id, id, status, createdAt, createdAt); + db.prepare( + `INSERT INTO job_items + (id, job_id, instance_id, attempt_number, status, created_at, updated_at) + VALUES (?, ?, 'instance-1', 1, ?, ?, ?)`, + ).run(`${id}-item`, id, status === 'queued' ? 'queued' : 'running', createdAt, createdAt); + if (status !== 'queued') { + db.prepare( + `INSERT INTO job_attempts (id, job_id, status, started_at, created_at) + VALUES (?, ?, 'running', ?, ?)`, + ).run(`${id}-attempt`, id, createdAt, createdAt); + } +} + +const rows = (db: Database.Database, table: string): Array> => + db.prepare(`SELECT * FROM ${table}`).all() as Array>; + +describe('JobReconcileService', () => { + it('closes dispatched jobs that can no longer finish', () => { + const { db, subject } = setup(); + insertJob(db, 'job-running', 'running', minutesAgo(60)); + + expect(subject.summary().pending).toBe(1); + expect(subject.reconcile()).toEqual({ interrupted: 1, pending: 0 }); + + expect(rows(db, 'jobs')[0]).toMatchObject({ + id: 'job-running', + status: 'unknown-result', + finished_at: NOW.toISOString(), + }); + expect(rows(db, 'job_items')[0]).toMatchObject({ + status: 'unknown-result', + result_code: 'INTERRUPTED', + }); + expect(rows(db, 'job_attempts')[0]).toMatchObject({ + status: 'unknown-result', + finished_at: NOW.toISOString(), + }); + }); + + it('cancels queued jobs that never reached a transport', () => { + const { db, subject } = setup(); + insertJob(db, 'job-queued', 'queued', minutesAgo(30)); + + expect(subject.reconcile()).toEqual({ interrupted: 1, pending: 0 }); + expect(rows(db, 'jobs')[0]).toMatchObject({ status: 'cancelled' }); + expect(rows(db, 'job_items')[0]).toMatchObject({ result_code: 'NEVER_DISPATCHED' }); + }); + + it('leaves jobs inside the grace window alone', () => { + const { db, subject } = setup(); + insertJob(db, 'job-fresh', 'running', minutesAgo(2)); + insertJob(db, 'job-old', 'cancelling', minutesAgo(DEFAULT_RECONCILE_GRACE_MS + 1)); + + expect(subject.reconcile()).toEqual({ interrupted: 1, pending: 1 }); + const byId = new Map(rows(db, 'jobs').map((row) => [row.id, row.status])); + expect(byId.get('job-fresh')).toBe('running'); + expect(byId.get('job-old')).toBe('unknown-result'); + }); + + it('reports the oldest pending job so the console can warn about it', () => { + const { db, subject } = setup(); + insertJob(db, 'job-newer', 'running', minutesAgo(20)); + insertJob(db, 'job-older', 'running', minutesAgo(40)); + insertJob(db, 'job-done', 'succeeded', minutesAgo(90)); + + expect(subject.summary()).toEqual({ + pending: 2, + dispatched: 2, + queued: 0, + oldestCreatedAt: minutesAgo(40), + }); + }); + + it('is idempotent and rejects a negative window', () => { + const { db, subject } = setup(); + insertJob(db, 'job-once', 'running', minutesAgo(60)); + + expect(subject.reconcile(0).interrupted).toBe(1); + expect(subject.reconcile(0)).toEqual({ interrupted: 0, pending: 0 }); + expect(() => subject.reconcile(-1)).toThrow(RangeError); + }); +}); diff --git a/apps/api/src/application/jobs/job-reconcile-service.ts b/apps/api/src/application/jobs/job-reconcile-service.ts new file mode 100644 index 0000000..4a9dbac --- /dev/null +++ b/apps/api/src/application/jobs/job-reconcile-service.ts @@ -0,0 +1,178 @@ +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; + +/** + * Reconciles jobs that the console still believes are in flight. + * + * Every job is executed inside the request that created it, so a row left in `running` after a + * crash can never finish on its own. The official Hub exposes the same capability on its command + * ledger; here it closes the loop against our own job table instead of a remote queue. + * + * `queued` rows never reached a transport, so they are cancelled. `running` and `cancelling` rows + * may or may not have reached the device, so they become `unknown-result`, which is the only + * honest terminal state for an interrupted write. + * + * The service also owns operator cancellation, which the published contract promises on + * `POST /api/v1/jobs/{jobId}/cancel`. Both paths share one rule: a job only ever moves from an + * active state to a terminal one, and every write is guarded by the active state, so a cancel + * racing an in-flight executor loses cleanly instead of overwriting a real result. + */ + +const DISPATCHED_STATUSES = new Set(['running', 'cancelling']); +const QUEUED_STATUSES = new Set(['queued']); +const ACTIVE_STATUSES = new Set([...DISPATCHED_STATUSES, ...QUEUED_STATUSES]); +const ACTIVE_PLACEHOLDERS = '?, ?, ?'; + +/** On-demand sweeps leave a margin so a slow batch in another request is never clobbered. */ +export const DEFAULT_RECONCILE_GRACE_MS = 15 * 60 * 1000; + +export interface JobReconcileResult { + /** Jobs closed by this sweep. */ + readonly interrupted: number; + /** Jobs still considered in flight once the sweep finished. */ + readonly pending: number; +} + +export interface JobReconcileSummary { + readonly pending: number; + readonly dispatched: number; + readonly queued: number; + readonly oldestCreatedAt: string | null; +} + +export interface JobReconcileOptions { + readonly db: SqliteDatabase; + readonly now?: () => Date; +} + +export type JobCancelErrorCode = 'NOT_FOUND' | 'NOT_CANCELLABLE'; + +export class JobCancelError extends Error { + constructor( + readonly code: JobCancelErrorCode, + message: string, + ) { + super(message); + this.name = 'JobCancelError'; + } +} + +const CANCEL_RESULT_CODE = 'CANCELLED_BY_OPERATOR'; + +const isActiveStatus = (status: unknown): status is string => + typeof status === 'string' && ACTIVE_STATUSES.has(status); + +export class JobReconcileService { + readonly #db: SqliteDatabase; + readonly #now: () => Date; + + constructor(options: JobReconcileOptions) { + this.#db = options.db; + this.#now = options.now ?? (() => new Date()); + } + + summary(olderThanMs = 0): JobReconcileSummary { + const cutoff = this.#cutoff(olderThanMs); + const rows = this.#db + .prepare( + 'SELECT status, created_at FROM jobs WHERE status IN (?,?,?) AND created_at <= ? ORDER BY created_at ASC, id ASC', + ) + .all('queued', 'running', 'cancelling', cutoff) as Array<{ + status: unknown; + created_at: unknown; + }>; + let dispatched = 0; + let queued = 0; + for (const row of rows) { + if (typeof row.status === 'string' && DISPATCHED_STATUSES.has(row.status)) dispatched += 1; + else if (typeof row.status === 'string' && QUEUED_STATUSES.has(row.status)) queued += 1; + } + const oldest = rows[0]?.created_at; + return { + pending: rows.length, + dispatched, + queued, + oldestCreatedAt: typeof oldest === 'string' ? oldest : null, + }; + } + + reconcile(olderThanMs = DEFAULT_RECONCILE_GRACE_MS): JobReconcileResult { + const cutoff = this.#cutoff(olderThanMs); + const closedAt = this.#now().toISOString(); + const interrupted = this.#db.transaction((): number => { + const rows = this.#db + .prepare( + 'SELECT id, status FROM jobs WHERE status IN (?,?,?) AND created_at <= ? ORDER BY created_at ASC, id ASC', + ) + .all('queued', 'running', 'cancelling', cutoff) as Array<{ + id: unknown; + status: unknown; + }>; + const closeItems = this.#db.prepare( + `UPDATE job_items SET status = ?, result_code = ?, finished_at = ?, updated_at = ? + WHERE job_id = ? AND status IN ('queued','running','cancelling')`, + ); + const closeAttempts = this.#db.prepare( + "UPDATE job_attempts SET status = ?, finished_at = ? WHERE job_id = ? AND status = 'running'", + ); + const closeJob = this.#db.prepare( + 'UPDATE jobs SET status = ?, finished_at = ?, updated_at = ? WHERE id = ? AND status IN (?,?,?)', + ); + let count = 0; + for (const row of rows) { + if (typeof row.id !== 'string' || !isActiveStatus(row.status)) continue; + const dispatched = DISPATCHED_STATUSES.has(row.status); + const finalStatus = dispatched ? 'unknown-result' : 'cancelled'; + const resultCode = dispatched ? 'INTERRUPTED' : 'NEVER_DISPATCHED'; + closeItems.run(finalStatus, resultCode, closedAt, closedAt, row.id); + closeAttempts.run(finalStatus, closedAt, row.id); + closeJob.run(finalStatus, closedAt, closedAt, row.id, 'queued', 'running', 'cancelling'); + count += 1; + } + return count; + })(); + return { interrupted, pending: this.summary().pending }; + } + + #cutoff(olderThanMs: number): string { + if (!Number.isFinite(olderThanMs) || olderThanMs < 0) { + throw new RangeError('olderThanMs must be a non-negative finite number'); + } + return new Date(this.#now().getTime() - olderThanMs).toISOString(); + } + + /** Requests cancellation of a job that has not reached a terminal state yet. */ + cancel(jobId: string): void { + if (typeof jobId !== 'string' || jobId.length === 0 || jobId.length > 128) { + throw new JobCancelError('NOT_FOUND', 'Job was not found'); + } + const closedAt = this.#now().toISOString(); + const changed = this.#db.transaction((): boolean => { + const row = this.#db.prepare('SELECT status FROM jobs WHERE id = ?').get(jobId) as + | { status: unknown } + | undefined; + if (!row) throw new JobCancelError('NOT_FOUND', 'Job was not found'); + if (!isActiveStatus(row.status)) + throw new JobCancelError('NOT_CANCELLABLE', 'Job already reached a terminal state'); + this.#db + .prepare( + `UPDATE job_items SET status = 'cancelled', result_code = ?, finished_at = ?, updated_at = ? + WHERE job_id = ? AND status IN ('queued', 'running', 'cancelling')`, + ) + .run(CANCEL_RESULT_CODE, closedAt, closedAt, jobId); + this.#db + .prepare( + "UPDATE job_attempts SET status = 'cancelled', finished_at = ? WHERE job_id = ? AND status = 'running'", + ) + .run(closedAt, jobId); + const updated = this.#db + .prepare( + `UPDATE jobs SET status = 'cancelled', finished_at = ?, updated_at = ? + WHERE id = ? AND status IN (${ACTIVE_PLACEHOLDERS})`, + ) + .run(closedAt, closedAt, jobId, 'queued', 'running', 'cancelling'); + return updated.changes === 1; + })(); + if (!changed) + throw new JobCancelError('NOT_CANCELLABLE', 'Job already reached a terminal state'); + } +} diff --git a/apps/api/src/application/legacy-import/activate-pending-secret.test.ts b/apps/api/src/application/legacy-import/activate-pending-secret.test.ts index 79832d5..edb4adc 100644 --- a/apps/api/src/application/legacy-import/activate-pending-secret.test.ts +++ b/apps/api/src/application/legacy-import/activate-pending-secret.test.ts @@ -20,16 +20,9 @@ function pendingDatabase(): Database.Database { db.pragma('foreign_keys = ON'); migrateDatabase(db); const now = '2026-07-16T00:00:00.000Z'; - db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run( - 'alpha', - 'Alpha', - 'https://203.0.113.8', - 'none', - 1, - 3, - now, - now, - ); + db.prepare( + 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', + ).run('alpha', 'Alpha', 'https://203.0.113.8', 'none', 1, 3, now, now); db.prepare('INSERT INTO app_settings VALUES (?,?,?,?)').run( 'legacy-import.instance.alpha', JSON.stringify({ @@ -233,7 +226,9 @@ describe('activatePendingSecret', () => { migrateDatabase(first); const now = '2026-07-16T00:00:00.000Z'; first - .prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)') + .prepare( + 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', + ) .run('alpha', 'Alpha', 'https://203.0.113.8', 'none', 1, 3, now, now); first .prepare('INSERT INTO app_settings VALUES (?,?,?,?)') diff --git a/apps/api/src/application/legacy-import/legacy-import.test.ts b/apps/api/src/application/legacy-import/legacy-import.test.ts index 8cf3378..e9d15d4 100644 --- a/apps/api/src/application/legacy-import/legacy-import.test.ts +++ b/apps/api/src/application/legacy-import/legacy-import.test.ts @@ -274,26 +274,12 @@ describe('legacy import', () => { const path = await legacyFile(valid); const db = database(); const now = new Date().toISOString(); - db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run( - 'alpha', - 'Different', - 'https://203.0.113.99', - 'none', - 1, - 7, - now, - now, - ); - db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run( - 'owner', - 'Owner', - 'http://[2001:4860:4860::8888]:8080', - 'none', - 1, - 1, - now, - now, - ); + db.prepare( + 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', + ).run('alpha', 'Different', 'https://203.0.113.99', 'none', 1, 7, now, now); + db.prepare( + 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', + ).run('owner', 'Owner', 'http://[2001:4860:4860::8888]:8080', 'none', 1, 1, now, now); const preview = await previewLegacyImport(path, db); expect(preview.instances.map((item) => item.status)).toEqual(['conflict', 'conflict']); expect(preview.counts.conflict).toBe(2); diff --git a/apps/api/src/application/messages/hub-message-service.test.ts b/apps/api/src/application/messages/hub-message-service.test.ts new file mode 100644 index 0000000..2ae6acb --- /dev/null +++ b/apps/api/src/application/messages/hub-message-service.test.ts @@ -0,0 +1,258 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { HubMessageService } from './hub-message-service.js'; +import type { InstanceMessage } from './instance-message-service.js'; + +const now = new Date('2026-09-03T10:00:00.000Z'); + +const instanceMessages = new Map([ + [ + 'device-1', + [ + { + id: '101', + direction: 'incoming', + phoneNumber: '10086', + content: '余额提醒', + timestamp: '2026-09-03T09:59:00.000Z', + status: 'received', + transport: 'modem', + }, + ], + ], +]); + +const messageService = { + list: async (instanceId: string, query: { limit: number; offset: number }) => ({ + messages: + query.offset >= (instanceMessages.get(instanceId)?.length ?? 0) + ? [] + : (instanceMessages.get(instanceId) ?? []).slice(query.offset, query.offset + query.limit), + }), + send: async () => { + const current = [...(instanceMessages.get('device-1') ?? [])]; + const message: InstanceMessage = { + id: '102', + direction: 'outgoing', + phoneNumber: '10086', + content: '已发送', + timestamp: now.toISOString(), + status: 'sent', + transport: 'modem', + }; + instanceMessages.set('device-1', [message, ...current]); + return { sent: true as const }; + }, + deleteMany: async (items: readonly { instanceId: string; id: string }[]) => ({ + requested: items.length, + deleted: items.length, + failed: 0, + failures: [], + }), +}; + +const initialInstanceMessages = new Map(instanceMessages); + +describe('HubMessageService', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + db.prepare( + `INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) + VALUES ('device-1','Modem A','http://device-1.invalid','password',1,1,?,?)`, + ).run(now.toISOString(), now.toISOString()); + }); + + afterEach(() => { + instanceMessages.clear(); + for (const [instanceId, messages] of initialInstanceMessages) { + instanceMessages.set(instanceId, [...messages]); + } + db.close(); + }); + + function service() { + return new HubMessageService(db, { + instances: { + list: async () => ({ + items: [ + { + id: 'device-1', + name: 'Modem A', + origin: 'http://device-1.invalid', + tags: [], + revision: 1, + capabilityStatus: 'unknown', + freshness: 'unknown', + credentialConfigured: true, + }, + ], + page: { page: 1, pageSize: 100, total: 1 }, + }), + }, + messages: messageService, + now: () => now, + }); + } + + it('persists a full SMS sync and serves subsequent reads from SQLite', async () => { + const hub = service(); + const first = await hub.syncAll(); + + expect(first).toMatchObject({ scanned: 1, synced: 1, available: 1 }); + instanceMessages.set('device-1', []); + + const page = await hub.list({ limit: 10, offset: 0 }); + expect(page.total).toBe(1); + expect(page.items[0]).toMatchObject({ + instanceId: 'device-1', + instanceName: 'Modem A', + phoneNumber: '10086', + content: '余额提醒', + syncedAt: now.toISOString(), + }); + }); + + it('records outbound SMS in the central message store after upstream delivery', async () => { + const hub = service(); + await hub.syncAll(); + + await hub.send('device-1', { phoneNumber: '10086', content: '已发送' }); + const page = await hub.list({ limit: 10, offset: 0 }); + expect(page).toMatchObject({ total: 2 }); + }); + + it('deletes the central copy without requiring another upstream read', async () => { + const hub = service(); + await hub.syncAll(); + + const result = await hub.deleteMany([{ instanceId: 'device-1', id: '101' }]); + expect(result).toMatchObject({ requested: 1, deleted: 1, failed: 0 }); + expect(await hub.list({ limit: 10, offset: 0 })).toMatchObject({ total: 0 }); + }); + + describe('conversations', () => { + const at = (secondsAgo: number): string => + new Date(now.getTime() - secondsAgo * 1_000).toISOString(); + + function message( + id: string, + direction: InstanceMessage['direction'], + phoneNumber: string, + content: string, + timestamp: string, + ): InstanceMessage { + return { + id, + direction, + phoneNumber, + content, + timestamp, + status: direction, + transport: 'modem', + }; + } + + function seed(hub: HubMessageService): void { + hub.upsert('device-1', message('a', 'incoming', '10086', '余额提醒', at(300)), now); + hub.upsert('device-1', message('b', 'outgoing', '10086', '已查询', at(200)), now); + hub.upsert('device-1', message('c', 'incoming', '13900139000', '验证码 1234', at(100)), now); + hub.upsert('device-1', message('d', 'incoming', '10086', '流量提醒', at(50)), now); + } + + it('groups the archive per node and phone with counts and a newest preview', () => { + const hub = service(); + seed(hub); + + const page = hub.conversations({ limit: 10 }); + expect(page.totalCount).toBe(2); + expect(page.stats).toEqual({ incoming: 3, outgoing: 1, total: 4 }); + expect(page.items.map((item) => item.phoneNumber)).toEqual(['10086', '13900139000']); + expect(page.items[0]).toMatchObject({ + instanceId: 'device-1', + instanceName: 'Modem A', + messageCount: 3, + incomingCount: 2, + lastMessage: { content: '流量提醒', timestamp: at(50) }, + }); + expect(page.items[1]).toMatchObject({ + messageCount: 1, + incomingCount: 1, + lastMessage: { content: '验证码 1234' }, + }); + }); + + it('filters conversations by node, phone and search text', () => { + const hub = service(); + seed(hub); + + expect(hub.conversations({ instanceId: 'device-2' }).totalCount).toBe(0); + expect(hub.conversations({ phoneNumber: '10086' }).items).toHaveLength(1); + const searched = hub.conversations({ search: '验证码' }); + expect(searched.items.map((item) => item.phoneNumber)).toEqual(['13900139000']); + expect(searched.totalCount).toBe(1); + }); + + it('keeps a thread when only one message matches the direction filter', () => { + const hub = service(); + seed(hub); + + const outgoing = hub.conversations({ direction: 'outgoing' }); + expect(outgoing.totalCount).toBe(1); + expect(outgoing.items[0]).toMatchObject({ + phoneNumber: '10086', + // The thread is still reported at its full size even though only one row matched. + messageCount: 3, + incomingCount: 2, + lastMessage: { direction: 'outgoing', content: '已查询' }, + }); + + const incoming = hub.conversations({ direction: 'incoming' }); + expect(incoming.totalCount).toBe(2); + expect(incoming.items.map((item) => item.phoneNumber)).toEqual(['10086', '13900139000']); + expect(incoming.items[0]?.lastMessage).toMatchObject({ + direction: 'incoming', + content: '流量提醒', + }); + expect(incoming.items[1]?.lastMessage).toMatchObject({ + direction: 'incoming', + content: '验证码 1234', + }); + }); + + it('reports direction totals for the whole filtered archive, not just the page', () => { + const hub = service(); + seed(hub); + + const page = hub.conversations({ limit: 1 }); + expect(page.items).toHaveLength(1); + expect(page.stats).toEqual({ incoming: 3, outgoing: 1, total: 4 }); + expect(hub.conversations({ instanceId: 'device-1', search: '10086' }).stats).toEqual({ + incoming: 2, + outgoing: 1, + total: 3, + }); + expect(hub.conversations({ instanceId: 'device-2' }).stats).toEqual({ + incoming: 0, + outgoing: 0, + total: 0, + }); + }); + + it('pages conversations by newest activity', () => { + const hub = service(); + seed(hub); + + const first = hub.conversations({ limit: 1, offset: 0 }); + const second = hub.conversations({ limit: 1, offset: 1 }); + expect(first.totalCount).toBe(2); + expect(first.items[0]?.phoneNumber).toBe('10086'); + expect(second.items[0]?.phoneNumber).toBe('13900139000'); + }); + }); +}); diff --git a/apps/api/src/application/messages/hub-message-service.ts b/apps/api/src/application/messages/hub-message-service.ts new file mode 100644 index 0000000..090e19e --- /dev/null +++ b/apps/api/src/application/messages/hub-message-service.ts @@ -0,0 +1,505 @@ +import { randomUUID } from 'node:crypto'; + +import type Database from 'better-sqlite3'; + +import type { InstancePageQuery } from '@multi-simadmin/contracts'; + +import type { + DeleteSmsMessageRequest, + InstanceMessage, + InstanceMessageService, + MessageDeleteResult, +} from './instance-message-service.js'; + +export interface HubMessagePage { + readonly items: readonly HubMessage[]; + readonly total: number; +} + +/** SQL literal sets; upstream labels the same direction two different ways. */ +const INCOMING_DIRECTIONS = "'incoming','received'"; +const OUTGOING_DIRECTIONS = "'outgoing','sent'"; + +export interface HubMessage { + 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; + readonly syncedAt: string; +} + +export interface HubMessageDevice { + readonly id: string; + readonly name: string; + readonly availability: 'online' | 'unavailable'; +} + +export interface HubMessageSnapshot { + readonly messages: readonly HubMessage[]; + readonly devices: readonly HubMessageDevice[]; + readonly total: number; +} + +/** + * One phone-thread per node, mirroring the Hub conversation model: the archive is + * grouped by (instance, phone number) and carries the newest message as a preview. + */ +export interface HubMessageConversation { + readonly instanceId: string; + readonly instanceName: string; + readonly phoneNumber: string; + readonly messageCount: number; + readonly incomingCount: number; + readonly lastMessage: HubMessage; +} + +/** Direction totals for the whole filtered archive, mirroring the Hub SMS counters. */ +export interface HubMessageDirectionStats { + readonly incoming: number; + readonly outgoing: number; + readonly total: number; +} + +export interface HubMessageConversationPage { + readonly items: readonly HubMessageConversation[]; + readonly totalCount: number; + readonly stats: HubMessageDirectionStats; +} + +export interface HubSyncSummary { + readonly scanned: number; + readonly available: number; + readonly synced: number; + readonly unavailable: number; +} + +interface HubMessageServiceOptions { + readonly instances: { + readonly list: (query?: InstancePageQuery) => Promise<{ + readonly items: readonly { readonly id: string; readonly name: string }[]; + }>; + }; + readonly messages: Pick; + readonly onIncomingMessage?: (instanceId: string, message: InstanceMessage) => Promise; + readonly now?: () => Date; + readonly id?: () => string; + readonly maximumMessagesPerDevice?: number; + readonly refreshTtlMs?: number; +} + +const PAGE_SIZE = 100; +const DEFAULT_MAXIMUM = 5_000; +const MAX_DEVICES = 5_000; +const DEFAULT_REFRESH_TTL_MS = 5_000; + +const isoTimestamp = (value: string, fallback: Date): string => { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toISOString() : fallback.toISOString(); +}; + +export class HubMessageService { + readonly #db: Database.Database; + readonly #options: HubMessageServiceOptions; + readonly #availability = new Map(); + #lastRefreshAt = Number.NEGATIVE_INFINITY; + #inFlight: Promise | undefined; + #lastSummary: HubSyncSummary = { scanned: 0, available: 0, synced: 0, unavailable: 0 }; + + constructor(db: Database.Database, options: HubMessageServiceOptions) { + this.#db = db; + this.#options = options; + } + + async #allInstances(): Promise { + const items: { readonly id: string; readonly name: string }[] = []; + let page = 1; + while (items.length < MAX_DEVICES) { + const current = await this.#options.instances.list({ page, pageSize: PAGE_SIZE }); + items.push(...current.items.slice(0, MAX_DEVICES - items.length)); + if (current.items.length < PAGE_SIZE) break; + page += 1; + } + return items; + } + + async syncAll(): Promise { + const instances = await this.#allInstances(); + let available = 0; + let synced = 0; + for (const instance of instances) { + try { + synced += await this.syncDevice(instance.id); + available += 1; + } catch { + // Preserve the central archive when a device is temporarily unavailable. + } + } + return { + scanned: instances.length, + available, + synced, + unavailable: instances.length - available, + }; + } + + async syncDevice(instanceId: string): Promise { + try { + const stored = await this.#pullDevice(instanceId); + this.#availability.set(instanceId, true); + return stored; + } catch (error) { + this.#availability.set(instanceId, false); + throw error; + } + } + + async #pullDevice(instanceId: string): Promise { + const observedAt = this.#nowDate(); + let stored = 0; + for (let offset = 0; offset < this.#maximumPerDevice(); offset += PAGE_SIZE) { + const result = await this.#options.messages.list(instanceId, { + limit: PAGE_SIZE, + offset, + }); + for (const message of result.messages) { + const inserted = this.upsert(instanceId, message, observedAt); + stored += inserted; + if ( + inserted === 1 && + (message.direction === 'incoming' || message.direction === 'received') + ) + await this.#options.onIncomingMessage?.(instanceId, message); + } + if (result.messages.length < PAGE_SIZE) break; + } + return stored; + } + + upsert(instanceId: string, message: InstanceMessage, observedAt: Date = this.#nowDate()): number { + const exists = this.#db + .prepare('SELECT 1 FROM sms_messages WHERE instance_id=? AND upstream_id=?') + .get(instanceId, message.id); + const result = this.#db + .prepare( + `INSERT INTO sms_messages + (id,instance_id,upstream_id,direction,phone_number,content,timestamp,status,transport,synced_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(instance_id, upstream_id) DO UPDATE SET + direction=excluded.direction, + phone_number=excluded.phone_number, + content=excluded.content, + timestamp=excluded.timestamp, + status=excluded.status, + transport=excluded.transport, + synced_at=excluded.synced_at`, + ) + .run( + this.#id(), + instanceId, + message.id, + message.direction, + message.phoneNumber, + message.content, + isoTimestamp(message.timestamp, observedAt), + message.status, + message.transport, + observedAt.toISOString(), + ); + return exists ? 0 : result.changes; + } + + async list( + query: { + readonly limit?: number; + readonly offset?: number; + readonly search?: string; + readonly instanceId?: string; + readonly phoneNumber?: string; + } = {}, + ): Promise { + const limit = Math.min(Math.max(query.limit ?? 24, 1), 100); + const offset = Math.max(query.offset ?? 0, 0); + const filter = this.#filter(query); + const where = filter.where; + const parameters = [...filter.parameters]; + const total = Number( + ( + this.#db + .prepare(`SELECT COUNT(*) AS count FROM sms_messages${where}`) + .get(...parameters) as { count?: number } | undefined + )?.count ?? 0, + ); + const rows = this.#db + .prepare( + `SELECT s.*, COALESCE(i.name, '已移除节点') AS instance_name + FROM sms_messages s LEFT JOIN instances i ON i.id = s.instance_id${where} + ORDER BY s.timestamp DESC, s.id DESC LIMIT ? OFFSET ?`, + ) + .all(...parameters, limit, offset) as Array>; + return { + items: Object.freeze(rows.map((row) => this.#message(row))), + total, + }; + } + + /** + * Groups the central archive into phone threads. The newest message of each group + * is resolved by the same `timestamp, id` ordering used by `list`, so the preview + * always matches the first row a reader sees when opening the conversation. + */ + conversations( + query: { + readonly limit?: number; + readonly offset?: number; + readonly search?: string; + readonly instanceId?: string; + readonly phoneNumber?: string; + readonly direction?: 'incoming' | 'outgoing'; + } = {}, + ): HubMessageConversationPage { + const limit = Math.min(Math.max(query.limit ?? 50, 1), 200); + const offset = Math.max(query.offset ?? 0, 0); + const filter = this.#filter(query); + const scoped = `SELECT id, instance_id, direction, phone_number, content, timestamp, + status, transport, synced_at FROM sms_messages${filter.where}`; + // A thread only survives a direction filter when it holds at least one matching message, + // and the preview resolves inside that subset, while `message_count` stays the full size. + const matched = + query.direction === 'incoming' + ? `direction IN (${INCOMING_DIRECTIONS})` + : query.direction === 'outgoing' + ? `direction IN (${OUTGOING_DIRECTIONS})` + : '1'; + const counters = this.#db + .prepare( + `SELECT COUNT(*) AS total, + SUM(CASE WHEN direction IN (${INCOMING_DIRECTIONS}) THEN 1 ELSE 0 END) AS incoming, + SUM(CASE WHEN direction IN (${OUTGOING_DIRECTIONS}) THEN 1 ELSE 0 END) AS outgoing + FROM (${scoped})`, + ) + .get(...filter.parameters) as Record | undefined; + const stats = Object.freeze({ + incoming: Number(counters?.incoming ?? 0), + outgoing: Number(counters?.outgoing ?? 0), + total: Number(counters?.total ?? 0), + }); + // One row per surviving thread, so the outer COUNT is the thread total. + const groupedSql = `SELECT instance_id FROM ( + SELECT *, CASE WHEN ${matched} THEN 1 ELSE 0 END AS matched FROM (${scoped}) + ) GROUP BY instance_id, phone_number HAVING SUM(matched) > 0`; + const totalCount = Number( + ( + this.#db + .prepare(`SELECT COUNT(*) AS count FROM (${groupedSql})`) + .get(...filter.parameters) as { count?: number } | undefined + )?.count ?? 0, + ); + const rows = this.#db + .prepare( + `WITH flagged AS ( + SELECT *, CASE WHEN ${matched} THEN 1 ELSE 0 END AS matched + FROM (${scoped}) + ), + grouped AS ( + SELECT instance_id, phone_number, + COUNT(*) AS message_count, + SUM(CASE WHEN direction IN (${INCOMING_DIRECTIONS}) THEN 1 ELSE 0 END) AS incoming_count, + MAX(CASE WHEN matched = 1 THEN timestamp || '#' || id END) AS latest_key + FROM flagged GROUP BY instance_id, phone_number + HAVING SUM(matched) > 0 + ) + SELECT s.id, s.direction, s.phone_number, s.content, s.timestamp, s.status, + s.transport, s.synced_at, g.instance_id, g.message_count, g.incoming_count, + COALESCE(i.name, '已移除节点') AS instance_name + FROM grouped g + JOIN flagged s + ON s.instance_id = g.instance_id AND s.phone_number = g.phone_number + AND (s.timestamp || '#' || s.id) = g.latest_key + LEFT JOIN instances i ON i.id = g.instance_id + ORDER BY g.latest_key DESC LIMIT ? OFFSET ?`, + ) + .all(...filter.parameters, limit, offset) as Array>; + return { + items: Object.freeze( + rows.flatMap((row) => { + const messageCount = Number(row.message_count ?? 0); + if (!Number.isSafeInteger(messageCount) || messageCount < 1) return []; + return [ + Object.freeze({ + instanceId: String(row.instance_id), + instanceName: String(row.instance_name), + phoneNumber: String(row.phone_number), + messageCount, + incomingCount: Number(row.incoming_count ?? 0), + lastMessage: this.#message(row), + }), + ]; + }), + ), + totalCount, + stats, + }; + } + + #filter( + query: Readonly<{ search?: string; instanceId?: string; phoneNumber?: string }>, + ): Readonly<{ where: string; parameters: readonly string[] }> { + const conditions: string[] = []; + const parameters: string[] = []; + if (query.instanceId) { + conditions.push('instance_id = ?'); + parameters.push(query.instanceId); + } + if (query.phoneNumber) { + conditions.push('phone_number = ?'); + parameters.push(query.phoneNumber); + } + const search = query.search?.trim(); + if (search) { + conditions.push( + `(content LIKE ? ESCAPE '\\' OR phone_number LIKE ? ESCAPE '\\' OR instance_id IN ( + SELECT id || '|' || name FROM instances WHERE name LIKE ? ESCAPE '\\' + ))`, + ); + const pattern = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; + parameters.push(pattern, pattern, pattern); + } + return { + where: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', + parameters, + }; + } + + async snapshot( + query: { + readonly limit?: number; + readonly offset?: number; + readonly search?: string; + readonly instanceId?: string; + readonly phoneNumber?: string; + } = {}, + ): Promise { + await this.refresh(); + const [page, instances] = await Promise.all([this.list(query), this.#allInstances()]); + return { + messages: page.items, + devices: instances.map((instance) => ({ + id: instance.id, + name: instance.name, + availability: this.#availability.get(instance.id) ? 'online' : 'unavailable', + })), + total: page.total, + }; + } + + /** + * Pulls every node into the central archive, but only when the last pull is older + * than the refresh window; concurrent readers share a single in-flight sync. + */ + async refresh(force = false): Promise { + const ttl = Math.max(0, this.#options.refreshTtlMs ?? DEFAULT_REFRESH_TTL_MS); + const age = this.#nowDate().getTime() - this.#lastRefreshAt; + if (!force && this.#lastRefreshAt > Number.NEGATIVE_INFINITY && age < ttl) { + return this.#lastSummary; + } + if (!this.#inFlight) { + this.#inFlight = this.syncAll().then((summary) => { + this.#lastSummary = summary; + this.#lastRefreshAt = this.#nowDate().getTime(); + return summary; + }); + void this.#inFlight.catch(() => undefined); + } + try { + return await this.#inFlight; + } finally { + this.#inFlight = undefined; + } + } + + async send( + instanceId: string, + input: { readonly phoneNumber: string; readonly content: string }, + ): Promise<{ readonly sent: true }> { + const result = await this.#options.messages.send(instanceId, input); + try { + await this.syncDevice(instanceId); + } catch { + // Upstream accepted the send; the next sync can repair the central copy. + } + return result; + } + + async deleteMany(input: readonly DeleteSmsMessageRequest[]): Promise { + const lookup = this.#db.prepare( + 'SELECT id,upstream_id FROM sms_messages WHERE instance_id=? AND (id=? OR upstream_id=?)', + ); + const resolved = input.map((item) => { + const row = lookup.get(item.instanceId, item.id, item.id) as + | { id: string; upstream_id: string } + | undefined; + return { + instanceId: item.instanceId, + upstreamId: row?.upstream_id ?? item.id, + centralId: row?.id ?? item.id, + }; + }); + const result = await this.#options.messages.deleteMany( + resolved.map(({ instanceId, upstreamId }) => ({ instanceId, id: upstreamId })), + ); + const byUpstream = new Map( + resolved.map((item) => [`${item.instanceId}|${item.upstreamId}`, item]), + ); + const failed = new Set(result.failures.map((failure) => `${failure.instanceId}|${failure.id}`)); + const remove = this.#db.prepare( + 'DELETE FROM sms_messages WHERE instance_id=? AND (id=? OR upstream_id=?)', + ); + this.#db.transaction(() => { + for (const item of resolved) { + if (failed.has(`${item.instanceId}|${item.upstreamId}`)) continue; + remove.run(item.instanceId, item.centralId, item.upstreamId); + } + })(); + return Object.freeze({ + ...result, + failures: Object.freeze( + result.failures.map((failure) => { + const item = byUpstream.get(`${failure.instanceId}|${failure.id}`); + return Object.freeze({ ...failure, id: item?.centralId ?? failure.id }); + }), + ), + }); + } + + #message(row: Record): HubMessage { + return Object.freeze({ + id: String(row.id), + instanceId: String(row.instance_id), + instanceName: String(row.instance_name), + direction: String(row.direction), + phoneNumber: String(row.phone_number), + content: String(row.content), + timestamp: String(row.timestamp), + status: String(row.status), + transport: String(row.transport), + syncedAt: String(row.synced_at), + }); + } + + #maximumPerDevice(): number { + return Math.max(PAGE_SIZE, this.#options.maximumMessagesPerDevice ?? DEFAULT_MAXIMUM); + } + + #nowDate(): Date { + return this.#options.now?.() ?? new Date(); + } + + #id(): string { + return this.#options.id?.() ?? randomUUID(); + } +} diff --git a/apps/api/src/application/messages/instance-message-service.test.ts b/apps/api/src/application/messages/instance-message-service.test.ts index b24fe50..336bb6b 100644 --- a/apps/api/src/application/messages/instance-message-service.test.ts +++ b/apps/api/src/application/messages/instance-message-service.test.ts @@ -5,6 +5,7 @@ import { MessageServiceError, parseMessageList, validMessageContent, + validMessageId, } from './instance-message-service.js'; const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' }; @@ -140,6 +141,88 @@ describe('InstanceMessageService', () => { }); }); + it('deletes a bounded batch with explicit IDs and reports per-instance failures', async () => { + expect(validMessageId('1')).toBe(true); + expect(validMessageId('9007199254740991')).toBe(true); + expect(validMessageId('9007199254740992')).toBe(false); + const request = vi.fn(async (value: unknown) => { + const body = value as { readonly smsBatchDelete?: { readonly ids: readonly number[] } }; + const deleted = body.smsBatchDelete?.ids.length ?? 0; + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { deleted }, + }), + }; + }); + const service = new InstanceMessageService({ + instances: instances as never, + sessions: new InstanceSessionStore(), + request, + }); + + const result = await service.deleteMany([ + { instanceId: 'alpha', id: '1' }, + { instanceId: 'alpha', id: '2' }, + { instanceId: 'missing', id: '3' }, + ]); + + expect(result).toEqual({ + requested: 3, + deleted: 2, + failed: 1, + failures: [ + { + instanceId: 'missing', + instanceName: '', + id: '3', + code: 'NOT_FOUND', + }, + ], + }); + expect(request).toHaveBeenCalledWith({ + url: 'http://192.168.1.10:8080/api/sms/batch-delete', + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + smsBatchDelete: { ids: [1, 2] }, + }); + }); + + it('fails closed for invalid batch payloads and malformed upstream delete replies', async () => { + const request = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{"status":"success","data":{"deleted":2}}', + })); + const service = new InstanceMessageService({ + instances: instances as never, + sessions: new InstanceSessionStore(), + request, + }); + await expect(service.deleteMany([])).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + await expect( + service.deleteMany([{ instanceId: 'alpha', id: 'not-a-number' }]), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + request.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: '{"status":"success","data":{"deleted":-1}}', + }); + await expect(service.deleteMany([{ instanceId: 'alpha', id: '1' }])).resolves.toMatchObject({ + requested: 1, + deleted: 0, + failed: 1, + failures: [{ code: 'UPSTREAM_FAILED' }], + }); + }); + it('fails closed for missing owners, stale sessions and upstream status:error', async () => { const sessions = new InstanceSessionStore(); sessions.set('alpha', 'http://192.168.1.99', 'simadmin_session=opaque'); diff --git a/apps/api/src/application/messages/instance-message-service.ts b/apps/api/src/application/messages/instance-message-service.ts index e3efc2d..73e5121 100644 --- a/apps/api/src/application/messages/instance-message-service.ts +++ b/apps/api/src/application/messages/instance-message-service.ts @@ -24,6 +24,22 @@ export interface SendMessageInput { readonly phoneNumber: string; readonly content: string; } +export interface DeleteSmsMessageRequest { + readonly instanceId: string; + readonly id: string; +} +export interface MessageDeleteFailure { + readonly instanceId: string; + readonly instanceName: string; + readonly id: string; + readonly code: MessageServiceErrorCode; +} +export interface MessageDeleteResult { + readonly requested: number; + readonly deleted: number; + readonly failed: number; + readonly failures: readonly MessageDeleteFailure[]; +} export type MessageServiceErrorCode = | 'NOT_FOUND' | 'VALIDATION_FAILED' @@ -37,6 +53,7 @@ export class MessageServiceError extends Error { } const MAX_RESPONSE_BYTES = 262_144; +const MAX_BATCH_DELETE = 500; const DIRECTIONS = new Set(['received', 'sent', 'incoming', 'outgoing']); const record = (value: unknown): Record | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) @@ -48,6 +65,10 @@ const bounded = (value: unknown, maximum: number): string | undefined => !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value)) ? String(value) : undefined; +const safeCount = (value: unknown, maximum: number): number | undefined => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum + ? value + : undefined; export function validPhoneNumber(value: string): boolean { return value.length >= 3 && value.length <= 32 && /^\+?[0-9][0-9 ()-]*$/u.test(value); @@ -61,6 +82,12 @@ export function validMessageContent(value: string): boolean { ); } +export function validMessageId(value: string): boolean { + if (!/^\d{1,18}$/u.test(value)) return false; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0; +} + export function parseMessageList( response: UpstreamResponse, limit: number, @@ -128,6 +155,26 @@ function parseSendSuccess(response: UpstreamResponse): void { } } +function parseBatchDeleteSuccess(response: UpstreamResponse, expected: number): number { + if ( + response.status < 200 || + response.status >= 300 || + Buffer.byteLength(response.body, 'utf8') > 32_768 + ) + throw new MessageServiceError('UPSTREAM_FAILED'); + let root: Record | undefined; + try { + root = record(JSON.parse(response.body)); + } catch { + throw new MessageServiceError('UPSTREAM_FAILED'); + } + if (root?.status !== 'success' && root?.status !== 'ok') + throw new MessageServiceError('UPSTREAM_FAILED'); + const deleted = safeCount(record(root.data)?.deleted, expected); + if (deleted === undefined) throw new MessageServiceError('UPSTREAM_FAILED'); + return deleted; +} + export class InstanceMessageService { constructor( private readonly options: { @@ -213,4 +260,78 @@ export class InstanceMessageService { parseSendSuccess(response); return { sent: true }; } + + async deleteMany(input: readonly DeleteSmsMessageRequest[]): Promise { + if (!Array.isArray(input) || input.length < 1 || input.length > MAX_BATCH_DELETE) + throw new MessageServiceError('VALIDATION_FAILED'); + const grouped = new Map>(); + const requested = input.length; + for (const item of input) { + const instanceId = typeof item?.instanceId === 'string' ? item.instanceId : ''; + const id = typeof item?.id === 'string' ? item.id : ''; + if (!instanceId || !validMessageId(id)) throw new MessageServiceError('VALIDATION_FAILED'); + const numericId = Number(id); + const current = grouped.get(instanceId); + if (current === undefined) grouped.set(instanceId, [{ id, numericId }]); + else current.push({ id, numericId }); + } + + let deleted = 0; + const failures: MessageDeleteFailure[] = []; + for (const [instanceId, entries] of grouped) { + const ids = entries.map((entry) => entry.numericId); + let instanceName = ''; + try { + const instance = await this.options.instances.get(instanceId); + if (!instance) throw new MessageServiceError('NOT_FOUND'); + instanceName = instance.name; + const session = this.options.sessions.sessionFor(instanceId); + if (session && session.origin !== instance.origin) + throw new MessageServiceError('SESSION_INVALID'); + if (!session && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin); + } catch { + // Passwordless instances may still accept anonymous batch deletion. + } + } + const request = (cookie?: string) => + this.options.request({ + url: `${instance.origin}/api/sms/batch-delete`, + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + smsBatchDelete: { ids }, + }); + let response = await request(this.options.sessions.sessionFor(instanceId)?.cookie); + if ([401, 403].includes(response.status) && this.options.ensureSession) { + await this.options.ensureSession(instanceId, instance.origin, true); + response = await request(this.options.sessions.sessionFor(instanceId)?.cookie); + } + deleted += parseBatchDeleteSuccess(response, entries.length); + } catch (error) { + const code = + error instanceof MessageServiceError ? error.code : ('UPSTREAM_FAILED' as const); + for (const entry of entries) { + failures.push( + Object.freeze({ + instanceId, + instanceName, + id: entry.id, + code, + }), + ); + } + } + } + return { + requested, + deleted, + failed: failures.length, + failures: Object.freeze(failures), + }; + } } diff --git a/apps/api/src/application/messages/sms-outbox-service.test.ts b/apps/api/src/application/messages/sms-outbox-service.test.ts new file mode 100644 index 0000000..659c6ce --- /dev/null +++ b/apps/api/src/application/messages/sms-outbox-service.test.ts @@ -0,0 +1,190 @@ +import Database from 'better-sqlite3'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { MessageServiceError } from './instance-message-service.js'; +import { SmsOutboxService } from './sms-outbox-service.js'; + +const START = new Date('2026-09-05T02:00:00.000Z'); + +interface Clock { + current: Date; + advance: (ms: number) => void; +} + +function clock(): Clock { + const state: Clock = { + current: new Date(START), + advance: (ms: number) => { + state.current = new Date(state.current.getTime() + ms); + }, + }; + return state; +} + +describe('SmsOutboxService', () => { + let db: Database.Database; + let time: Clock; + let offline: Set; + let delivered: Array<{ readonly instanceId: string; readonly content: string }>; + let sendError: (() => Error) | undefined; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + for (const [id, name] of [ + ['device-1', 'Modem A'], + ['device-2', 'Modem B'], + ]) { + db.prepare( + `INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) + VALUES (?,?,?,'password',1,1,?,?)`, + ).run(id, name, `http://${id}.invalid`, START.toISOString(), START.toISOString()); + } + time = clock(); + offline = new Set(); + delivered = []; + sendError = undefined; + }); + + afterEach(() => { + db.close(); + }); + + function service(overrides: { readonly maxAttempts?: number } = {}): SmsOutboxService { + let sequence = 0; + return new SmsOutboxService(db, { + send: async (instanceId, input) => { + if (sendError) throw sendError(); + delivered.push({ instanceId, content: input.content }); + return { sent: true }; + }, + offlineInstances: () => offline, + now: () => time.current, + id: () => { + sequence += 1; + return `queue-${sequence}`; + }, + maxAttempts: overrides.maxAttempts ?? 3, + backoffMs: 15_000, + }); + } + + it('delivers straight through while the device is reachable', async () => { + const result = await service().submit('device-1', { + phoneNumber: '13800138000', + content: '在线直发', + }); + expect(result.status).toBe('sent'); + expect(result.item).toBeNull(); + expect(delivered).toEqual([{ instanceId: 'device-1', content: '在线直发' }]); + expect(service().list().total).toBe(0); + }); + + it('queues for an offline device and delivers once it returns', async () => { + offline.add('device-1'); + const outbox = service(); + const result = await outbox.submit('device-1', { + phoneNumber: '13800138000', + content: '离线排队', + }); + expect(result.status).toBe('queued'); + expect(result.item?.status).toBe('queued'); + expect(result.item?.attempts).toBe(0); + expect(delivered).toEqual([]); + + expect(await outbox.flush()).toMatchObject({ attempted: 1, delivered: 0, deferred: 1 }); + expect(outbox.get(result.item?.id ?? '')?.status).toBe('queued'); + + offline.delete('device-1'); + expect(await outbox.flush()).toMatchObject({ attempted: 1, delivered: 1, remaining: 0 }); + expect(delivered).toEqual([{ instanceId: 'device-1', content: '离线排队' }]); + const settled = outbox.get(result.item?.id ?? ''); + expect(settled?.status).toBe('sent'); + expect(settled?.sentAt).toBe(time.current.toISOString()); + }); + + it('falls back to the queue when a live send fails', async () => { + sendError = () => new MessageServiceError('UPSTREAM_FAILED'); + const outbox = service(); + const result = await outbox.submit('device-1', { + phoneNumber: '13800138000', + content: '节点抖动', + }); + expect(result.status).toBe('queued'); + expect(result.item?.lastError).toBe('UPSTREAM_FAILED'); + }); + + it('backs off between attempts and gives up at the limit', async () => { + sendError = () => new MessageServiceError('UPSTREAM_FAILED'); + const outbox = service(); + await outbox.submit('device-1', { phoneNumber: '13800138000', content: '重试退避' }); + + expect(await outbox.flush()).toMatchObject({ attempted: 1, deferred: 1 }); + expect(outbox.list().items[0]).toMatchObject({ attempts: 1, status: 'queued' }); + // The next attempt is parked behind the backoff window. + expect(await outbox.flush()).toMatchObject({ attempted: 0 }); + + time.advance(15_000); + expect(await outbox.flush()).toMatchObject({ attempted: 1, deferred: 1 }); + expect(outbox.list().items[0]).toMatchObject({ attempts: 2, status: 'queued' }); + + time.advance(30_000); + expect(await outbox.flush()).toMatchObject({ attempted: 1, failed: 1, remaining: 0 }); + const exhausted = outbox.list({ status: 'all' }).items[0]; + expect(exhausted).toMatchObject({ status: 'failed', attempts: 3 }); + + const revived = outbox.retry(exhausted?.id ?? ''); + expect(revived.status).toBe('queued'); + expect(revived.maxAttempts).toBeGreaterThanOrEqual(revived.attempts + 1); + }); + + it('cancels pending work, prunes history, and reconciles an interrupted process', async () => { + offline.add('device-1'); + const outbox = service(); + const first = await outbox.submit('device-1', { + phoneNumber: '13800138000', + content: '待取消', + }); + const second = await outbox.submit('device-1', { + phoneNumber: '13800138001', + content: '待删除', + }); + expect(outbox.summary()).toMatchObject({ queued: 2 }); + + expect(outbox.cancel(first.item?.id ?? '').status).toBe('cancelled'); + expect(() => outbox.cancel(first.item?.id ?? '')).toThrow(MessageServiceError); + outbox.remove(second.item?.id ?? ''); + expect(outbox.list({ status: 'all' }).total).toBe(1); + + time.advance(3_600_000); + expect(outbox.prune(0)).toBe(1); + expect(outbox.list({ status: 'all' }).total).toBe(0); + + db.prepare( + `INSERT INTO sms_outbox (id,instance_id,phone_number,content,status,attempts,max_attempts, + available_at,created_at,updated_at) + VALUES ('stale','device-1','13800138000','中断','sending',1,3,?,?,?)`, + ).run(START.toISOString(), START.toISOString(), START.toISOString()); + expect(outbox.reconcileInterrupted()).toBe(1); + expect(outbox.get('stale')).toMatchObject({ status: 'queued', lastError: 'INTERRUPTED' }); + }); + + it('rejects malformed submissions and unknown queue entries', async () => { + const outbox = service(); + await expect(outbox.submit('', { phoneNumber: '13800138000', content: 'x' })).rejects.toThrow( + MessageServiceError, + ); + await expect( + outbox.submit('device-1', { phoneNumber: 'not-a-number', content: 'x' }), + ).rejects.toThrow(MessageServiceError); + await expect( + outbox.submit('device-1', { phoneNumber: '13800138000', content: ' ' }), + ).rejects.toThrow(MessageServiceError); + expect(outbox.get('missing')).toBeUndefined(); + expect(() => outbox.cancel('missing')).toThrow(MessageServiceError); + expect(outbox.list({ status: 'all', limit: 10, offset: 0 }).items).toEqual([]); + }); +}); diff --git a/apps/api/src/application/messages/sms-outbox-service.ts b/apps/api/src/application/messages/sms-outbox-service.ts new file mode 100644 index 0000000..50c3095 --- /dev/null +++ b/apps/api/src/application/messages/sms-outbox-service.ts @@ -0,0 +1,483 @@ +import { randomUUID } from 'node:crypto'; + +import type Database from 'better-sqlite3'; + +import { MessageServiceError } from './instance-message-service.js'; + +export type SmsOutboxStatus = 'queued' | 'sending' | 'sent' | 'failed' | 'cancelled'; + +export interface SmsOutboxItem { + readonly id: string; + readonly instanceId: string; + readonly phoneNumber: string; + readonly content: string; + readonly status: SmsOutboxStatus; + readonly attempts: number; + readonly maxAttempts: number; + readonly lastError: string | null; + readonly availableAt: string; + readonly sentAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface SmsOutboxPage { + readonly items: readonly SmsOutboxItem[]; + readonly total: number; +} + +export interface SmsOutboxSummary { + readonly queued: number; + readonly sending: number; + readonly failed: number; + readonly sent: number; +} + +export interface SmsOutboxFlushResult { + readonly attempted: number; + readonly delivered: number; + readonly deferred: number; + readonly failed: number; + readonly remaining: number; +} + +export interface SmsOutboxSubmitResult { + readonly status: 'sent' | 'queued'; + readonly item: SmsOutboxItem | null; +} + +export interface SmsOutboxServiceOptions { + readonly send: ( + instanceId: string, + input: { readonly phoneNumber: string; readonly content: string }, + ) => Promise; + /** + * Devices the heartbeat positively marked unreachable. Never-probed devices stay optimistic so + * a send still goes straight to the node; only known-dead devices queue. + */ + readonly offlineInstances: () => ReadonlySet; + readonly now?: () => Date; + readonly id?: () => string; + readonly maxAttempts?: number; + readonly backoffMs?: number; + readonly maximumBackoffMs?: number; + readonly pageSize?: number; +} + +const DEFAULT_MAX_ATTEMPTS = 24; +const DEFAULT_BACKOFF_MS = 15_000; +const DEFAULT_MAXIMUM_BACKOFF_MS = 600_000; +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; +const MAX_PHONE_LENGTH = 32; +const MAX_CONTENT_LENGTH = 2_000; +const MAX_ERROR_LENGTH = 200; +const OPEN_STATUSES: readonly SmsOutboxStatus[] = ['queued', 'sending']; + +interface OutboxRow { + readonly id: string; + readonly instance_id: string; + readonly phone_number: string; + readonly content: string; + readonly status: string; + readonly attempts: number; + readonly max_attempts: number; + readonly last_error: string | null; + readonly available_at: string; + readonly sent_at: string | null; + readonly created_at: string; + readonly updated_at: string; +} + +function truncate(value: string, maximum: number): string { + return value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`; +} + +function describeError(error: unknown): string { + if (error instanceof MessageServiceError) return error.code; + if (error instanceof Error && error.message.trim() !== '') + return truncate(error.message.replace(/\s+/gu, ' '), MAX_ERROR_LENGTH); + return 'DELIVERY_FAILED'; +} + +function validPhoneNumber(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim().length >= 3 && + value.trim().length <= MAX_PHONE_LENGTH && + /^[+0-9][0-9 ()-]*$/u.test(value.trim()) + ); +} + +function validContent(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim().length > 0 && + value.trim().length <= MAX_CONTENT_LENGTH + ); +} + +/** + * Durable outbound SMS queue. The Hub keeps accepting sends while a device is offline and hands + * them over once the device returns, so this service owns the wait rather than failing the request. + */ +export class SmsOutboxService { + readonly #db: Database.Database; + readonly #options: SmsOutboxServiceOptions; + readonly #now: () => Date; + readonly #id: () => string; + readonly #maxAttempts: number; + readonly #backoffMs: number; + readonly #maximumBackoffMs: number; + readonly #pageSize: number; + #flushing: Promise | undefined; + + constructor(db: Database.Database, options: SmsOutboxServiceOptions) { + const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 1_000) + throw new RangeError('maxAttempts must be between 1 and 1000'); + const backoffMs = options.backoffMs ?? DEFAULT_BACKOFF_MS; + if (!Number.isSafeInteger(backoffMs) || backoffMs < 1_000 || backoffMs > 600_000) + throw new RangeError('backoffMs must be between 1000 and 600000'); + const maximumBackoffMs = options.maximumBackoffMs ?? DEFAULT_MAXIMUM_BACKOFF_MS; + if (!Number.isSafeInteger(maximumBackoffMs) || maximumBackoffMs < backoffMs) + throw new RangeError('maximumBackoffMs must be at least backoffMs'); + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE) + throw new RangeError(`pageSize must be between 1 and ${MAX_PAGE_SIZE}`); + this.#db = db; + this.#options = options; + this.#now = options.now ?? (() => new Date()); + this.#id = options.id ?? (() => randomUUID()); + this.#maxAttempts = maxAttempts; + this.#backoffMs = backoffMs; + this.#maximumBackoffMs = maximumBackoffMs; + this.#pageSize = pageSize; + } + + /** + * Delivers immediately when the device is reachable and otherwise parks the message in the + * queue. A failed live attempt also falls back to the queue so a flapping device never loses + * an operator send. + */ + async submit( + instanceId: string, + input: { readonly phoneNumber: unknown; readonly content: unknown }, + ): Promise { + if (typeof instanceId !== 'string' || instanceId.trim() === '') + throw new MessageServiceError('VALIDATION_FAILED'); + if (!validPhoneNumber(input.phoneNumber) || !validContent(input.content)) + throw new MessageServiceError('VALIDATION_FAILED'); + const phoneNumber = input.phoneNumber.trim(); + const content = input.content.trim(); + if (!this.#offline().has(instanceId)) { + try { + await this.#options.send(instanceId, { phoneNumber, content }); + return { status: 'sent', item: null }; + } catch (error) { + if (error instanceof MessageServiceError && error.code === 'VALIDATION_FAILED') throw error; + const item = this.#enqueue(instanceId, phoneNumber, content, describeError(error)); + return { status: 'queued', item }; + } + } + const item = this.#enqueue(instanceId, phoneNumber, content, null); + return { status: 'queued', item }; + } + + list( + query: { + readonly status?: SmsOutboxStatus | 'open' | 'all'; + readonly instanceId?: string; + readonly limit?: number; + readonly offset?: number; + } = {}, + ): SmsOutboxPage { + const limit = Math.min(Math.max(Math.trunc(query.limit ?? this.#pageSize), 1), MAX_PAGE_SIZE); + const offset = Math.max(Math.trunc(query.offset ?? 0), 0); + const conditions: string[] = []; + const parameters: unknown[] = []; + const status = query.status ?? 'open'; + if (status === 'open') { + conditions.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`); + parameters.push(...OPEN_STATUSES); + } else if (status !== 'all') { + conditions.push('status = ?'); + parameters.push(status); + } + if (typeof query.instanceId === 'string' && query.instanceId.trim() !== '') { + conditions.push('instance_id = ?'); + parameters.push(query.instanceId.trim()); + } + const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : ''; + const total = (this.#db + .prepare(`SELECT COUNT(*) AS count FROM sms_outbox${where}`) + .get(...parameters) ?? { count: 0 }) as { count: number }; + const rows = this.#db + .prepare( + `SELECT * FROM sms_outbox${where} + ORDER BY CASE status WHEN 'sending' THEN 0 WHEN 'queued' THEN 1 ELSE 2 END, + available_at, created_at + LIMIT ? OFFSET ?`, + ) + .all(...parameters, limit, offset) as readonly OutboxRow[]; + return Object.freeze({ + items: Object.freeze(rows.map((row) => this.#item(row))), + total: Number.isSafeInteger(total.count) ? total.count : 0, + }); + } + + summary(): SmsOutboxSummary { + const rows = this.#db + .prepare('SELECT status, COUNT(*) AS count FROM sms_outbox GROUP BY status') + .all() as readonly { readonly status: string; readonly count: number }[]; + const counts = new Map(rows.map((row) => [row.status, Number(row.count) || 0])); + return Object.freeze({ + queued: counts.get('queued') ?? 0, + sending: counts.get('sending') ?? 0, + failed: counts.get('failed') ?? 0, + sent: counts.get('sent') ?? 0, + }); + } + + get(id: string): SmsOutboxItem | undefined { + const row = this.#db.prepare('SELECT * FROM sms_outbox WHERE id=?').get(id) as + | OutboxRow + | undefined; + return row ? this.#item(row) : undefined; + } + + cancel(id: string): SmsOutboxItem { + const current = this.#require(id); + if (!OPEN_STATUSES.includes(current.status)) throw new MessageServiceError('VALIDATION_FAILED'); + const timestamp = this.#now().toISOString(); + this.#db + .prepare("UPDATE sms_outbox SET status='cancelled',last_error=?,updated_at=? WHERE id=?") + .run('CANCELLED_BY_OPERATOR', timestamp, id); + return this.#require(id); + } + + /** Re-arms a cancelled or exhausted message so the next flush can try it again. */ + retry(id: string): SmsOutboxItem { + const current = this.#require(id); + if (current.status === 'sending' || current.status === 'sent') + throw new MessageServiceError('VALIDATION_FAILED'); + const timestamp = this.#now().toISOString(); + this.#db + .prepare( + `UPDATE sms_outbox + SET status='queued',available_at=?,last_error=NULL,max_attempts=MAX(max_attempts,attempts+1), + updated_at=? + WHERE id=?`, + ) + .run(timestamp, timestamp, id); + return this.#require(id); + } + + remove(id: string): void { + this.#require(id); + this.#db.prepare('DELETE FROM sms_outbox WHERE id=?').run(id); + } + + /** Drops delivered and abandoned entries; queued work is always preserved. */ + prune(olderThanMs: number): number { + if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0) + throw new RangeError('olderThanMs must be a non-negative integer'); + const cutoff = new Date(this.#now().getTime() - olderThanMs).toISOString(); + const info = this.#db + .prepare( + `DELETE FROM sms_outbox + WHERE status IN ('sent','failed','cancelled') AND updated_at <= ?`, + ) + .run(cutoff); + return Number(info.changes ?? 0); + } + + /** Startup sweep: nothing can be mid-flight before the first request of a fresh process. */ + reconcileInterrupted(): number { + const timestamp = this.#now().toISOString(); + const info = this.#db + .prepare( + `UPDATE sms_outbox + SET status='queued',last_error=?,available_at=?,updated_at=? WHERE status='sending'`, + ) + .run('INTERRUPTED', timestamp, timestamp); + return Number(info.changes ?? 0); + } + + /** Concurrent callers share one pass, the same way the heartbeat collapses overlapping beats. */ + flush(): Promise { + if (this.#flushing) return this.#flushing; + const pending = this.#flush().finally(() => { + if (this.#flushing === pending) this.#flushing = undefined; + }); + this.#flushing = pending; + return pending; + } + + async #flush(): Promise { + const claimed = this.#claim(); + let delivered = 0; + let deferred = 0; + let failed = 0; + const offline = this.#offline(); + for (const item of claimed) { + if (offline.has(item.instanceId)) { + // Waiting for the device is not a failed attempt, so keep the item due on the next tick. + this.#defer(item.id, 'DEVICE_OFFLINE'); + deferred += 1; + continue; + } + try { + await this.#options.send(item.instanceId, { + phoneNumber: item.phoneNumber, + content: item.content, + }); + this.#settle(item.id, 'sent', null); + delivered += 1; + } catch (error) { + const reason = describeError(error); + if (error instanceof MessageServiceError && error.code === 'VALIDATION_FAILED') { + this.#settle(item.id, 'failed', reason); + failed += 1; + continue; + } + if (this.#release(item.id, reason) === 'failed') failed += 1; + else deferred += 1; + } + } + const summary = this.summary(); + return Object.freeze({ + attempted: claimed.length, + delivered, + deferred, + failed, + remaining: summary.queued + summary.sending, + }); + } + + #offline(): ReadonlySet { + try { + return this.#options.offlineInstances(); + } catch { + // A broken reachability read must not lose queued work; treat every device as reachable. + return new Set(); + } + } + + #claim(): readonly SmsOutboxItem[] { + const now = this.#now(); + const timestamp = now.toISOString(); + return this.#db.transaction((): readonly SmsOutboxItem[] => { + const rows = this.#db + .prepare( + `SELECT * FROM sms_outbox + WHERE status='queued' AND available_at <= ? + ORDER BY available_at, created_at LIMIT ?`, + ) + .all(timestamp, this.#pageSize) as readonly OutboxRow[]; + const claim = this.#db.prepare( + `UPDATE sms_outbox + SET status='sending',attempts=attempts+1,last_error=NULL,updated_at=? + WHERE id=? AND status='queued'`, + ); + const claimed: SmsOutboxItem[] = []; + for (const row of rows) { + const info = claim.run(timestamp, row.id); + if (Number(info.changes ?? 0) > 0) claimed.push(this.#item(row, 'sending')); + } + return claimed; + })(); + } + + /** Puts a claimed item back in line; returns 'failed' once the attempt budget is spent. */ + #release(id: string, reason: string): 'queued' | 'failed' { + const current = this.#require(id); + const backoff = Math.min( + this.#backoffMs * 2 ** Math.max(current.attempts - 1, 0), + this.#maximumBackoffMs, + ); + const timestamp = this.#now().toISOString(); + const availableAt = new Date(this.#now().getTime() + backoff).toISOString(); + const exhausted = current.attempts >= current.maxAttempts; + this.#db + .prepare( + `UPDATE sms_outbox + SET status=?,last_error=?,available_at=?,updated_at=? WHERE id=?`, + ) + .run(exhausted ? 'failed' : 'queued', reason, availableAt, timestamp, id); + return exhausted ? 'failed' : 'queued'; + } + + #defer(id: string, reason: string): void { + const timestamp = this.#now().toISOString(); + this.#db + .prepare( + "UPDATE sms_outbox SET status='queued',last_error=?,available_at=?,updated_at=? WHERE id=?", + ) + .run(reason, timestamp, timestamp, id); + } + + #settle(id: string, status: 'sent' | 'failed', reason: string | null): void { + const timestamp = this.#now().toISOString(); + this.#db + .prepare( + `UPDATE sms_outbox + SET status=?,last_error=?,sent_at=?,updated_at=? WHERE id=?`, + ) + .run(status, reason, status === 'sent' ? timestamp : null, timestamp, id); + } + + #enqueue(instanceId: string, phoneNumber: string, content: string, reason: string | null) { + const timestamp = this.#now().toISOString(); + const id = this.#id(); + this.#db + .prepare( + `INSERT INTO sms_outbox (id,instance_id,phone_number,content,status,attempts,max_attempts, + last_error,available_at,created_at,updated_at) + VALUES (?,?,?,?,'queued',0,?,?,?,?,?)`, + ) + .run( + id, + instanceId, + phoneNumber, + content, + this.#maxAttempts, + reason, + timestamp, + timestamp, + timestamp, + ); + return this.#require(id); + } + + #require(id: string): SmsOutboxItem { + const item = this.get(id); + if (!item) throw new MessageServiceError('NOT_FOUND'); + return item; + } + + #item(row: OutboxRow, status?: SmsOutboxStatus): SmsOutboxItem { + return Object.freeze({ + id: String(row.id), + instanceId: String(row.instance_id), + phoneNumber: String(row.phone_number), + content: String(row.content), + status: status ?? this.#status(row.status), + attempts: Number.isSafeInteger(row.attempts) ? row.attempts : Number(row.attempts) || 0, + maxAttempts: Number.isSafeInteger(row.max_attempts) + ? row.max_attempts + : Number(row.max_attempts) || this.#maxAttempts, + lastError: typeof row.last_error === 'string' ? row.last_error : null, + availableAt: String(row.available_at), + sentAt: typeof row.sent_at === 'string' ? row.sent_at : null, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }); + } + + #status(value: unknown): SmsOutboxStatus { + return value === 'sending' || value === 'sent' || value === 'failed' || value === 'cancelled' + ? value + : 'queued'; + } +} diff --git a/apps/api/src/application/notifications/central-notification-service.test.ts b/apps/api/src/application/notifications/central-notification-service.test.ts new file mode 100644 index 0000000..4c1cef1 --- /dev/null +++ b/apps/api/src/application/notifications/central-notification-service.test.ts @@ -0,0 +1,458 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; +import { + CentralNotificationService, + type NotificationDeliverRequest, +} from './central-notification-service.js'; + +class MemorySecrets implements SecretStore { + readonly values = new Map(); + + async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) { + const reference = `memory://notification/${key.instanceId}/${key.purpose}`; + this.values.set(reference, value); + return reference; + } + + async get(reference: string) { + return this.values.get(reference); + } + + async delete(reference: string) { + return this.values.delete(reference); + } +} + +const directories: Database.Database[] = []; + +afterEach(() => { + for (const db of directories.splice(0)) db.close(); +}); + +function fixture(clock?: () => Date) { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + directories.push(db); + const store = new MemorySecrets(); + let sequence = 0; + const deliveries: NotificationDeliverRequest[] = []; + const service = new CentralNotificationService(db, { + store, + now: clock ?? (() => new Date('2026-09-03T08:00:00.000Z')), + id: () => `id-${++sequence}`, + deliver: async (request) => { + deliveries.push(request); + const endpoint = String(request.config['url'] ?? request.config['server_url'] ?? ''); + if (endpoint.includes('unavailable')) throw new Error('channel unreachable'); + return { ok: true }; + }, + }); + return { db, service, store, deliveries }; +} + +const bark = { + name: 'Bark', + type: 'bark' as const, + enabled: true, + config: { server_url: 'https://api.day.app', group: 'Island', device_key: 'device-key' }, +}; + +describe('CentralNotificationService', () => { + it('stores channel credentials in the secret store and returns redacted channels', async () => { + const { db, service, store } = fixture(); + const channel = await service.createChannel(bark); + + expect(channel).toMatchObject({ + name: 'Bark', + type: 'bark', + enabled: true, + config: { server_url: 'https://api.day.app', group: 'Island' }, + hasSecret: true, + secretFields: ['device_key'], + }); + expect(JSON.stringify(channel)).not.toContain('device-key'); + expect(store.values.get(channel.secretReference ?? '')).toBe( + JSON.stringify({ device_key: 'device-key' }), + ); + expect( + db + .prepare( + 'SELECT config_json, secret_reference, secret_fields FROM notification_channels WHERE id = ?', + ) + .get(channel.id), + ).toEqual({ + config_json: JSON.stringify({ + server_url: 'https://api.day.app', + group: 'Island', + sound: '', + level: '', + icon: '', + auto_copy: true, + save_history: true, + }), + secret_reference: channel.secretReference, + secret_fields: 'device_key', + }); + }); + + it('creates, matches, and updates device-scoped rules without exposing credentials', async () => { + const { service } = fixture(); + const channel = await service.createChannel(bark); + const rule = await service.createRule({ + name: '短信转发', + eventType: 'sms', + enabled: true, + condition: { field: 'content', mode: 'contains', value: '验证码' }, + scope: { mode: 'tags', tags: ['lab', 'east'], match: 'any' }, + channelIds: [channel.id], + templates: { title: '来自 {{sender}}', body: '{{content}}' }, + }); + + expect(rule).toMatchObject({ + name: '短信转发', + eventType: 'sms', + channels: [{ id: channel.id, name: 'Bark', enabled: true }], + }); + expect( + service.matchRules('sms', { + instanceId: 'instance-1', + instanceTags: ['east'], + fields: { content: '您的验证码是 1234' }, + }), + ).toEqual([rule]); + + const updated = await service.updateRule(rule.id, { + name: '验证码转发', + scope: { mode: 'devices', instanceIds: ['instance-2'] }, + }); + expect(updated.name).toBe('验证码转发'); + expect(updated.scope).toEqual({ mode: 'devices', instanceIds: ['instance-2'] }); + }); + + it('tests an enabled channel, records delivery, and never logs the secret', async () => { + const { db, service, deliveries } = fixture(); + const channel = await service.createChannel(bark); + const result = await service.testChannel(channel.id, { + title: '测试通知', + body: '融合控制台通知链路正常', + }); + + expect(result).toMatchObject({ ok: true, status: 'success' }); + expect(deliveries).toEqual([ + { + channelId: channel.id, + type: 'bark', + config: { + server_url: 'https://api.day.app', + group: 'Island', + sound: '', + level: '', + icon: '', + auto_copy: true, + save_history: true, + device_key: 'device-key', + }, + eventType: 'test', + occurredAt: '2026-09-03T08:00:00.000Z', + title: '测试通知', + body: '融合控制台通知链路正常', + }, + ]); + const delivery = db.prepare('SELECT * FROM notification_deliveries').get() as Record< + string, + unknown + >; + expect(delivery).toMatchObject({ + event_type: 'test', + status: 'success', + channel_id: channel.id, + }); + expect(JSON.stringify(delivery)).not.toContain('device-key'); + expect(service.listLogs().items[0]).toMatchObject({ + eventType: 'test', + status: 'success', + channelName: 'Bark', + }); + }); + + it('records failed channel tests with a safe delivery detail', async () => { + const { service } = fixture(); + const channel = await service.createChannel({ + ...bark, + config: { ...bark.config, server_url: 'https://unavailable.invalid' }, + }); + + await expect( + service.testChannel(channel.id, { title: '测试通知', body: 'hello' }), + ).resolves.toMatchObject({ ok: false, status: 'failed', detail: 'channel unreachable' }); + expect(service.listLogs().items[0]).toMatchObject({ + status: 'failed', + detail: 'channel unreachable', + }); + }); + + describe('delivery log retention', () => { + function insertLog( + db: Database.Database, + channelId: string, + id: string, + createdAt: string, + status: 'success' | 'failed' = 'success', + eventType = 'sms', + ): void { + db.prepare( + `INSERT INTO notification_deliveries + (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at) + VALUES (?,NULL,?,NULL,?,?,NULL,?,?)`, + ).run(id, channelId, eventType, status, createdAt, createdAt); + } + + it('keeps the Hub retention defaults until they are stored', () => { + const { db, service } = fixture(); + expect(service.logCleanup()).toEqual({ + retentionDaysEnabled: true, + retentionDays: 180, + maxEntriesEnabled: false, + maxEntries: 10_000, + }); + + service.updateLogCleanup({ retentionDays: 30, maxEntriesEnabled: true, maxEntries: 500 }); + + expect(service.logCleanup()).toEqual({ + retentionDaysEnabled: true, + retentionDays: 30, + maxEntriesEnabled: true, + maxEntries: 500, + }); + const stored = db + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get('notifications.logs.cleanup') as { value_json: string }; + expect(JSON.parse(stored.value_json)).toMatchObject({ retentionDays: 30, maxEntries: 500 }); + }); + + it('rejects out-of-range and unknown retention fields', () => { + const { service } = fixture(); + expect(() => service.updateLogCleanup({ retentionDays: 0 })).toThrow(); + expect(() => service.updateLogCleanup({ maxEntries: 0 })).toThrow(); + expect(() => service.updateLogCleanup({ retentionDays: 30, extra: true })).toThrow(); + expect(() => service.updateLogCleanup('180')).toThrow(); + }); + + it('prunes by age and then by the entry ceiling', async () => { + const { db, service } = fixture(); + const channel = await service.createChannel(bark); + insertLog(db, channel.id, 'old', '2020-01-01T00:00:00.000Z'); + insertLog(db, channel.id, 'recent', '2026-09-03T07:00:00.000Z'); + insertLog(db, channel.id, 'newest', '2026-09-03T07:30:00.000Z'); + + service.updateLogCleanup({ retentionDays: 30, maxEntriesEnabled: false }); + expect(service.pruneLogs()).toBe(1); + expect(service.listLogs().items.map((item) => item.id)).toEqual(['newest', 'recent']); + + service.updateLogCleanup({ + retentionDaysEnabled: false, + maxEntriesEnabled: true, + maxEntries: 1, + }); + expect(service.pruneLogs()).toBe(1); + expect(service.listLogs().items.map((item) => item.id)).toEqual(['newest']); + }); + + it('filters and clears logs by status, event type and date range', async () => { + const { db, service } = fixture(); + const channel = await service.createChannel(bark); + insertLog(db, channel.id, 'a', '2026-09-01T00:00:00.000Z', 'success', 'sms'); + insertLog(db, channel.id, 'b', '2026-09-02T00:00:00.000Z', 'failed', 'sms'); + insertLog(db, channel.id, 'c', '2026-09-03T00:00:00.000Z', 'failed', 'system'); + + expect(service.listLogs(1, 50, { status: 'failed' }).items.map((item) => item.id)).toEqual([ + 'c', + 'b', + ]); + expect(service.listLogs(1, 50, { eventType: 'system' }).page.total).toBe(1); + expect( + service + .listLogs(1, 50, { from: '2026-09-02T00:00:00.000Z', to: '2026-09-02T12:00:00.000Z' }) + .items.map((item) => item.id), + ).toEqual(['b']); + + expect(service.clearLogs({ status: 'failed', eventType: 'sms' })).toBe(1); + expect(service.listLogs().items.map((item) => item.id)).toEqual(['c', 'a']); + expect(service.clearLogs('2026-09-01T12:00:00.000Z')).toBe(1); + expect(service.listLogs().page.total).toBe(1); + expect(() => service.clearLogs({ before: 'not-a-date' })).toThrow(); + }); + }); + + describe('rate limit and quiet hours', () => { + const sms = { fields: { content: '您的验证码是 1234' } }; + + async function rule( + service: CentralNotificationService, + channelId: string, + suppression: Record, + ) { + return service.createRule({ + name: '抑制规则', + eventType: 'sms', + enabled: true, + condition: { field: 'content', mode: 'all' }, + scope: { mode: 'all' }, + channelIds: [channelId], + templates: { title: '标题', body: '{{content}}' }, + ...suppression, + }); + } + + it('stores the Hub defaults when a rule omits both suppression blocks', async () => { + const { service } = fixture(); + const channel = await service.createChannel(bark); + const created = await rule(service, channel.id, {}); + + expect(created.rateLimit).toEqual({ enabled: false, maxMessages: 20, windowSeconds: 60 }); + expect(created.quietHours).toEqual([]); + }); + + it('drops events inside a quiet window and records one suppressed log', async () => { + // 08:30 Shanghai time sits inside 08:00-09:00. + const { service } = fixture(() => new Date('2026-09-03T00:30:00.000Z')); + const channel = await service.createChannel(bark); + const created = await rule(service, channel.id, { + quietHours: [{ start: '08:00', end: '09:00' }], + }); + + expect(await service.enqueueEvent('sms', sms)).toEqual([]); + const log = service.listLogs().items[0]; + expect(log).toMatchObject({ + status: 'quiet_hours', + eventType: 'sms', + ruleId: created.id, + channelName: '—', + detail: '免打扰时段:08:00-09:00', + }); + expect(log?.channelId).toBeUndefined(); + expect(service.logSummary().suppressed).toBe(1); + + expect(service.listLogs(1, 50, { status: 'quiet_hours' }).page.total).toBe(1); + expect(service.listLogs(1, 50, { status: 'success' }).page.total).toBe(0); + }); + + it('treats a window that ends before it starts as crossing midnight', async () => { + let instant = new Date('2026-09-02T23:30:00.000Z'); + const { service } = fixture(() => instant); + const channel = await service.createChannel(bark); + await rule(service, channel.id, { quietHours: [{ start: '22:00', end: '08:00' }] }); + + // 07:30 Shanghai, inside the window. + expect(await service.enqueueEvent('sms', sms)).toEqual([]); + // 09:00 Shanghai, outside the window. + instant = new Date('2026-09-03T01:00:00.000Z'); + expect(await service.enqueueEvent('sms', sms)).toHaveLength(1); + }); + + it('enforces the rate limit inside the window and releases it afterwards', async () => { + let instant = new Date('2026-09-03T00:00:00.000Z'); + const { service } = fixture(() => instant); + const channel = await service.createChannel(bark); + await rule(service, channel.id, { + rateLimit: { enabled: true, maxMessages: 2, windowSeconds: 60 }, + }); + + expect(await service.enqueueEvent('sms', sms)).toHaveLength(1); + expect(await service.enqueueEvent('sms', sms)).toHaveLength(1); + expect(await service.enqueueEvent('sms', sms)).toEqual([]); + expect(service.logSummary()).toMatchObject({ suppressed: 1, total: 1 }); + + instant = new Date('2026-09-03T00:01:01.000Z'); + expect(await service.enqueueEvent('sms', sms)).toHaveLength(1); + }); + + it('prefers the quiet window over the rate limit', async () => { + const { service } = fixture(() => new Date('2026-09-03T00:30:00.000Z')); + const channel = await service.createChannel(bark); + await rule(service, channel.id, { + rateLimit: { enabled: true, maxMessages: 5, windowSeconds: 60 }, + quietHours: [{ start: '08:00', end: '09:00' }], + }); + + expect(await service.enqueueEvent('sms', sms)).toEqual([]); + expect(service.listLogs().items[0]?.status).toBe('quiet_hours'); + }); + + it('leaves suppression settings untouched when a patch omits them', async () => { + const { service } = fixture(); + const channel = await service.createChannel(bark); + const created = await rule(service, channel.id, { + rateLimit: { enabled: true, maxMessages: 3, windowSeconds: 120 }, + quietHours: [{ start: '23:00', end: '06:00' }], + }); + + const patched = await service.updateRule(created.id, { name: '改名后的规则' }); + expect(patched.name).toBe('改名后的规则'); + expect(patched.rateLimit).toEqual({ enabled: true, maxMessages: 3, windowSeconds: 120 }); + expect(patched.quietHours).toEqual([{ start: '23:00', end: '06:00' }]); + + const disabled = await service.updateRule(created.id, { + rateLimit: { enabled: false, maxMessages: 3, windowSeconds: 120 }, + quietHours: [], + }); + expect(disabled.rateLimit.enabled).toBe(false); + expect(disabled.quietHours).toEqual([]); + }); + + it('rejects malformed suppression settings', async () => { + const { service } = fixture(); + const channel = await service.createChannel(bark); + const base = { + name: '抑制规则', + eventType: 'sms', + enabled: true, + condition: { field: 'content', mode: 'all' }, + scope: { mode: 'all' }, + channelIds: [channel.id], + templates: { title: '标题', body: '正文' }, + }; + + await expect( + service.createRule({ + ...base, + rateLimit: { enabled: true, maxMessages: 0, windowSeconds: 60 }, + }), + ).rejects.toThrow(); + await expect( + service.createRule({ + ...base, + rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 86_401 }, + }), + ).rejects.toThrow(); + await expect( + service.createRule({ + ...base, + rateLimit: { enabled: true, maxMessages: 20, windowSeconds: 60, extra: 1 }, + }), + ).rejects.toThrow(); + await expect( + service.createRule({ ...base, quietHours: [{ start: '08:00', end: '08:00' }] }), + ).rejects.toThrow(); + await expect( + service.createRule({ ...base, quietHours: [{ start: '8:00', end: '09:00' }] }), + ).rejects.toThrow(); + await expect( + service.createRule({ ...base, quietHours: [{ start: '08:00', end: '09:00', extra: 1 }] }), + ).rejects.toThrow(); + await expect( + service.createRule({ + ...base, + quietHours: Array.from({ length: 9 }, (_, index) => ({ + start: '00:00', + end: index === 8 ? '23:59' : String(index).padStart(2, '0') + ':30', + })), + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/apps/api/src/application/notifications/central-notification-service.ts b/apps/api/src/application/notifications/central-notification-service.ts new file mode 100644 index 0000000..a5bec2e --- /dev/null +++ b/apps/api/src/application/notifications/central-notification-service.ts @@ -0,0 +1,1578 @@ +import { randomUUID } from 'node:crypto'; + +import type Database from 'better-sqlite3'; + +import { + isNotificationChannelType, + notificationChannelSecretKeys, + splitNotificationChannelConfig, + NotificationChannelConfigError, + type NotificationChannelConfigMap, +} from '@multi-simadmin/contracts'; + +import { shanghaiMinuteOfDay } from '../automation/schedule-time.js'; +import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; +import { + deliverThroughChannel, + type ChannelConfig, + type HttpRequester, +} from './channel-delivery.js'; +import { sendEmailNotification } from './smtp-sender.js'; + +export type NotificationChannelType = + | 'webhook' + | 'bark' + | 'pushplus' + | 'wecom_app' + | 'wecom_robot' + | 'dingtalk_robot' + | 'dingtalk_app' + | 'feishu_robot' + | 'telegram' + | 'email' + | 'serverchan'; + +export type NotificationEventType = 'sms' | 'ddns' | 'version' | 'system' | 'device' | 'automation'; + +export type NotificationDeliveryStatus = + | 'success' + | 'failed' + | 'pending' + | 'sending' + | 'retrying' + | 'unmatched' + | 'no_available_channel' + | 'quiet_hours' + | 'rate_limited'; + +export interface NotificationChannelConfig { + readonly [key: string]: string | number | boolean | ReadonlyArray; +} + +export interface NotificationChannel { + readonly id: string; + readonly name: string; + readonly type: NotificationChannelType; + readonly enabled: boolean; + readonly config: NotificationChannelConfig; + readonly secretReference?: string; + readonly hasSecret: boolean; + /** Secret field names that already hold a stored value; the values never leave the store. */ + readonly secretFields: readonly string[]; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface NotificationCondition { + readonly field: 'content' | 'sender' | 'title' | 'status'; + readonly mode: 'all' | 'contains' | 'equals' | 'regex'; + readonly value?: string | undefined; +} + +export interface NotificationTargetScope { + readonly mode: 'all' | 'tags' | 'devices'; + readonly tags?: readonly string[]; + readonly match?: 'all' | 'any'; + readonly instanceIds?: readonly string[]; +} + +export interface NotificationTemplates { + readonly title?: string; + readonly body?: string; +} + +/** Hub-style per-rule throttle: at most `maxMessages` enqueues inside a rolling window. */ +export interface NotificationRateLimit { + readonly enabled: boolean; + readonly maxMessages: number; + readonly windowSeconds: number; +} + +/** + * A do-not-disturb window on the Asia/Shanghai wall clock. When `end` is earlier than `start` + * the window wraps past midnight, matching the Hub's `22:00`-`08:00` default. + */ +export interface NotificationQuietWindow { + readonly start: string; + readonly end: string; +} + +export const DEFAULT_NOTIFICATION_RATE_LIMIT: NotificationRateLimit = Object.freeze({ + enabled: false, + maxMessages: 20, + windowSeconds: 60, +}); + +const RATE_LIMIT_MINIMUM_MESSAGES = 1; +const RATE_LIMIT_MAXIMUM_MESSAGES = 10_000; +const RATE_LIMIT_MINIMUM_WINDOW_SECONDS = 1; +const RATE_LIMIT_MAXIMUM_WINDOW_SECONDS = 86_400; +const MAXIMUM_QUIET_WINDOWS = 8; + +export type NotificationSuppression = 'quiet_hours' | 'rate_limited'; + +export interface NotificationRule { + readonly id: string; + readonly name: string; + readonly eventType: NotificationEventType; + readonly enabled: boolean; + readonly condition: NotificationCondition; + readonly scope: NotificationTargetScope; + readonly channels: readonly NotificationChannel[]; + readonly templates: NotificationTemplates; + readonly rateLimit: NotificationRateLimit; + readonly quietHours: readonly NotificationQuietWindow[]; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface NotificationLog { + readonly id: string; + readonly ruleId?: string; + /** Absent for rule-level suppressions, which are not tied to any single channel. */ + readonly channelId?: string; + readonly ruleName?: string; + readonly channelName: string; + readonly instanceId?: string; + readonly eventType: string; + readonly status: NotificationDeliveryStatus; + readonly detail?: string; + readonly createdAt: string; + readonly sentAt?: string; +} + +export interface NotificationLogPage { + readonly items: readonly NotificationLog[]; + readonly page: { readonly page: number; readonly pageSize: number; readonly total: number }; +} + +export interface NotificationQueueItem { + readonly id: string; + readonly instanceId?: string; + readonly eventType: string; + readonly ruleId?: string; + readonly channelId?: string; + readonly ruleName?: string; + readonly channelName?: string; + readonly status: 'pending' | 'sending' | 'succeeded' | 'failed' | 'cancelled'; + readonly state: 'pending' | 'retrying' | 'sending' | 'succeeded' | 'failed' | 'cancelled'; + readonly attempts: number; + readonly maxAttempts: number; + readonly title: string; + readonly body: string; + readonly availableAt: string; + readonly deliveredAt?: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface NotificationQueuePage { + readonly items: readonly NotificationQueueItem[]; + readonly page: { readonly page: number; readonly pageSize: number; readonly total: number }; +} + +export interface NotificationQueueSummary { + readonly total: number; + readonly pending: number; + readonly retrying: number; + readonly sending: number; + readonly succeeded: number; + readonly failed: number; + readonly cancelled: number; + readonly recent: readonly NotificationQueueItem[]; +} + +export interface NotificationLogSummary { + readonly total: number; + readonly success: number; + readonly failed: number; + readonly sending: number; + readonly pending: number; + /** Quiet-hours and rate-limit suppressions, which never reach a channel. */ + readonly suppressed: number; + readonly recent: readonly NotificationLog[]; +} + +export interface NotificationChannelTypeSummary { + readonly type: string; + readonly total: number; + readonly enabled: number; +} + +export interface NotificationOverview { + readonly config: { + readonly channelCount: number; + readonly channelEnabled: number; + readonly ruleCount: number; + readonly ruleEnabled: number; + readonly channelTypes: readonly NotificationChannelTypeSummary[]; + }; + readonly logs: NotificationLogSummary; + readonly queue: NotificationQueueSummary; +} + +/** + * Retention policy for the delivery log, stored in the `app_settings` key-value + * table. Both limits are optional so a deployment can keep logs by time, by size, + * by both, or neither. + */ +export interface NotificationLogCleanup { + readonly retentionDaysEnabled: boolean; + readonly retentionDays: number; + readonly maxEntriesEnabled: boolean; + readonly maxEntries: number; +} + +export interface NotificationLogFilter { + readonly status?: string; + readonly eventType?: string; + readonly from?: string; + readonly to?: string; +} + +export const DEFAULT_NOTIFICATION_LOG_CLEANUP: NotificationLogCleanup = Object.freeze({ + retentionDaysEnabled: true, + retentionDays: 180, + maxEntriesEnabled: false, + maxEntries: 10_000, +}); + +const LOG_CLEANUP_SETTING_KEY = 'notifications.logs.cleanup'; +const LOG_CLEANUP_MINIMUM_DAYS = 1; +const LOG_CLEANUP_MAXIMUM_DAYS = 36_500; +const LOG_CLEANUP_MINIMUM_ENTRIES = 1; +const LOG_CLEANUP_MAXIMUM_ENTRIES = 1_000_000; + +export interface NotificationEventInput { + readonly instanceId?: string; + readonly instanceTags?: readonly string[]; + readonly fields?: Readonly>; +} + +export interface NotificationDeliverRequest { + readonly channelId: string; + readonly type: NotificationChannelType; + /** Public config merged with the channel's stored credentials. */ + readonly config: ChannelConfig; + readonly title: string; + readonly body: string; + readonly eventType: string; + readonly occurredAt: string; + readonly instanceName?: string; +} + +export interface CentralNotificationServiceOptions { + readonly store: SecretStore; + readonly now?: () => Date; + readonly id?: () => string; + readonly deliver?: ( + request: NotificationDeliverRequest, + ) => Promise<{ readonly ok: boolean; readonly detail?: string }>; + /** Injected so tests can capture outbound requests without opening sockets. */ + readonly request?: HttpRequester; +} + +export class CentralNotificationServiceError extends Error { + constructor( + readonly code: + | 'VALIDATION_FAILED' + | 'CHANNEL_NOT_FOUND' + | 'RULE_NOT_FOUND' + | 'QUEUE_NOT_FOUND' + | 'SECRET_UNAVAILABLE', + message?: string, + ) { + super(message ?? code); + this.name = 'CentralNotificationServiceError'; + } +} + +const EVENT_TYPES: ReadonlySet = new Set([ + 'sms', + 'ddns', + 'version', + 'system', + 'device', + 'automation', +]); + +const record = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const text = (value: unknown, maximum = 200, required = true): string | undefined => { + if (typeof value !== 'string') return required ? undefined : ''; + const trimmed = value.trim().slice(0, maximum); + return trimmed === '' && required ? undefined : trimmed; +}; + +function configValue(value: unknown): NotificationChannelConfig[string] | undefined { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return value; + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) + return Object.freeze(value as string[]); + return undefined; +} + +function parseConfig(value: string): NotificationChannelConfig { + const source = record(JSON.parse(value)); + if (!source) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const output: Record = {}; + for (const [key, raw] of Object.entries(source)) { + const parsed = configValue(raw); + // Empty strings, zero and false are legitimate channel settings ("no ringtone"). + if (parsed === undefined) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + output[key] = parsed; + } + return Object.freeze(output); +} + +function parseJson(value: string, validate: (value: unknown) => T | undefined): T { + const parsed = validate(JSON.parse(value)); + if (!parsed) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return parsed; +} + +function boundedInteger(value: unknown, minimum: number, maximum: number): number | undefined { + return typeof value === 'number' && + Number.isSafeInteger(value) && + value >= minimum && + value <= maximum + ? value + : undefined; +} + +function flag(value: unknown, fallback: boolean): boolean | undefined { + if (value === undefined) return fallback; + return typeof value === 'boolean' ? value : undefined; +} + +function validateLogCleanup(value: unknown): NotificationLogCleanup | undefined { + const item = record(value); + if (!item) return undefined; + const known = ['retentionDaysEnabled', 'retentionDays', 'maxEntriesEnabled', 'maxEntries']; + if (Object.keys(item).some((key) => !known.includes(key))) return undefined; + const retentionDaysEnabled = flag(item.retentionDaysEnabled, true); + const maxEntriesEnabled = flag(item.maxEntriesEnabled, false); + const retentionDays = + item.retentionDays === undefined + ? DEFAULT_NOTIFICATION_LOG_CLEANUP.retentionDays + : boundedInteger(item.retentionDays, LOG_CLEANUP_MINIMUM_DAYS, LOG_CLEANUP_MAXIMUM_DAYS); + const maxEntries = + item.maxEntries === undefined + ? DEFAULT_NOTIFICATION_LOG_CLEANUP.maxEntries + : boundedInteger(item.maxEntries, LOG_CLEANUP_MINIMUM_ENTRIES, LOG_CLEANUP_MAXIMUM_ENTRIES); + if ( + retentionDaysEnabled === undefined || + maxEntriesEnabled === undefined || + retentionDays === undefined || + maxEntries === undefined + ) + return undefined; + return Object.freeze({ retentionDaysEnabled, retentionDays, maxEntriesEnabled, maxEntries }); +} + +const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/u; + +function clockMinutes(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined; + const match = CLOCK_TIME.exec(value.trim()); + if (!match) return undefined; + return Number(match[1]) * 60 + Number(match[2]); +} + +function clockText(minutes: number): string { + return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; +} + +function validateRateLimit(value: unknown): NotificationRateLimit { + if (value === undefined) return DEFAULT_NOTIFICATION_RATE_LIMIT; + const item = record(value); + if (!item) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const known = ['enabled', 'maxMessages', 'windowSeconds']; + if (Object.keys(item).some((key) => !known.includes(key))) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const enabled = flag(item.enabled, DEFAULT_NOTIFICATION_RATE_LIMIT.enabled); + const maxMessages = + item.maxMessages === undefined + ? DEFAULT_NOTIFICATION_RATE_LIMIT.maxMessages + : boundedInteger(item.maxMessages, RATE_LIMIT_MINIMUM_MESSAGES, RATE_LIMIT_MAXIMUM_MESSAGES); + const windowSeconds = + item.windowSeconds === undefined + ? DEFAULT_NOTIFICATION_RATE_LIMIT.windowSeconds + : boundedInteger( + item.windowSeconds, + RATE_LIMIT_MINIMUM_WINDOW_SECONDS, + RATE_LIMIT_MAXIMUM_WINDOW_SECONDS, + ); + if (enabled === undefined || maxMessages === undefined || windowSeconds === undefined) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Object.freeze({ enabled, maxMessages, windowSeconds }); +} + +function validateQuietHours(value: unknown): readonly NotificationQuietWindow[] { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value) || value.length > MAXIMUM_QUIET_WINDOWS) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Object.freeze( + value.map((entry) => { + const item = record(entry); + if (item && Object.keys(item).some((key) => key !== 'start' && key !== 'end')) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const start = clockMinutes(item?.start); + const end = clockMinutes(item?.end); + if (!item || start === undefined || end === undefined || start === end) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Object.freeze({ start: clockText(start), end: clockText(end) }); + }), + ); +} + +/** True when the Asia/Shanghai wall clock falls inside any configured window. */ +export function isWithinQuietHours( + windows: readonly NotificationQuietWindow[], + instant: Date, +): boolean { + if (windows.length === 0) return false; + const now = shanghaiMinuteOfDay(instant); + return windows.some((window) => { + const start = clockMinutes(window.start) ?? 0; + const end = clockMinutes(window.end) ?? 0; + // A wrapped window such as 22:00-08:00 covers either tail of the day. + return start <= end ? now >= start && now < end : now >= start || now < end; + }); +} +export class CentralNotificationService { + readonly #db: Database.Database; + readonly #options: CentralNotificationServiceOptions; + readonly #deliver: ( + request: NotificationDeliverRequest, + ) => Promise<{ readonly ok: boolean; readonly detail?: string }>; + + constructor(db: Database.Database, options: CentralNotificationServiceOptions) { + this.#db = db; + this.#options = options; + this.#deliver = + options.deliver ?? + ((request) => + deliverThroughChannel( + { + type: request.type, + config: request.config, + title: request.title, + body: request.body, + eventType: request.eventType, + occurredAt: request.occurredAt, + ...(request.instanceName ? { instanceName: request.instanceName } : {}), + }, + { request: options.request, sendEmail: sendEmailNotification }, + )); + } + + async createChannel(input: unknown): Promise { + const value = record(input); + const id = this.#id(); + const now = this.#now(); + const name = text(value?.name, 160); + const type = text(value?.type, 40); + if (!value || !name || !type || !isNotificationChannelType(type)) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const { config, secrets } = this.#channelConfig(type, value.config, {}); + const secretReference = + Object.keys(secrets).length > 0 ? await this.#saveSecrets(id, secrets) : undefined; + this.#db + .prepare( + `INSERT INTO notification_channels + (id,name,type,enabled,config_json,secret_reference,secret_fields,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + ) + .run( + id, + name, + type, + value.enabled === false ? 0 : 1, + JSON.stringify(config), + secretReference ?? null, + Object.keys(secrets).join(','), + now, + now, + ); + return this.getChannel(id); + } + + getChannel(id: string): NotificationChannel { + const row = this.#row(`SELECT * FROM notification_channels WHERE id = ?`, id); + if (!row) throw new CentralNotificationServiceError('CHANNEL_NOT_FOUND'); + return this.#channel(row); + } + + async updateChannel(id: string, patch: unknown): Promise { + const current = this.getChannel(id); + const value = record(patch); + if (!value) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const nextType = value.type === undefined ? current.type : text(value.type, 40); + if (!isNotificationChannelType(nextType)) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + if (value.enabled !== undefined && typeof value.enabled !== 'boolean') + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const name = value.name === undefined ? current.name : text(value.name, 160); + if (!name) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const stored = await this.#readSecrets(current); + const carried = value.config === undefined ? stored : {}; + const { config, secrets } = this.#channelConfig( + nextType, + value.config === undefined ? current.config : value.config, + carried, + ); + const hasSecrets = Object.keys(secrets).length > 0; + const nextReference = hasSecrets ? await this.#saveSecrets(id, secrets) : undefined; + const wroteReference = + nextReference && nextReference !== current.secretReference ? nextReference : undefined; + try { + this.#db + .prepare( + `UPDATE notification_channels + SET name=?,type=?,enabled=?,config_json=?,secret_reference=?,secret_fields=?,updated_at=? + WHERE id=?`, + ) + .run( + name, + nextType, + value.enabled === undefined ? current.enabled : value.enabled, + JSON.stringify(config), + nextReference ?? null, + Object.keys(secrets).join(','), + this.#now(), + id, + ); + } catch (error) { + if (wroteReference) await this.#options.store.delete(wroteReference).catch(() => false); + throw error; + } + if (current.secretReference && current.secretReference !== nextReference) + await this.#options.store.delete(current.secretReference).catch(() => false); + return this.getChannel(id); + } + + async deleteChannel(id: string): Promise { + const channel = this.getChannel(id); + this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id); + if (channel.secretReference) + await this.#options.store.delete(channel.secretReference).catch(() => false); + } + + async createRule(input: unknown): Promise { + const value = record(input); + const id = this.#id(); + const now = this.#now(); + const name = text(value?.name, 160); + const eventType = text(value?.eventType, 40); + const condition = + value?.condition === undefined + ? Object.freeze({ field: 'content', mode: 'all' } satisfies NotificationCondition) + : this.#condition(value.condition); + const scope = this.#scope(value?.scope); + const channelIds = Array.isArray(value?.channelIds) ? value.channelIds : []; + if (!value || !name || !eventType || !EVENT_TYPES.has(eventType) || channelIds.length === 0) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const uniqueIds = [ + ...new Set(channelIds.filter((item): item is string => typeof item === 'string')), + ]; + if (uniqueIds.length !== channelIds.length) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + // Every referenced channel must exist; getChannel throws otherwise. + for (const channelId of uniqueIds) this.getChannel(channelId); + const templates = this.#templates(value.templates); + const rateLimit = validateRateLimit(value.rateLimit); + const quietHours = validateQuietHours(value.quietHours); + this.#db + .prepare( + `INSERT INTO notification_rules + (id,name,event_type,enabled,condition_json,scope_json,channel_ids_json,templates_json, + rate_limit_json,quiet_hours_json,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + ) + .run( + id, + name, + eventType, + value.enabled === false ? 0 : 1, + JSON.stringify(condition), + JSON.stringify(scope), + JSON.stringify(uniqueIds), + JSON.stringify(templates), + JSON.stringify(rateLimit), + JSON.stringify(quietHours), + now, + now, + ); + return this.getRule(id); + } + + getRule(id: string): NotificationRule { + const row = this.#row('SELECT * FROM notification_rules WHERE id = ?', id); + if (!row) throw new CentralNotificationServiceError('RULE_NOT_FOUND'); + return this.#rule(row); + } + + updateRule(id: string, patch: unknown): NotificationRule { + const current = this.getRule(id); + const value = record(patch); + if (!value) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const name = value.name === undefined ? current.name : text(value.name, 160); + if (!name) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const enabled = value.enabled === undefined ? current.enabled : value.enabled === true; + const condition = + value.condition === undefined ? current.condition : this.#condition(value.condition); + const scope = value.scope === undefined ? current.scope : this.#scope(value.scope); + const templates = + value.templates === undefined ? current.templates : this.#templates(value.templates); + const rateLimit = + value.rateLimit === undefined ? current.rateLimit : validateRateLimit(value.rateLimit); + const quietHours = + value.quietHours === undefined ? current.quietHours : validateQuietHours(value.quietHours); + let channelIds = current.channels.map((channel) => channel.id); + if (value.channelIds !== undefined) { + if (!Array.isArray(value.channelIds) || value.channelIds.length === 0) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + channelIds = [...new Set(value.channelIds)]; + channelIds.forEach((channelId) => this.getChannel(channelId)); + } + this.#db + .prepare( + `UPDATE notification_rules + SET name=?,enabled=?,condition_json=?,scope_json=?,channel_ids_json=?,templates_json=?, + rate_limit_json=?,quiet_hours_json=?,updated_at=? + WHERE id=?`, + ) + .run( + name, + enabled ? 1 : 0, + JSON.stringify(condition), + JSON.stringify(scope), + JSON.stringify(channelIds), + JSON.stringify(templates), + JSON.stringify(rateLimit), + JSON.stringify(quietHours), + this.#now(), + id, + ); + return this.getRule(id); + } + + deleteRule(id: string): void { + this.getRule(id); + this.#db.prepare('DELETE FROM notification_rules WHERE id=?').run(id); + } + + matchRules( + eventType: NotificationEventType, + event: NotificationEventInput, + ): readonly NotificationRule[] { + return this.listRules().filter((rule) => { + if (!rule.enabled || rule.eventType !== eventType) return false; + const scope = rule.scope; + if (scope.mode === 'tags') { + const tags = event.instanceTags ?? []; + const selected = scope.tags ?? []; + const matched = + scope.match === 'all' + ? selected.every((tag) => tags.includes(tag)) + : selected.some((tag) => tags.includes(tag)); + if (!matched) return false; + } + if (scope.mode === 'devices' && !(scope.instanceIds ?? []).includes(event.instanceId ?? '')) + return false; + const field = rule.condition.field; + const actual = event.fields?.[field]; + const expected = rule.condition.value ?? ''; + if (rule.condition.mode === 'all') return true; + if (actual === undefined) return false; + if (rule.condition.mode === 'contains') return actual.includes(expected); + if (rule.condition.mode === 'equals') return actual === expected; + try { + return new RegExp(expected, 'u').test(actual); + } catch { + return false; + } + }); + } + + async testChannel( + channelId: string, + input: { readonly title?: unknown; readonly body?: unknown } = {}, + ): Promise<{ + readonly ok: boolean; + readonly status: 'success' | 'failed'; + readonly detail?: string; + }> { + const channel = this.getChannel(channelId); + const title = text(input.title, 200) ?? `${channel.name} 测试通知`; + const body = text(input.body, 2000) ?? '通知链路测试成功。'; + if (channel.secretReference && Object.keys(await this.#readSecrets(channel)).length === 0) + throw new CentralNotificationServiceError('SECRET_UNAVAILABLE'); + const config = await this.#deliveryConfig(channel); + const deliveryId = this.#id(); + const now = this.#now(); + this.#db + .prepare( + `INSERT INTO notification_deliveries + (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at) + VALUES (?,NULL,?,NULL,'test','sending',NULL,?,NULL)`, + ) + .run(deliveryId, channelId, now); + try { + const result = await this.#deliver({ + channelId, + type: channel.type, + config, + title, + body, + eventType: 'test', + occurredAt: now, + }); + this.#finishDelivery(deliveryId, result.ok ? 'success' : 'failed', now, now); + return { + ok: result.ok, + status: result.ok ? 'success' : 'failed', + ...(result.ok || !result.detail ? {} : { detail: result.detail }), + }; + } catch (error) { + const detail = text(error instanceof Error ? error.message : 'delivery failed', 500); + this.#finishDelivery(deliveryId, 'failed', now, now, detail); + return { ok: false, status: 'failed', ...(detail ? { detail } : {}) }; + } + } + + listLogs(page = 1, pageSize = 50, filter?: NotificationLogFilter): NotificationLogPage { + if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const where = this.#logWhere(filter); + const total = ( + this.#db + .prepare(`SELECT COUNT(*) AS count FROM notification_deliveries d${where.sql}`) + .get(...where.parameters) as { count: number } + ).count; + const rows = this.#db + .prepare( + `SELECT d.*, r.name AS rule_name, c.name AS channel_name + FROM notification_deliveries d + LEFT JOIN notification_rules r ON r.id = d.rule_id + LEFT JOIN notification_channels c ON c.id = d.channel_id${where.sql} + ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, + ) + .all(...where.parameters, pageSize, (page - 1) * pageSize) as Array>; + return { + items: Object.freeze(rows.map((row) => this.#log(row))), + page: { page, pageSize, total }, + }; + } + + /** + * Builds the shared WHERE clause for log reads and log deletes. The alias `d` + * matches the joined select; the delete statements re-run the same predicates + * against the bare table, so column names stay unqualified where both agree. + */ + #logWhere(filter?: NotificationLogFilter): Readonly<{ sql: string; parameters: string[] }> { + const conditions: string[] = []; + const parameters: string[] = []; + const status = text(filter?.status, 40, false); + if (filter?.status !== undefined && !status) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + if (status) { + conditions.push('d.status = ?'); + parameters.push(status); + } + const eventType = text(filter?.eventType, 40, false); + if (filter?.eventType !== undefined && !eventType) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + if (eventType) { + conditions.push('d.event_type = ?'); + parameters.push(eventType); + } + const bound = (key: 'from' | 'to', operator: string): void => { + const raw = filter?.[key]; + if (raw === undefined) return; + const parsed = Date.parse(raw); + if (!Number.isFinite(parsed)) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + conditions.push(`d.created_at ${operator} ?`); + parameters.push(new Date(parsed).toISOString()); + }; + bound('from', '>='); + bound('to', '<='); + return { + sql: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', + parameters, + }; + } + + /** + * Deletes delivery logs. Accepts either a bare `before` timestamp, kept for the + * original contract, or a filter object with status, event type and a date range. + */ + clearLogs(filter?: unknown): number { + const value: Record = + typeof filter === 'string' || typeof filter === 'number' + ? { before: filter } + : (record(filter) ?? {}); + const known = ['before', 'status', 'eventType', 'from', 'to']; + if (Object.keys(value).some((key) => !known.includes(key))) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const conditions: string[] = []; + const parameters: string[] = []; + const timestamp = (key: 'before' | 'from' | 'to', operator: string): void => { + const raw = value[key]; + if (raw === undefined) return; + const parsed = typeof raw === 'string' ? Date.parse(raw) : Number.NaN; + if (!Number.isFinite(parsed)) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + conditions.push(`created_at ${operator} ?`); + parameters.push(new Date(parsed).toISOString()); + }; + timestamp('before', '<'); + timestamp('from', '>='); + timestamp('to', '<='); + const status = text(value.status, 40, false); + if (value.status !== undefined && !status) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + if (status) { + conditions.push('status = ?'); + parameters.push(status); + } + const eventType = text(value.eventType, 40, false); + if (value.eventType !== undefined && !eventType) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + if (eventType) { + conditions.push('event_type = ?'); + parameters.push(eventType); + } + const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : ''; + return Number( + this.#db.prepare(`DELETE FROM notification_deliveries${where}`).run(...parameters).changes, + ); + } + + logCleanup(): NotificationLogCleanup { + const row = this.#db + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get(LOG_CLEANUP_SETTING_KEY) as { value_json?: string } | undefined; + if (!row?.value_json) return DEFAULT_NOTIFICATION_LOG_CLEANUP; + let parsed: unknown; + try { + parsed = JSON.parse(row.value_json); + } catch { + return DEFAULT_NOTIFICATION_LOG_CLEANUP; + } + return validateLogCleanup(parsed) ?? DEFAULT_NOTIFICATION_LOG_CLEANUP; + } + + updateLogCleanup(input: unknown): NotificationLogCleanup { + const cleanup = validateLogCleanup(input); + if (!cleanup) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const now = this.#now(); + this.#db + .prepare( + `INSERT INTO app_settings (key,value_json,created_at,updated_at) + VALUES (?,?,?,?) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, + updated_at = excluded.updated_at`, + ) + .run(LOG_CLEANUP_SETTING_KEY, JSON.stringify(cleanup), now, now); + return cleanup; + } + + /** Applies the retention policy and reports how many log rows were removed. */ + pruneLogs(input?: unknown): number { + let cleanup: NotificationLogCleanup; + if (input === undefined) { + cleanup = this.logCleanup(); + } else { + const parsed = validateLogCleanup(input); + if (!parsed) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + cleanup = parsed; + } + let removed = 0; + if (cleanup.retentionDaysEnabled) { + const cutoff = new Date( + Date.parse(this.#now()) - cleanup.retentionDays * 86_400_000, + ).toISOString(); + removed += Number( + this.#db.prepare('DELETE FROM notification_deliveries WHERE created_at < ?').run(cutoff) + .changes, + ); + } + if (cleanup.maxEntriesEnabled) { + removed += Number( + this.#db + .prepare( + `DELETE FROM notification_deliveries WHERE id NOT IN ( + SELECT id FROM notification_deliveries ORDER BY created_at DESC, id DESC LIMIT ? + )`, + ) + .run(cleanup.maxEntries).changes, + ); + } + return removed; + } + + async enqueueEvent( + eventType: NotificationEventType, + event: NotificationEventInput, + ): Promise { + const rules = this.matchRules(eventType, event); + if (rules.length === 0) return []; + const ids: string[] = []; + const instant = new Date(this.#now()); + this.#db.transaction(() => { + for (const rule of rules) { + const suppression = this.#suppressionFor(rule, instant); + if (suppression) { + this.#recordSuppression(rule, eventType, event.instanceId, suppression, instant); + continue; + } + for (const channel of rule.channels) { + if (!channel.enabled) continue; + const id = this.#id(); + const now = this.#now(); + const fields = event.fields ?? {}; + const title = this.#renderTemplate(rule.templates.title ?? rule.name, fields); + const body = this.#renderTemplate( + rule.templates.body ?? Object.values(fields).join('\n'), + fields, + ); + this.#db + .prepare( + `INSERT INTO notification_queue + (id,instance_id,event_type,rule_id,channel_id,status,attempts,max_attempts, + payload_json,available_at,created_at,updated_at) + VALUES (?,?,?,?,?,'pending',0,3,?,?,?,?)`, + ) + .run( + id, + event.instanceId ?? null, + eventType, + rule.id, + channel.id, + JSON.stringify({ title, body }), + now, + now, + now, + ); + ids.push(id); + } + } + })(); + return ids; + } + + /** + * Quiet hours win over the rate limit because a suppressed window is an explicit operator + * choice, while the limit only protects downstream providers from a burst. + */ + #suppressionFor(rule: NotificationRule, instant: Date): NotificationSuppression | undefined { + if (isWithinQuietHours(rule.quietHours, instant)) return 'quiet_hours'; + const limit = rule.rateLimit; + if (!limit.enabled) return undefined; + const cutoff = new Date(instant.getTime() - limit.windowSeconds * 1_000).toISOString(); + const recent = this.#db + .prepare( + `SELECT COUNT(*) AS count FROM notification_queue + WHERE rule_id = ? AND created_at > ?`, + ) + .get(rule.id, cutoff) as { count: number }; + return Number(recent.count) >= limit.maxMessages ? 'rate_limited' : undefined; + } + + #recordSuppression( + rule: NotificationRule, + eventType: NotificationEventType, + instanceId: string | undefined, + suppression: NotificationSuppression, + instant: Date, + ): void { + const detail = + suppression === 'quiet_hours' + ? `免打扰时段:${rule.quietHours.map((window) => `${window.start}-${window.end}`).join('、')}` + : `限流生效:${rule.rateLimit.windowSeconds} 秒内最多 ${rule.rateLimit.maxMessages} 条`; + this.#db + .prepare( + `INSERT INTO notification_deliveries + (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at) + VALUES (?,?,NULL,?,?,?,?,?,NULL)`, + ) + .run( + this.#id(), + rule.id, + instanceId ?? null, + eventType, + suppression, + detail, + instant.toISOString(), + ); + } + + listQueue( + page = 1, + pageSize = 50, + status?: NotificationQueueItem['status'], + ): NotificationQueuePage { + if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const where = status ? ' WHERE q.status = ?' : ''; + const parameters = status ? [status] : []; + const total = Number( + ( + this.#db + .prepare(`SELECT COUNT(*) AS count FROM notification_queue q${where}`) + .get(...parameters) as { count: number } + ).count, + ); + const rows = this.#db + .prepare( + `SELECT q.*, r.name AS rule_name, c.name AS channel_name + FROM notification_queue q + LEFT JOIN notification_rules r ON r.id=q.rule_id + LEFT JOIN notification_channels c ON c.id=q.channel_id${where} + ORDER BY q.created_at DESC, q.id LIMIT ? OFFSET ?`, + ) + .all(...parameters, pageSize, (page - 1) * pageSize) as Array>; + return Object.freeze({ + items: Object.freeze(rows.map((row) => this.#queueItem(row))), + page: { page, pageSize, total }, + }); + } + + queueSummary(limit = 20): NotificationQueueSummary { + const counts = ( + this.#db + .prepare('SELECT status,COUNT(*) AS count FROM notification_queue GROUP BY status') + .all() as Array<{ status: string; count: number }> + ).reduce>((accumulator, row) => { + accumulator[row.status] = Number(row.count); + return accumulator; + }, {}); + const rows = this.#db + .prepare( + `SELECT q.*, r.name AS rule_name, c.name AS channel_name + FROM notification_queue q + LEFT JOIN notification_rules r ON r.id=q.rule_id + LEFT JOIN notification_channels c ON c.id=q.channel_id + ORDER BY q.created_at DESC, q.id LIMIT ?`, + ) + .all(limit) as Array>; + const items = Object.freeze(rows.map((row) => this.#queueItem(row))); + const total = Number( + ( + this.#db.prepare('SELECT COUNT(*) AS count FROM notification_queue').get() as { + count: number; + } + ).count, + ); + return Object.freeze({ + total, + pending: Number(counts.pending ?? 0), + retrying: items.filter((item) => item.state === 'retrying').length, + sending: Number(counts.sending ?? 0), + succeeded: Number(counts.succeeded ?? 0), + failed: Number(counts.failed ?? 0), + cancelled: Number(counts.cancelled ?? 0), + recent: items, + }); + } + + logSummary(limit = 20): NotificationLogSummary { + const counts = ( + this.#db + .prepare('SELECT status,COUNT(*) AS count FROM notification_deliveries GROUP BY status') + .all() as Array<{ status: string; count: number }> + ).reduce>((accumulator, row) => { + accumulator[row.status] = Number(row.count); + return accumulator; + }, {}); + const rows = this.#db + .prepare( + `SELECT d.*, r.name AS rule_name, c.name AS channel_name + FROM notification_deliveries d + LEFT JOIN notification_rules r ON r.id = d.rule_id + LEFT JOIN notification_channels c ON c.id = d.channel_id + ORDER BY d.created_at DESC, d.id LIMIT ?`, + ) + .all(limit) as Array>; + return Object.freeze({ + total: Object.values(counts).reduce((sum, value) => sum + value, 0), + success: Number(counts.success ?? 0), + failed: Number(counts.failed ?? 0), + sending: Number(counts.sending ?? 0), + pending: Number(counts.pending ?? 0), + suppressed: Number(counts.quiet_hours ?? 0) + Number(counts.rate_limited ?? 0), + recent: Object.freeze(rows.map((row) => this.#log(row))), + }); + } + + overview(limit = 20): NotificationOverview { + const channels = this.listChannels(); + const rules = this.listRules(); + const channelTypes = new Map(); + for (const channel of channels) { + const current = channelTypes.get(channel.type); + channelTypes.set(channel.type, { + type: channel.type, + total: (current?.total ?? 0) + 1, + enabled: (current?.enabled ?? 0) + (channel.enabled ? 1 : 0), + }); + } + return Object.freeze({ + config: Object.freeze({ + channelCount: channels.length, + channelEnabled: channels.filter((channel) => channel.enabled).length, + ruleCount: rules.length, + ruleEnabled: rules.filter((rule) => rule.enabled).length, + channelTypes: Object.freeze([...channelTypes.values()]), + }), + logs: this.logSummary(limit), + queue: this.queueSummary(limit), + }); + } + + listRules(): readonly NotificationRule[] { + return Object.freeze( + ( + this.#db + .prepare('SELECT id FROM notification_rules ORDER BY created_at DESC, id') + .all() as Array<{ + id: string; + }> + ).map((row) => this.getRule(row.id)), + ); + } + + listChannels(): readonly NotificationChannel[] { + return Object.freeze( + ( + this.#db + .prepare('SELECT id FROM notification_channels ORDER BY created_at DESC, id') + .all() as Array<{ + id: string; + }> + ).map((row) => this.getChannel(row.id)), + ); + } + + async processQueue( + limit = 20, + ): Promise<{ processed: number; succeeded: number; failed: number }> { + const now = this.#now(); + const candidates = this.#db + .prepare( + `SELECT id FROM notification_queue + WHERE status='pending' AND available_at <= ? + ORDER BY available_at, created_at LIMIT ?`, + ) + .all(now, limit) as Array<{ id: string }>; + let succeeded = 0; + let failed = 0; + for (const candidate of candidates) { + const claimed = this.#db + .prepare( + `UPDATE notification_queue + SET status='sending',attempts=attempts+1,updated_at=? + WHERE id=? AND status='pending'`, + ) + .run(this.#now(), candidate.id); + if (claimed.changes !== 1) continue; + const row = this.#row( + `SELECT q.*, c.enabled AS channel_enabled,c.config_json,c.secret_reference + FROM notification_queue q + LEFT JOIN notification_channels c ON c.id=q.channel_id + WHERE q.id=?`, + candidate.id, + ); + if (!row) continue; + const finished = await this.#deliverQueueItem(row); + if (finished === 'succeeded') succeeded += 1; + else failed += 1; + } + return { processed: succeeded + failed, succeeded, failed }; + } + + retryQueueItem(id: string): void { + const result = this.#db + .prepare( + `UPDATE notification_queue + SET status='pending',available_at=?,last_error=NULL,updated_at=?, + max_attempts=MAX(max_attempts,attempts+1) + WHERE id=? AND status IN ('failed','cancelled','sending')`, + ) + .run(this.#now(), this.#now(), id); + if (result.changes !== 1) throw new CentralNotificationServiceError('QUEUE_NOT_FOUND'); + } + + deleteQueueItem(id: string): void { + if (this.#db.prepare('DELETE FROM notification_queue WHERE id=?').run(id).changes !== 1) + throw new CentralNotificationServiceError('QUEUE_NOT_FOUND'); + } + + clearQueue(status?: NotificationQueueItem['status']): number { + const allowed = new Set(['pending', 'sending', 'succeeded', 'failed', 'cancelled']); + if (status !== undefined && !allowed.has(status)) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Number( + this.#db + .prepare(`DELETE FROM notification_queue${status ? ' WHERE status=?' : ''}`) + .run(...(status ? [status] : [])).changes, + ); + } + + #row(query: string, ...parameters: readonly unknown[]): Record | undefined { + return this.#db.prepare(query).get(...parameters) as Record | undefined; + } + + #renderTemplate(template: string, fields: Readonly>): string { + return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/gu, (_, key: string) => + fields[key] === undefined ? '' : fields[key]!, + ); + } + + async #deliverQueueItem(row: Record): Promise<'succeeded' | 'failed'> { + const id = String(row.id); + const attempts = Number(row.attempts ?? 0); + const maxAttempts = Number(row.max_attempts ?? 3); + const now = this.#now(); + let payload: { title?: unknown; body?: unknown }; + try { + payload = record(JSON.parse(String(row.payload_json))) ?? {}; + } catch { + payload = {}; + } + const title = text(payload.title, 300) ?? '通知'; + const body = text(payload.body, 3000) ?? ''; + const channelId = typeof row.channel_id === 'string' ? row.channel_id : undefined; + const channelEnabled = row.channel_enabled === 1; + let detail: string | undefined; + let ok = false; + if (!channelId || !channelEnabled) { + detail = 'Notification channel is unavailable'; + } else { + const channelRow = this.#row('SELECT * FROM notification_channels WHERE id=?', channelId); + if (!channelRow) { + detail = 'Notification channel is unavailable'; + } else { + const channel = this.#channel(channelRow); + const secrets = await this.#readSecrets(channel); + if (channel.secretReference && Object.keys(secrets).length === 0) + detail = 'Notification channel secret is unavailable'; + else { + const config = { ...channel.config, ...secrets } as ChannelConfig; + const instanceId = typeof row.instance_id === 'string' ? row.instance_id : undefined; + const instanceRow = instanceId + ? (this.#db.prepare('SELECT name FROM instances WHERE id = ?').get(instanceId) as + | { name?: unknown } + | undefined) + : undefined; + const instanceName = text(instanceRow?.name, 160, false); + const deliveryId = this.#id(); + this.#db + .prepare( + `INSERT INTO notification_deliveries + (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at) + VALUES (?,?,?,?,?,'sending',NULL,?)`, + ) + .run( + deliveryId, + typeof row.rule_id === 'string' ? row.rule_id : null, + channelId, + instanceId ?? null, + String(row.event_type), + now, + ); + try { + const result = await this.#deliver({ + channelId, + type: channel.type, + config, + title, + body, + eventType: String(row.event_type), + occurredAt: now, + ...(instanceName ? { instanceName } : {}), + }); + ok = result.ok; + if (!ok) detail = result.detail ?? 'Notification endpoint rejected delivery'; + } catch (error) { + detail = text(error instanceof Error ? error.message : 'delivery failed', 500); + } + this.#db + .prepare(`UPDATE notification_deliveries SET status=?,detail=?,sent_at=? WHERE id=?`) + .run(ok ? 'success' : 'failed', detail ?? null, this.#now(), deliveryId); + } + } + } + const finishedAt = this.#now(); + const willRetry = !ok && attempts < maxAttempts; + const availableAt = new Date( + Date.parse(finishedAt) + Math.min(30_000 * 2 ** Math.max(attempts - 1, 0), 600_000), + ).toISOString(); + this.#db + .prepare( + `UPDATE notification_queue + SET status=?,last_error=?,available_at=?,delivered_at=?,updated_at=? WHERE id=?`, + ) + .run( + ok ? 'succeeded' : willRetry ? 'pending' : 'failed', + detail ?? null, + willRetry ? availableAt : finishedAt, + ok ? finishedAt : null, + finishedAt, + id, + ); + return ok ? 'succeeded' : 'failed'; + } + + #queueItem(row: Record): NotificationQueueItem { + const status = String(row.status) as NotificationQueueItem['status']; + const attempts = Number(row.attempts ?? 0); + const payload = record(JSON.parse(String(row.payload_json))) ?? {}; + const deliveredAt = + typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined; + const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined; + const channelId = + typeof row.channel_id === 'string' && row.channel_id ? row.channel_id : undefined; + const ruleName = typeof row.rule_name === 'string' && row.rule_name ? row.rule_name : undefined; + const channelName = + typeof row.channel_name === 'string' && row.channel_name ? row.channel_name : undefined; + const instanceId = + typeof row.instance_id === 'string' && row.instance_id ? row.instance_id : undefined; + return Object.freeze({ + id: String(row.id), + ...(instanceId ? { instanceId } : {}), + eventType: String(row.event_type), + ...(ruleId ? { ruleId } : {}), + ...(channelId ? { channelId } : {}), + ...(ruleName ? { ruleName } : {}), + ...(channelName ? { channelName } : {}), + status, + state: status === 'pending' && attempts > 0 ? 'retrying' : status, + attempts, + maxAttempts: Number(row.max_attempts ?? 3), + title: text(payload.title, 300) ?? '通知', + body: text(payload.body, 3000) ?? '', + availableAt: String(row.available_at), + ...(deliveredAt ? { deliveredAt } : {}), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }); + } + + #now(): string { + return (this.#options.now?.() ?? new Date()).toISOString(); + } + + #id(): string { + return this.#options.id?.() ?? randomUUID(); + } + + async #saveSecret(channelId: string, secret: string): Promise { + return this.#options.store.set( + { instanceId: channelId, purpose: 'notification-channel-secret' }, + secret, + ); + } + + /** + * Validates editor input against the shared channel table and separates the + * clear-text config from the credentials that belong in the secret store. + */ + #channelConfig( + type: NotificationChannelType, + input: unknown, + carried: Readonly>, + ): { + readonly config: NotificationChannelConfig; + readonly secrets: Record; + } { + const source = record(input); + if (!source) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + try { + const split = splitNotificationChannelConfig(type, source, carried); + return { + config: Object.freeze( + split.config as NotificationChannelConfigMap as NotificationChannelConfig, + ), + secrets: split.secrets, + }; + } catch (error) { + if (error instanceof NotificationChannelConfigError) + throw new CentralNotificationServiceError('VALIDATION_FAILED', error.message); + throw error; + } + } + + async #saveSecrets( + channelId: string, + secrets: Readonly>, + ): Promise { + return this.#saveSecret(channelId, JSON.stringify(secrets)); + } + + /** Reads stored credentials, mapping a pre-split single secret onto its field name. */ + async #readSecrets(channel: NotificationChannel): Promise> { + if (!channel.secretReference) return {}; + const raw = await this.#options.store.get(channel.secretReference); + if (!raw) return {}; + const parsed = record(JSON.parse(raw) as unknown); + if (parsed) { + const output: Record = {}; + for (const [key, value] of Object.entries(parsed)) + if (typeof value === 'string') output[key] = value; + return output; + } + const [first] = notificationChannelSecretKeys(channel.type); + return first ? { [first]: raw } : {}; + } + + /** Public config plus stored credentials, ready for a delivery attempt. */ + async #deliveryConfig(channel: NotificationChannel): Promise { + const secrets = await this.#readSecrets(channel); + return { ...channel.config, ...secrets } as ChannelConfig; + } + + #finishDelivery( + id: string, + status: NotificationDeliveryStatus, + createdAt: string, + sentAt: string, + detail?: string, + ): void { + this.#db + .prepare( + 'UPDATE notification_deliveries SET status=?, detail=?, sent_at=? WHERE id=? AND created_at=?', + ) + .run(status, detail ?? null, sentAt, id, createdAt); + } + + #condition(value: unknown): NotificationCondition { + const item = record(value); + const field = text(item?.field, 40); + const mode = text(item?.mode, 20); + if ( + !item || + !['content', 'sender', 'title', 'status'].includes(field ?? '') || + !['all', 'contains', 'equals', 'regex'].includes(mode ?? '') + ) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const conditionValue = text(item.value, 500, false); + if (mode !== 'all' && conditionValue === '') + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Object.freeze({ + field: field as NotificationCondition['field'], + mode: mode as NotificationCondition['mode'], + ...(conditionValue === '' ? {} : { value: conditionValue }), + }); + } + + #scope(value: unknown): NotificationTargetScope { + const item = record(value); + const mode = text(item?.mode, 20); + if (!item || !['all', 'tags', 'devices'].includes(mode ?? '')) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + if (mode === 'tags') { + const tags = item.tags; + if (!Array.isArray(tags) || tags.length === 0 || tags.some((tag) => !text(tag, 80))) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Object.freeze({ + mode: 'tags', + tags: Object.freeze(tags.map((tag) => text(tag, 80) as string)), + match: item.match === 'all' ? 'all' : 'any', + }); + } + if (mode === 'devices') { + const ids = item.instanceIds; + if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => !text(id, 256))) + throw new CentralNotificationServiceError('VALIDATION_FAILED'); + return Object.freeze({ + mode: 'devices', + instanceIds: Object.freeze(ids.map((id) => text(id, 256) as string)), + }); + } + return Object.freeze({ mode: 'all' }); + } + + #templates(value: unknown): NotificationTemplates { + if (value === undefined) return Object.freeze({}); + const item = record(value); + if (!item) throw new CentralNotificationServiceError('VALIDATION_FAILED'); + const title = text(item.title, 300, false); + const body = text(item.body, 3000, false); + return Object.freeze({ + ...(title ? { title } : {}), + ...(body ? { body } : {}), + }); + } + + #channel(row: Record): NotificationChannel { + const reference = typeof row.secret_reference === 'string' ? row.secret_reference : undefined; + const secretFields = + typeof row.secret_fields === 'string' && row.secret_fields !== '' + ? Object.freeze(row.secret_fields.split(',').filter((key) => key !== '')) + : reference + ? Object.freeze(notificationChannelSecretKeys(String(row.type)).slice(0, 1)) + : Object.freeze([] as string[]); + return Object.freeze({ + id: String(row.id), + name: String(row.name), + type: row.type as NotificationChannelType, + enabled: row.enabled === 1, + config: parseConfig(String(row.config_json)), + ...(reference ? { secretReference: reference } : {}), + hasSecret: Boolean(reference), + secretFields, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }); + } + + #rule(row: Record): NotificationRule { + const channelIds = parseJson(String(row.channel_ids_json), (value) => + Array.isArray(value) && value.every((id) => typeof id === 'string') + ? (value as string[]) + : undefined, + ); + return Object.freeze({ + id: String(row.id), + name: String(row.name), + eventType: row.event_type as NotificationEventType, + enabled: row.enabled === 1, + condition: parseJson(String(row.condition_json), this.#condition.bind(this)), + scope: parseJson(String(row.scope_json), this.#scope.bind(this)), + channels: Object.freeze(channelIds.map((id) => this.getChannel(id))), + templates: parseJson(String(row.templates_json), this.#templates.bind(this)), + rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit), + quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }); + } + + #log(row: Record): NotificationLog { + const status = String(row.status) as NotificationDeliveryStatus; + const detail = typeof row.detail === 'string' && row.detail ? row.detail : undefined; + const sentAt = typeof row.sent_at === 'string' && row.sent_at ? row.sent_at : undefined; + const ruleName = typeof row.rule_name === 'string' ? row.rule_name : undefined; + const instanceId = typeof row.instance_id === 'string' ? row.instance_id : undefined; + return Object.freeze({ + id: String(row.id), + ...(typeof row.channel_id === 'string' ? { channelId: row.channel_id } : {}), + eventType: String(row.event_type), + status, + createdAt: String(row.created_at), + ...(detail ? { detail } : {}), + ...(sentAt ? { sentAt } : {}), + ...(ruleName ? { ruleName } : {}), + // Rule-level suppressions have no channel, so the log shows a dash instead of a name. + channelName: + typeof row.channel_name === 'string' && row.channel_name ? row.channel_name : '—', + ...(instanceId ? { instanceId } : {}), + ...(typeof row.rule_id === 'string' ? { ruleId: row.rule_id } : {}), + }); + } +} diff --git a/apps/api/src/application/notifications/channel-delivery.test.ts b/apps/api/src/application/notifications/channel-delivery.test.ts new file mode 100644 index 0000000..e95acfa --- /dev/null +++ b/apps/api/src/application/notifications/channel-delivery.test.ts @@ -0,0 +1,305 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +import { deliverThroughChannel, type HttpRequest } from './channel-delivery.js'; + +const NOW = new Date('2026-09-03T08:00:00.000Z'); + +function recorder(response: { status: number; body: string }) { + const calls: HttpRequest[] = []; + return { + calls, + request: async (request: HttpRequest) => { + calls.push(request); + return response; + }, + }; +} + +const base = { + title: '设备离线', + body: 'SIM 卡所在设备已断开', + eventType: 'device', + occurredAt: NOW.toISOString(), + instanceName: 'lab-01', +}; + +describe('deliverThroughChannel', () => { + it('signs the generic webhook with the Hub timestamp scheme', async () => { + const sink = recorder({ status: 204, body: '' }); + const result = await deliverThroughChannel( + { + ...base, + type: 'webhook', + config: { + url: 'https://hook.example.test/notify', + http_method: 'post', + secret: 'topsecret', + headers: 'X-Tenant: acme\nbad header: skip', + }, + }, + { request: sink.request, now: () => NOW }, + ); + + expect(result).toEqual({ ok: true }); + const [request] = sink.calls; + expect(request?.url).toBe('https://hook.example.test/notify'); + expect(request?.method).toBe('POST'); + const payload = JSON.parse(String(request?.body)) as Record; + expect(payload).toEqual({ + event: 'device', + title: '设备离线', + body: 'SIM 卡所在设备已断开', + instance: 'lab-01', + occurred_at: NOW.toISOString(), + }); + const timestamp = String(request?.headers['x-hub-timestamp']); + expect(timestamp).toBe(String(NOW.getTime())); + expect(request?.headers['x-hub-signature']).toBe( + `sha256=${createHmac('sha256', 'topsecret') + .update(`${timestamp}.${String(request?.body)}`) + .digest('base64')}`, + ); + expect(request?.headers['x-tenant']).toBe('acme'); + expect(Object.keys(request?.headers ?? {})).not.toContain('bad header: skip'); + }); + + it('posts Bark options to the device-key path', async () => { + const sink = recorder({ status: 200, body: '{"code":200}' }); + const result = await deliverThroughChannel( + { + ...base, + type: 'bark', + config: { + server_url: 'https://bark.example.test/', + device_key: 'abc def', + group: 'Island', + sound: 'bell', + level: 'timeSensitive', + icon: 'https://bark.example.test/icon.png', + auto_copy: true, + save_history: false, + }, + }, + { request: sink.request, now: () => NOW }, + ); + + expect(result).toEqual({ ok: true }); + const [request] = sink.calls; + expect(request?.url).toBe('https://bark.example.test/abc%20def'); + expect(JSON.parse(String(request?.body))).toEqual({ + title: '设备离线', + body: 'SIM 卡所在设备已断开', + group: 'Island', + autocopy: 1, + save: 0, + sound: 'bell', + level: 'timeSensitive', + icon: 'https://bark.example.test/icon.png', + }); + }); + + it('treats a Bark error code in a 200 response as a failure', async () => { + const sink = recorder({ status: 200, body: '{"code":400,"message":"bad device key"}' }); + const result = await deliverThroughChannel( + { + ...base, + type: 'bark', + config: { server_url: 'https://bark.example.test', device_key: 'k' }, + }, + { request: sink.request, now: () => NOW }, + ); + expect(result).toEqual({ ok: false, detail: 'code=400 bad device key' }); + }); + + it('signs the DingTalk robot webhook', async () => { + const sink = recorder({ status: 200, body: '{"errcode":0}' }); + await deliverThroughChannel( + { + ...base, + type: 'dingtalk_robot', + config: { + webhook_url: 'https://oapi.dingtalk.com/robot/send', + access_token: 'token-1', + secret: 'SEC-abc', + at_mobiles: '13800000000, 13900000000', + at_all: true, + }, + }, + { request: sink.request, now: () => NOW }, + ); + + const [request] = sink.calls; + const url = new URL(String(request?.url)); + expect(url.searchParams.get('access_token')).toBe('token-1'); + const timestamp = String(url.searchParams.get('timestamp')); + expect(url.searchParams.get('sign')).toBe( + createHmac('sha256', 'SEC-abc').update(`${timestamp}\nSEC-abc`).digest('base64'), + ); + const body = JSON.parse(String(request?.body)) as Record; + expect(body['msgtype']).toBe('text'); + expect(body['at']).toEqual({ atMobiles: ['13800000000', '13900000000'], isAtAll: true }); + }); + + it('signs the Feishu robot webhook with the key-derived signature', async () => { + const sink = recorder({ status: 200, body: '{"code":0}' }); + await deliverThroughChannel( + { + ...base, + type: 'feishu_robot', + config: { webhook_url: 'https://open.feishu.cn/open-apis/bot/v2/hook/x', secret: 'fs' }, + }, + { request: sink.request, now: () => NOW }, + ); + const body = JSON.parse(String(sink.calls[0]?.body)) as Record; + const timestamp = String(Math.floor(NOW.getTime() / 1000)); + expect(body['timestamp']).toBe(timestamp); + expect(body['sign']).toBe(createHmac('sha256', `${timestamp}\nfs`).update('').digest('base64')); + }); + + it('exchanges credentials before sending a WeCom application message', async () => { + const calls: HttpRequest[] = []; + const result = await deliverThroughChannel( + { + ...base, + type: 'wecom_app', + config: { + corp_id: 'wx-corp', + agent_id: 1000002, + secret: 'app-secret', + to_user: 'alice|bob', + to_party: '2', + safe: true, + }, + }, + { + now: () => NOW, + request: async (request) => { + calls.push(request); + if (request.url.includes('gettoken')) + return { status: 200, body: '{"errcode":0,"access_token":"token-9"}' }; + return { status: 200, body: '{"errcode":0,"errmsg":"ok"}' }; + }, + }, + ); + + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(2); + expect(new URL(String(calls[0]?.url)).searchParams.get('corpsecret')).toBe('app-secret'); + const send = calls[1]; + expect(send?.url).toBe('https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=token-9'); + expect(JSON.parse(String(send?.body))).toEqual({ + touser: 'alice|bob', + msgtype: 'text', + agentid: 1000002, + text: { content: '设备离线\nSIM 卡所在设备已断开\n来源:lab-01\n' + NOW.toISOString() }, + safe: 1, + toparty: '2', + }); + }); + + it('reports a WeCom token failure without attempting the send', async () => { + const calls: HttpRequest[] = []; + const result = await deliverThroughChannel( + { + ...base, + type: 'wecom_app', + config: { corp_id: 'c', agent_id: 1, secret: 's' }, + }, + { + now: () => NOW, + request: async (request) => { + calls.push(request); + return { status: 200, body: '{"errcode":40013,"errmsg":"invalid corp"}' }; + }, + }, + ); + expect(calls).toHaveLength(1); + expect(result).toEqual({ ok: false, detail: 'errcode=40013 invalid corp' }); + }); + + it('uses the DingTalk v2 token header for application messages', async () => { + const calls: HttpRequest[] = []; + const result = await deliverThroughChannel( + { + ...base, + type: 'dingtalk_app', + config: { + app_key: 'key', + app_secret: 'shh', + robot_code: 'robot', + open_conversation_id: 'cid', + msg_key: 'sampleMarkdown', + }, + }, + { + now: () => NOW, + request: async (request) => { + calls.push(request); + if (request.url.endsWith('accessToken')) + return { status: 200, body: '{"accessToken":"at-1"}' }; + return { status: 200, body: '{"processQueryKey":"pk"}' }; + }, + }, + ); + expect(result).toEqual({ ok: true }); + const send = calls[1]; + expect(send?.headers['x-acs-dingtalk-access-token']).toBe('at-1'); + const body = JSON.parse(String(send?.body)) as Record; + expect(body['msgKey']).toBe('sampleMarkdown'); + expect(JSON.parse(String(body['msgParam']))).toEqual({ + title: '设备离线', + text: '**设备离线**\n\nSIM 卡所在设备已断开\n\n来源:lab-01\n\n' + NOW.toISOString(), + }); + }); + + it('routes Server 酱 v3 keys to the ft07 endpoint as a form body', async () => { + const sink = recorder({ status: 200, body: '{"code":0}' }); + await deliverThroughChannel( + { ...base, type: 'serverchan', config: { send_key: 'sct12345@9876', uid: '42' } }, + { request: sink.request, now: () => NOW }, + ); + const [request] = sink.calls; + expect(request?.url).toBe('https://push.ft07.com/send/sct12345%409876.send'); + expect(request?.headers['content-type']).toBe('application/x-www-form-urlencoded'); + expect(request?.body).toContain('title=' + encodeURIComponent('设备离线')); + expect(request?.body).toContain('uid=42'); + }); + + it('falls back to the legacy Server 酱 host for plain keys', async () => { + const sink = recorder({ status: 200, body: '' }); + await deliverThroughChannel( + { ...base, type: 'serverchan', config: { send_key: 'SCT999' } }, + { request: sink.request, now: () => NOW }, + ); + expect(sink.calls[0]?.url).toBe('https://sctapi.ftqq.com/SCT999.send'); + }); + + it('rejects non-HTTP endpoints and missing credentials with a readable detail', async () => { + await expect( + deliverThroughChannel( + { ...base, type: 'webhook', config: { url: 'file:///etc/passwd' } }, + { now: () => NOW }, + ), + ).resolves.toEqual({ ok: false, detail: '回调地址 只支持 http 或 https' }); + await expect( + deliverThroughChannel({ ...base, type: 'telegram', config: { chat_id: '1' } }, {}), + ).resolves.toEqual({ ok: false, detail: '缺少 Telegram Bot Token' }); + }); + + it('delegates email delivery to the injected SMTP sender', async () => { + const seen: string[] = []; + const result = await deliverThroughChannel( + { ...base, type: 'email', config: { smtp_host: 'smtp.example.test' } }, + { + now: () => NOW, + sendEmail: async (input) => { + seen.push(input.type); + return { ok: true }; + }, + }, + ); + expect(result).toEqual({ ok: true }); + expect(seen).toEqual(['email']); + }); +}); diff --git a/apps/api/src/application/notifications/channel-delivery.ts b/apps/api/src/application/notifications/channel-delivery.ts new file mode 100644 index 0000000..0704798 --- /dev/null +++ b/apps/api/src/application/notifications/channel-delivery.ts @@ -0,0 +1,556 @@ +import { createHmac } from 'node:crypto'; + +import { + notificationChannelSpec, + parseKeyValueField, + type NotificationChannelConfigValue, + type NotificationChannelType, +} from '@multi-simadmin/contracts'; + +export type ChannelConfig = Readonly>; + +export interface ChannelDeliveryInput { + readonly type: NotificationChannelType; + readonly config: ChannelConfig; + readonly title: string; + readonly body: string; + readonly eventType: string; + readonly occurredAt: string; + readonly instanceName?: string; +} + +export interface ChannelDeliveryResult { + readonly ok: boolean; + readonly detail?: string; +} + +export interface HttpRequest { + readonly url: string; + readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH'; + readonly headers: Readonly>; + readonly body?: string; +} + +export interface HttpResponse { + readonly status: number; + readonly body: string; +} + +export type HttpRequester = (request: HttpRequest, timeoutMs: number) => Promise; + +export interface ChannelDeliveryOptions { + readonly request?: HttpRequester | undefined; + readonly sendEmail?: ( + input: ChannelDeliveryInput, + timeoutMs: number, + ) => Promise; + readonly timeoutMs?: number | undefined; + readonly now?: (() => Date) | undefined; +} + +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 64 * 1024; + +export class ChannelDeliveryError extends Error { + constructor(message: string) { + super(message); + this.name = 'ChannelDeliveryError'; + } +} + +function stringConfig(config: ChannelConfig, key: string): string { + const value = config[key]; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return ''; +} + +function booleanConfig(config: ChannelConfig, key: string): boolean { + const value = config[key]; + return value === true || value === 'true' || value === 1; +} + +function numberConfig(config: ChannelConfig, key: string, fallback: number): number { + const value = config[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + const parsed = Number(typeof value === 'string' ? value.trim() : ''); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function trimSlash(value: string): string { + return value.replace(/\/+$/u, ''); +} + +function httpsUrl(raw: string, label: string): string { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new ChannelDeliveryError(`${label} 不是合法地址`); + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') + throw new ChannelDeliveryError(`${label} 只支持 http 或 https`); + return parsed.toString(); +} + +function withQuery(raw: string, parameters: Readonly>): string { + const url = new URL(raw); + for (const [key, value] of Object.entries(parameters)) { + if (value !== '' && !url.searchParams.has(key)) url.searchParams.set(key, value); + } + return url.toString(); +} + +function sign(algorithm: string, key: string, data: string): string { + return createHmac(algorithm, key).update(data).digest('base64'); +} + +function json(value: unknown): string { + return JSON.stringify(value); +} + +function parseBody(raw: string): Record | undefined { + if (raw.length === 0) return undefined; + try { + const value: unknown = JSON.parse(raw); + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + } catch { + return undefined; + } +} + +/** Turns a service reply into a verdict; most vendors answer 200 even on failure. */ +function verdict( + response: HttpResponse, + failurePaths: readonly string[] = ['errcode', 'code'], + successValues: Readonly> = {}, +): ChannelDeliveryResult { + if (response.status < 200 || response.status >= 300) { + return { ok: false, detail: `HTTP ${response.status} ${response.body.slice(0, 200)}`.trim() }; + } + const payload = parseBody(response.body); + if (!payload) return { ok: true }; + for (const path of failurePaths) { + const value = payload[path]; + // Most vendors use errcode 0; Bark reports success as code 200. + if (typeof value === 'number' && value !== (successValues[path] ?? 0)) { + const message = payload['msg'] ?? payload['errmsg'] ?? payload['message']; + return { + ok: false, + detail: `${path}=${value}${typeof message === 'string' ? ` ${message.slice(0, 180)}` : ''}`, + }; + } + if (typeof value === 'boolean' && value === false) { + const message = payload['error_description'] ?? payload['description']; + return { + ok: false, + detail: `${path}=false${typeof message === 'string' ? ` ${message.slice(0, 180)}` : ''}`, + }; + } + } + return { ok: true }; +} + +async function defaultRequester(request: HttpRequest, timeoutMs: number): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + ...(request.body === undefined ? {} : { body: request.body }), + signal: AbortSignal.timeout(timeoutMs), + }); + const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES); + return { status: response.status, body: text }; +} + +function markdownBody(input: ChannelDeliveryInput): string { + const source = input.instanceName ? `\n\n来源:${input.instanceName}` : ''; + return `**${input.title}**\n\n${input.body}${source}\n\n${input.occurredAt}`; +} + +function plainBody(input: ChannelDeliveryInput): string { + const source = input.instanceName ? `\n来源:${input.instanceName}` : ''; + return `${input.title}\n${input.body}${source}\n${input.occurredAt}`; +} + +function deliverWebhook(input: ChannelDeliveryInput, now: () => Date): HttpRequest | undefined { + const raw = stringConfig(input.config, 'url'); + if (!raw) throw new ChannelDeliveryError('缺少回调地址'); + const url = httpsUrl(raw, '回调地址'); + const method = (stringConfig(input.config, 'http_method') || 'POST').toUpperCase(); + const payload = json({ + event: input.eventType, + title: input.title, + body: input.body, + instance: input.instanceName ?? null, + occurred_at: input.occurredAt, + }); + const headers: Record = { 'content-type': 'application/json' }; + const secret = stringConfig(input.config, 'secret'); + if (secret) { + const timestamp = String(now().getTime()); + headers['x-hub-timestamp'] = timestamp; + headers['x-hub-signature'] = `sha256=${sign('sha256', secret, `${timestamp}.${payload}`)}`; + } + for (const [key, value] of Object.entries(parseKeyValueField(input.config['headers']))) { + if (/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/u.test(key)) headers[key.toLowerCase()] = value; + } + return { + url, + method: method === 'PUT' || method === 'PATCH' ? (method as 'PUT' | 'PATCH') : 'POST', + headers, + body: payload, + }; +} + +function deliverBark(input: ChannelDeliveryInput): HttpRequest | undefined { + const server = trimSlash(stringConfig(input.config, 'server_url') || 'https://api.day.app'); + const key = stringConfig(input.config, 'device_key'); + if (!key) throw new ChannelDeliveryError('缺少 Bark 设备 Key'); + const body: Record = { + title: input.title, + body: input.body, + group: stringConfig(input.config, 'group') || 'SimAdminHub', + autocopy: booleanConfig(input.config, 'auto_copy') ? 1 : 0, + save: booleanConfig(input.config, 'save_history') ? 1 : 0, + }; + const sound = stringConfig(input.config, 'sound'); + const level = stringConfig(input.config, 'level'); + const icon = stringConfig(input.config, 'icon'); + if (sound) body['sound'] = sound; + if (level) body['level'] = level; + if (icon) body['icon'] = icon; + return { + url: httpsUrl(`${server}/${encodeURIComponent(key)}`, 'Bark 服务器地址'), + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: json(body), + }; +} + +function deliverPushPlus(input: ChannelDeliveryInput): HttpRequest | undefined { + const token = stringConfig(input.config, 'token'); + if (!token) throw new ChannelDeliveryError('缺少 PushPlus Token'); + const body: Record = { + token, + title: input.title, + content: input.body, + template: stringConfig(input.config, 'template') || 'txt', + }; + const topic = stringConfig(input.config, 'topic'); + const channel = stringConfig(input.config, 'channel'); + const callback = stringConfig(input.config, 'callback_url'); + if (topic) body['topic'] = topic; + if (channel) body['channel'] = channel; + if (callback) body['callbackUrl'] = callback; + return { + url: 'https://www.pushplus.plus/send', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json(body), + }; +} + +function deliverWeComRobot(input: ChannelDeliveryInput): HttpRequest | undefined { + const raw = stringConfig(input.config, 'webhook_url'); + if (!raw) throw new ChannelDeliveryError('缺少企业微信机器人 Webhook 地址'); + const key = stringConfig(input.config, 'key'); + const url = withQuery(httpsUrl(raw, '企业微信 Webhook 地址'), key ? { key } : {}); + return { + url, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json({ msgtype: 'markdown', markdown: { content: markdownBody(input) } }), + }; +} + +function deliverDingTalkRobot( + input: ChannelDeliveryInput, + now: () => Date, +): HttpRequest | undefined { + const raw = stringConfig(input.config, 'webhook_url'); + if (!raw) throw new ChannelDeliveryError('缺少钉钉机器人 Webhook 地址'); + const parameters: Record = {}; + const accessToken = stringConfig(input.config, 'access_token'); + if (accessToken) parameters['access_token'] = accessToken; + const secret = stringConfig(input.config, 'secret'); + if (secret) { + const timestamp = String(now().getTime()); + parameters['timestamp'] = timestamp; + // withQuery encodes values already; pre-encoding here would double-escape the signature. + parameters['sign'] = sign('sha256', secret, `${timestamp}\n${secret}`); + } + const mobiles = stringConfig(input.config, 'at_mobiles') + .split(/[,,\s]+/u) + .map((item) => item.trim()) + .filter((item) => item !== '') + .slice(0, 50); + const body: Record = { + msgtype: 'text', + text: { content: plainBody(input) }, + at: { atMobiles: mobiles, isAtAll: booleanConfig(input.config, 'at_all') }, + }; + return { + url: withQuery(httpsUrl(raw, '钉钉 Webhook 地址'), parameters), + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json(body), + }; +} + +function deliverFeishuRobot(input: ChannelDeliveryInput, now: () => Date): HttpRequest | undefined { + const raw = stringConfig(input.config, 'webhook_url'); + if (!raw) throw new ChannelDeliveryError('缺少飞书机器人 Webhook 地址'); + const body: Record = { + msg_type: 'text', + content: { text: plainBody(input) }, + }; + const secret = stringConfig(input.config, 'secret'); + if (secret) { + const timestamp = String(Math.floor(now().getTime() / 1000)); + body['timestamp'] = timestamp; + body['sign'] = sign('sha256', `${timestamp}\n${secret}`, ''); + } + const token = stringConfig(input.config, 'token'); + const url = withQuery(httpsUrl(raw, '飞书 Webhook 地址'), token ? { token } : {}); + return { url, method: 'POST', headers: { 'content-type': 'application/json' }, body: json(body) }; +} + +function deliverTelegram(input: ChannelDeliveryInput): HttpRequest | undefined { + const base = trimSlash(stringConfig(input.config, 'api_base_url') || 'https://api.telegram.org'); + const token = stringConfig(input.config, 'bot_token'); + const chatId = stringConfig(input.config, 'chat_id'); + if (!token) throw new ChannelDeliveryError('缺少 Telegram Bot Token'); + if (!chatId) throw new ChannelDeliveryError('缺少 Telegram Chat ID'); + const body: Record = { + chat_id: chatId, + text: `${input.title}\n\n${input.body}`, + disable_web_page_preview: booleanConfig(input.config, 'disable_web_page_preview'), + }; + const parseMode = stringConfig(input.config, 'parse_mode'); + if (parseMode) body['parse_mode'] = parseMode; + return { + url: httpsUrl(`${base}/bot${encodeURIComponent(token)}/sendMessage`, 'Telegram 接口地址'), + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json(body), + }; +} + +function deliverServerChan(input: ChannelDeliveryInput): HttpRequest | undefined { + const sendKey = stringConfig(input.config, 'send_key'); + if (!sendKey) throw new ChannelDeliveryError('缺少 Server 酱 SendKey'); + const endpoint = sendKey.includes('@') + ? `https://push.ft07.com/send/${encodeURIComponent(sendKey)}.send` + : `https://sctapi.ftqq.com/${encodeURIComponent(sendKey)}.send`; + const fields: Record = { title: input.title, desp: input.body }; + for (const key of ['uid', 'channel', 'openid'] as const) { + const value = stringConfig(input.config, key); + if (value) fields[key] = value; + } + const form = new URLSearchParams(fields).toString(); + return { + url: httpsUrl(endpoint, 'Server 酱 SendKey'), + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form, + }; +} + +/** + * Builds the outbound request for single-shot channels. Channels that need a + * token exchange are handled separately because the second call depends on the + * first response. + */ +function buildRequests( + input: ChannelDeliveryInput, + now: () => Date, +): readonly { + readonly request: HttpRequest; + readonly failurePaths: readonly string[]; + readonly successValues?: Readonly>; +}[] { + switch (input.type) { + case 'webhook': + return [{ request: single(deliverWebhook(input, now)), failurePaths: [] }]; + case 'bark': + return [ + { + request: single(deliverBark(input)), + failurePaths: ['code'], + successValues: { code: 200 }, + }, + ]; + case 'pushplus': + return [{ request: single(deliverPushPlus(input)), failurePaths: ['code'] }]; + case 'wecom_robot': + return [{ request: single(deliverWeComRobot(input)), failurePaths: ['errcode'] }]; + case 'dingtalk_robot': + return [{ request: single(deliverDingTalkRobot(input, now)), failurePaths: ['errcode'] }]; + case 'feishu_robot': + return [ + { + request: single(deliverFeishuRobot(input, now)), + failurePaths: ['code', 'StatusCode'], + }, + ]; + case 'telegram': + return [{ request: single(deliverTelegram(input)), failurePaths: ['ok'] }]; + case 'serverchan': + return [{ request: single(deliverServerChan(input)), failurePaths: ['code'] }]; + default: + return []; + } +} + +/** A channel adapter either produced a request or explained why it could not. */ +function single(request: HttpRequest | undefined): HttpRequest { + if (!request) throw new ChannelDeliveryError('通道配置不完整'); + return request; +} + +async function deliverWeComApp( + input: ChannelDeliveryInput, + request: HttpRequester, + timeoutMs: number, +): Promise { + const base = trimSlash( + stringConfig(input.config, 'api_base_url') || 'https://qyapi.weixin.qq.com', + ); + const corpId = stringConfig(input.config, 'corp_id'); + const secret = stringConfig(input.config, 'secret'); + const agentId = stringConfig(input.config, 'agent_id'); + if (!corpId || !secret || !agentId) + throw new ChannelDeliveryError('企业微信应用需要企业 ID、应用 Secret 与 AgentId'); + const tokenUrl = httpsUrl( + withQuery(`${base}/cgi-bin/gettoken`, { corpid: corpId, corpsecret: secret }), + '企业微信接口地址', + ); + const tokenResponse = await request( + { url: tokenUrl, method: 'GET', headers: { accept: 'application/json' } }, + timeoutMs, + ); + const tokenPayload = parseBody(tokenResponse.body); + const accessToken = + tokenResponse.status >= 200 && + tokenResponse.status < 300 && + typeof tokenPayload?.['access_token'] === 'string' + ? tokenPayload['access_token'] + : undefined; + if (!accessToken) return verdict(tokenResponse, ['errcode']); + const sendUrl = httpsUrl( + withQuery(`${base}/cgi-bin/message/send`, { access_token: accessToken }), + '企业微信接口地址', + ); + const body: Record = { + touser: stringConfig(input.config, 'to_user') || '@all', + msgtype: 'text', + agentid: numberConfig(input.config, 'agent_id', 0), + text: { content: plainBody(input) }, + safe: booleanConfig(input.config, 'safe') ? 1 : 0, + }; + const toParty = stringConfig(input.config, 'to_party'); + const toTag = stringConfig(input.config, 'to_tag'); + if (toParty) body['toparty'] = toParty; + if (toTag) body['totag'] = toTag; + const response = await request( + { + url: sendUrl, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json(body), + }, + timeoutMs, + ); + return verdict(response, ['errcode']); +} + +async function deliverDingTalkApp( + input: ChannelDeliveryInput, + request: HttpRequester, + timeoutMs: number, +): Promise { + const appKey = stringConfig(input.config, 'app_key'); + const appSecret = stringConfig(input.config, 'app_secret'); + const robotCode = stringConfig(input.config, 'robot_code'); + const conversationId = stringConfig(input.config, 'open_conversation_id'); + if (!appKey || !appSecret || !robotCode || !conversationId) + throw new ChannelDeliveryError('钉钉应用缺少 App Key、App Secret、Robot Code 或会话 ID'); + const tokenResponse = await request( + { + url: 'https://api.dingtalk.com/v1.0/oauth2/accessToken', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: json({ appKey, appSecret }), + }, + timeoutMs, + ); + const tokenPayload = parseBody(tokenResponse.body); + const accessToken = + tokenResponse.status >= 200 && + tokenResponse.status < 300 && + typeof tokenPayload?.['accessToken'] === 'string' + ? tokenPayload['accessToken'] + : undefined; + if (!accessToken) return verdict(tokenResponse, ['code', 'errcode']); + const msgKey = stringConfig(input.config, 'msg_key') || 'sampleText'; + const msgParam = + msgKey === 'sampleMarkdown' + ? { title: input.title, text: markdownBody(input) } + : { content: plainBody(input) }; + const response = await request( + { + url: 'https://api.dingtalk.com/v1.0/robot/groupMessages/send', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-acs-dingtalk-access-token': accessToken, + }, + body: json({ + robotCode, + openConversationId: conversationId, + msgKey, + msgParam: json(msgParam), + }), + }, + timeoutMs, + ); + return verdict(response, ['code', 'errcode']); +} + +export function channelSupportsDelivery(type: NotificationChannelType | string): boolean { + return notificationChannelSpec(type) !== undefined; +} + +/** Delivers one notification through the channel adapter that matches its type. */ +export async function deliverThroughChannel( + input: ChannelDeliveryInput, + options: ChannelDeliveryOptions = {}, +): Promise { + const request = options.request ?? defaultRequester; + const now = options.now ?? (() => new Date()); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + try { + if (input.type === 'email') { + if (!options.sendEmail) return { ok: false, detail: '邮件通道未启用 SMTP 发送器' }; + return await options.sendEmail(input, timeoutMs); + } + if (input.type === 'wecom_app') return await deliverWeComApp(input, request, timeoutMs); + if (input.type === 'dingtalk_app') return await deliverDingTalkApp(input, request, timeoutMs); + const steps = buildRequests(input, now); + if (steps.length === 0) return { ok: false, detail: `不支持的通道类型:${input.type}` }; + let result: ChannelDeliveryResult = { ok: true }; + for (const step of steps) { + const response = await request(step.request, timeoutMs); + result = verdict(response, step.failurePaths, step.successValues ?? {}); + if (!result.ok) return result; + } + return result; + } catch (error) { + if (error instanceof ChannelDeliveryError) return { ok: false, detail: error.message }; + const message = error instanceof Error ? error.message : '投递失败'; + return { ok: false, detail: message.slice(0, 300) }; + } +} diff --git a/apps/api/src/application/notifications/instance-notification-service.ts b/apps/api/src/application/notifications/instance-notification-service.ts new file mode 100644 index 0000000..073570a --- /dev/null +++ b/apps/api/src/application/notifications/instance-notification-service.ts @@ -0,0 +1,713 @@ +import type { Instance } from '@multi-simadmin/contracts'; +import type { InstanceService } from '../instances/instance-service.js'; +import type { + InstanceSessionStore, + UpstreamResponse, + UpstreamSessionClientOptions, +} from '../connections/upstream-session-client.js'; + +export type FleetNotificationState = 'ready' | 'unavailable' | 'failed'; + +export interface FleetNotificationDevice { + readonly id: string; + readonly name: string; + readonly state: FleetNotificationState; +} + +export interface FleetNotificationChannelTypeSummary { + readonly type: string; + readonly total: number; + readonly enabled: number; +} + +export interface FleetNotificationConfigSummary { + readonly channelCount: number; + readonly channelEnabled: number; + readonly ruleCount: number; + readonly ruleEnabled: number; + readonly channelTypes: readonly FleetNotificationChannelTypeSummary[]; +} + +export interface FleetNotificationLogEntry { + readonly id: string; + readonly eventType: string; + readonly status: string; + readonly ruleName?: string; + readonly channelName?: string; + readonly createdAt: string; +} + +export interface FleetNotificationLogSummary { + readonly total: number; + readonly success: number; + readonly failed: number; + readonly quietHours: number; + readonly unmatched: number; + readonly noAvailableChannel: number; + readonly other: number; + readonly recent: readonly FleetNotificationLogEntry[]; +} + +export interface FleetNotificationQueueEntry { + readonly id: string; + readonly instanceId: string; + readonly instanceName: string; + readonly status: string; + readonly eventType: string; + readonly ruleName?: string; + readonly channelName?: string; + readonly createdAt: string; +} + +export interface FleetNotificationQueueSummary { + readonly total: number; + readonly pending: number; + readonly scheduled: number; + readonly retrying: number; + readonly sending: number; + readonly failed: number; + readonly recent: readonly FleetNotificationQueueEntry[]; +} + +export interface InstanceNotificationSummary { + readonly observedAt: string; + readonly deviceCount: number; + readonly readyCount: number; + readonly unavailableCount: number; + readonly devices: readonly FleetNotificationDevice[]; + readonly config: FleetNotificationConfigSummary; + readonly logs: FleetNotificationLogSummary; + readonly queue: FleetNotificationQueueSummary; +} + +export interface FleetNotificationActionFailure { + readonly instanceId: string; + readonly instanceName: string; + readonly code: NotificationServiceErrorCode; +} + +export interface FleetNotificationQueueRetryResult { + readonly requested: number; + readonly succeeded: number; + readonly failed: number; + readonly skipped: number; + readonly failures: readonly FleetNotificationActionFailure[]; +} + +export type NotificationServiceErrorCode = + | 'NOT_FOUND' + | 'VALIDATION_FAILED' + | 'UPSTREAM_FAILED' + | 'SESSION_INVALID'; + +export class NotificationServiceError extends Error { + constructor(readonly code: NotificationServiceErrorCode) { + super(code); + this.name = 'NotificationServiceError'; + } +} + +const MAX_DEVICES = 200; +const MAX_RESPONSE_BYTES = 262_144; +const MAX_LOGS = 200; +const MAX_QUEUE = 500; +const MAX_CHANNELS = 80; +const MAX_RULES = 200; +const MAX_RECENT_LOGS = 20; +const MAX_RECENT_QUEUE = 20; +const DEFAULT_LOGS_LIMIT = 50; +const DEFAULT_QUEUE_LIMIT = 100; +const LEGACY_CHANNEL_TYPES = [ + 'webhook', + 'bark', + 'pushplus', + 'wecom_app', + 'wecom_robot', + 'dingtalk_robot', + 'dingtalk_app', + 'feishu_robot', + 'telegram', + 'email', + 'serverchan', +] as const; +const LOG_STATUSES = new Set([ + 'success', + 'failed', + 'quiet_hours', + 'unmatched', + 'no_available_channel', +]); +const QUEUE_STATUSES = new Set(['pending', 'scheduled', 'retrying', 'sending', 'failed']); + +const record = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; + +const bounded = (value: unknown, maximum: number): string | undefined => + (typeof value === 'string' || typeof value === 'number') && + String(value).length <= maximum && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value)) + ? String(value) + : undefined; + +const safeCount = (value: unknown, maximum: number): number | undefined => + (typeof value === 'number' || typeof value === 'string') && + /^\d+$/u.test(String(value)) && + Number.isSafeInteger(Number(value)) && + Number(value) >= 0 && + Number(value) <= maximum + ? Number(value) + : undefined; + +function hasSuccessStatus(body: string): Record | undefined { + let root: Record | undefined; + try { + root = record(JSON.parse(body)); + } catch { + return undefined; + } + return root && (root.status === 'success' || root.status === 'ok' || root.status === 'OK') + ? root + : undefined; +} + +function parseApiResponse(response: UpstreamResponse): Record { + if ( + response.status < 200 || + response.status >= 300 || + Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES + ) + throw new NotificationServiceError('UPSTREAM_FAILED'); + const root = hasSuccessStatus(response.body); + const data = record(root?.data); + if (!data) throw new NotificationServiceError('UPSTREAM_FAILED'); + return data; +} + +function channelTypeSummary( + current: FleetNotificationChannelTypeSummary | undefined, + type: string, + enabled: boolean, +): FleetNotificationChannelTypeSummary { + return { + type, + total: (current?.total ?? 0) + 1, + enabled: (current?.enabled ?? 0) + (enabled ? 1 : 0), + }; +} + +function parseConfig(data: Record): FleetNotificationConfigSummary { + let channelCount = 0; + let channelEnabled = 0; + const channelTypes = new Map(); + const collect = (type: string, value: unknown): void => { + const item = record(value); + if (!item || typeof item.enabled !== 'boolean') return; + channelCount += 1; + if (item.enabled) channelEnabled += 1; + channelTypes.set(type, channelTypeSummary(channelTypes.get(type), type, item.enabled)); + }; + + if (Array.isArray(data.channels)) { + for (const value of data.channels.slice(0, MAX_CHANNELS)) { + const item = record(value); + const type = bounded(item?.type, 80); + if (!item || !type || typeof item.enabled !== 'boolean') continue; + channelCount += 1; + if (item.enabled) channelEnabled += 1; + channelTypes.set(type, channelTypeSummary(channelTypes.get(type), type, item.enabled)); + } + } else { + for (const type of LEGACY_CHANNEL_TYPES) collect(type, data[type]); + } + + let ruleCount = 0; + let ruleEnabled = 0; + if (Array.isArray(data.rules)) { + for (const value of data.rules.slice(0, MAX_RULES)) { + const item = record(value); + if (!item || typeof item.enabled !== 'boolean') continue; + ruleCount += 1; + if (item.enabled) ruleEnabled += 1; + } + } + + return { + channelCount, + channelEnabled, + ruleCount, + ruleEnabled, + channelTypes: Object.freeze([...channelTypes.values()]), + }; +} + +function parseLogEntry(value: unknown): FleetNotificationLogEntry | undefined { + const item = record(value); + if (!item) return undefined; + const id = bounded(item.id, 128); + const eventType = bounded(item.event_type, 80); + const status = bounded(item.status, 40); + const ruleName = bounded(item.rule_name, 160); + const channelName = bounded(item.channel_name, 160); + const createdAt = bounded(item.created_at, 64); + if (!id || !eventType || !status || !createdAt) return undefined; + return Object.freeze({ + id, + eventType, + status, + ...(ruleName === undefined ? {} : { ruleName }), + ...(channelName === undefined ? {} : { channelName }), + createdAt, + }); +} + +function parseLogs(data: Record): FleetNotificationLogSummary { + const total = safeCount(data.total, 10_000_000); + if (!Array.isArray(data.logs) || data.logs.length > MAX_LOGS || total === undefined) + throw new NotificationServiceError('UPSTREAM_FAILED'); + const recent: FleetNotificationLogEntry[] = []; + const counts = { + success: 0, + failed: 0, + quietHours: 0, + unmatched: 0, + noAvailableChannel: 0, + other: 0, + }; + for (const value of data.logs) { + const entry = parseLogEntry(value); + if (!entry) throw new NotificationServiceError('UPSTREAM_FAILED'); + if (LOG_STATUSES.has(entry.status)) { + if (entry.status === 'success') counts.success += 1; + else if (entry.status === 'failed') counts.failed += 1; + else if (entry.status === 'quiet_hours') counts.quietHours += 1; + else if (entry.status === 'unmatched') counts.unmatched += 1; + else counts.noAvailableChannel += 1; + } else { + counts.other += 1; + } + if (recent.length < MAX_RECENT_LOGS) recent.push(entry); + } + return { + total, + ...counts, + recent: Object.freeze(recent), + }; +} + +function parseQueueEntry( + value: unknown, + instanceId: string, + instanceName: string, +): FleetNotificationQueueEntry | undefined { + const item = record(value); + if (!item) return undefined; + const id = bounded(item.id, 128); + const status = bounded(item.status, 32); + const eventType = bounded(item.event_type, 80); + const ruleName = bounded(item.rule_name, 160); + const channelName = bounded(item.channel_name, 160); + const createdAt = bounded(item.created_at, 64); + if (!id || !status || !eventType || !createdAt) return undefined; + return Object.freeze({ + id, + instanceId, + instanceName, + status, + eventType, + ...(ruleName === undefined ? {} : { ruleName }), + ...(channelName === undefined ? {} : { channelName }), + createdAt, + }); +} + +function parseQueue( + data: Record, + instanceId: string, + instanceName: string, +): FleetNotificationQueueSummary { + const total = safeCount(data.total, 10_000_000); + if (!Array.isArray(data.items) || data.items.length > MAX_QUEUE || total === undefined) + throw new NotificationServiceError('UPSTREAM_FAILED'); + const recent: FleetNotificationQueueEntry[] = []; + const counts = { pending: 0, scheduled: 0, retrying: 0, sending: 0, failed: 0 }; + for (const value of data.items) { + const entry = parseQueueEntry(value, instanceId, instanceName); + if (!entry) throw new NotificationServiceError('UPSTREAM_FAILED'); + if (QUEUE_STATUSES.has(entry.status)) { + counts[entry.status as keyof typeof counts] += 1; + } + if (recent.length < MAX_RECENT_QUEUE) recent.push(entry); + } + return { + total, + ...counts, + recent: Object.freeze(recent), + }; +} + +function aggregateLogs( + value: FleetNotificationLogSummary, + accumulator: FleetNotificationLogSummary, +): FleetNotificationLogSummary { + return { + total: value.total + accumulator.total, + success: value.success + accumulator.success, + failed: value.failed + accumulator.failed, + quietHours: value.quietHours + accumulator.quietHours, + unmatched: value.unmatched + accumulator.unmatched, + noAvailableChannel: value.noAvailableChannel + accumulator.noAvailableChannel, + other: value.other + accumulator.other, + recent: Object.freeze([...value.recent, ...accumulator.recent].slice(0, MAX_RECENT_LOGS)), + }; +} + +function aggregateQueue( + value: FleetNotificationQueueSummary, + accumulator: FleetNotificationQueueSummary, +): FleetNotificationQueueSummary { + return { + total: value.total + accumulator.total, + pending: value.pending + accumulator.pending, + scheduled: value.scheduled + accumulator.scheduled, + retrying: value.retrying + accumulator.retrying, + sending: value.sending + accumulator.sending, + failed: value.failed + accumulator.failed, + recent: Object.freeze([...value.recent, ...accumulator.recent].slice(0, MAX_RECENT_QUEUE)), + }; +} + +function emptyLogSummary(): FleetNotificationLogSummary { + return { + total: 0, + success: 0, + failed: 0, + quietHours: 0, + unmatched: 0, + noAvailableChannel: 0, + other: 0, + recent: Object.freeze([]), + }; +} + +function emptyQueueSummary(): FleetNotificationQueueSummary { + return { + total: 0, + pending: 0, + scheduled: 0, + retrying: 0, + sending: 0, + failed: 0, + recent: Object.freeze([]), + }; +} + +interface InstanceReadResult { + readonly config: FleetNotificationConfigSummary; + readonly logs: FleetNotificationLogSummary; + readonly queue: FleetNotificationQueueSummary; +} + +type InstanceNotificationResult = + | { + readonly id: string; + readonly name: string; + readonly state: 'ready'; + readonly value: InstanceReadResult; + } + | { + readonly id: string; + readonly name: string; + readonly state: 'unavailable' | 'failed'; + }; + +export class InstanceNotificationService { + constructor( + private readonly options: { + readonly instances: InstanceService; + readonly sessions: InstanceSessionStore; + readonly request: UpstreamSessionClientOptions['request']; + readonly ensureSession?: ( + instanceId: string, + origin: string, + force?: boolean, + ) => Promise; + }, + ) {} + + async summarize(): Promise { + const instances = await this.listInstances(); + const results: readonly InstanceNotificationResult[] = await Promise.all( + instances.map(async (instance) => { + try { + return { + id: instance.id, + name: instance.name, + state: 'ready' as const, + value: await this.readInstance(instance), + }; + } catch (error) { + if (error instanceof NotificationServiceError && error.code === 'NOT_FOUND') { + return { + id: instance.id, + name: instance.name, + state: 'unavailable' as const, + }; + } + return { id: instance.id, name: instance.name, state: 'failed' as const }; + } + }), + ); + + const ready = results.filter( + (result): result is Extract => + result.state === 'ready', + ); + const config = ready.reduce( + (accumulator, result) => { + accumulator.channelCount += result.value.config.channelCount; + accumulator.channelEnabled += result.value.config.channelEnabled; + accumulator.ruleCount += result.value.config.ruleCount; + accumulator.ruleEnabled += result.value.config.ruleEnabled; + return accumulator; + }, + { channelCount: 0, channelEnabled: 0, ruleCount: 0, ruleEnabled: 0 }, + ); + const channelTypes = new Map(); + for (const result of ready) { + for (const typeSummary of result.value.config.channelTypes) { + const current = channelTypes.get(typeSummary.type); + channelTypes.set( + typeSummary.type, + channelTypeSummary(current, typeSummary.type, typeSummary.enabled > 0), + ); + } + } + const logs = ready.reduce( + (accumulator, result) => aggregateLogs(result.value.logs, accumulator), + emptyLogSummary(), + ); + const queue = ready.reduce( + (accumulator, result) => aggregateQueue(result.value.queue, accumulator), + emptyQueueSummary(), + ); + return { + observedAt: new Date().toISOString(), + deviceCount: instances.length, + readyCount: ready.length, + unavailableCount: results.length - ready.length, + devices: Object.freeze( + results.map((result) => + Object.freeze({ + id: result.id, + name: result.name, + state: result.state, + }), + ), + ), + config: { ...config, channelTypes: Object.freeze([...channelTypes.values()]) }, + logs, + queue, + }; + } + + private async listInstances(): Promise { + const instances: Instance[] = []; + let page = 1; + while (instances.length < MAX_DEVICES) { + const current = await this.options.instances.list({ page, pageSize: 100 }); + instances.push(...current.items.slice(0, MAX_DEVICES - instances.length)); + if (current.items.length < 100) break; + page += 1; + } + return instances; + } + + private async readInstance(instance: Instance): Promise { + const cookie = await this.ensureOwnerCookie(instance); + const [config, logs, queue] = await Promise.all([ + this.readEndpoint(instance, cookie, '/api/notifications/config').then(parseConfig), + this.readEndpoint( + instance, + cookie, + `/api/notifications/logs?limit=${DEFAULT_LOGS_LIMIT}&offset=0`, + ).then(parseLogs), + this.readEndpoint( + instance, + cookie, + `/api/notifications/queue?limit=${DEFAULT_QUEUE_LIMIT}`, + ).then((data) => parseQueue(data, instance.id, instance.name)), + ]); + return { config, logs, queue }; + } + + private async ensureOwnerCookie(instance: Instance): Promise { + const current = this.options.sessions.sessionFor(instance.id); + if (current && current.origin !== instance.origin) + throw new NotificationServiceError('SESSION_INVALID'); + if (!current && this.options.ensureSession) { + try { + await this.options.ensureSession(instance.id, instance.origin); + } catch { + // Passwordless or anonymous reads may still succeed without a session. + } + } + const session = this.options.sessions.sessionFor(instance.id); + return session && session.origin === instance.origin ? session.cookie : undefined; + } + + private async readEndpoint( + instance: Instance, + cookie: string | undefined, + path: string, + ): Promise> { + const request = (currentCookie?: string) => + this.options.request({ + url: `${instance.origin}${path}`, + method: 'GET', + headers: { + accept: 'application/json', + ...(currentCookie ? { cookie: currentCookie } : {}), + }, + }); + let response = await request(cookie); + if ([401, 403].includes(response.status) && this.options.ensureSession) { + await this.options.ensureSession(instance.id, instance.origin, true); + response = await request(this.options.sessions.sessionFor(instance.id)?.cookie); + } + return parseApiResponse(response); + } + + async retryAllQueue(): Promise { + const instances = await this.listInstances(); + const outcomes = await Promise.all( + instances.map(async (instance) => { + try { + await this.postEndpoint(instance, '/api/notifications/queue/retry-all'); + return { + instanceId: instance.id, + instanceName: instance.name, + kind: 'succeeded' as const, + }; + } catch (error) { + if (error instanceof NotificationServiceError && error.code === 'NOT_FOUND') + return { + instanceId: instance.id, + instanceName: instance.name, + kind: 'skipped' as const, + }; + return { + instanceId: instance.id, + instanceName: instance.name, + kind: 'failed' as const, + code: + error instanceof NotificationServiceError ? error.code : ('UPSTREAM_FAILED' as const), + }; + } + }), + ); + const failures = outcomes.filter( + (outcome): outcome is Extract => + outcome.kind === 'failed', + ); + return { + requested: instances.length, + succeeded: outcomes.filter((outcome) => outcome.kind === 'succeeded').length, + failed: failures.length, + skipped: outcomes.filter((outcome) => outcome.kind === 'skipped').length, + failures: Object.freeze( + failures.map((failure) => + Object.freeze({ + instanceId: failure.instanceId, + instanceName: failure.instanceName, + code: failure.code, + }), + ), + ), + }; + } + + async retryQueueItem(instanceId: string, queueId: string): Promise<{ readonly retried: true }> { + await this.queueItemAction(instanceId, queueId, 'retry'); + return { retried: true }; + } + + async deleteQueueItem(instanceId: string, queueId: string): Promise<{ readonly deleted: true }> { + await this.queueItemAction(instanceId, queueId, 'delete'); + return { deleted: true }; + } + + private async queueItemAction( + instanceId: string, + queueId: string, + action: 'retry' | 'delete', + ): Promise { + if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(queueId)) + throw new NotificationServiceError('VALIDATION_FAILED'); + const instance = await this.options.instances.get(instanceId); + if (!instance) throw new NotificationServiceError('NOT_FOUND'); + try { + const path = + action === 'retry' + ? `/api/notifications/queue/${queueId}/retry` + : `/api/notifications/queue/${queueId}`; + if (action === 'delete') { + await this.deleteEndpoint(instance, path); + return; + } + await this.postEndpoint(instance, path); + } catch (error) { + if (error instanceof NotificationServiceError) throw error; + throw new NotificationServiceError('UPSTREAM_FAILED'); + } + } + + private async postEndpoint(instance: Instance, path: string): Promise { + const cookie = await this.ensureOwnerCookie(instance); + const request = (currentCookie?: string) => + this.options.request({ + url: `${instance.origin}${path}`, + method: 'POST', + headers: { + accept: 'application/json', + ...(currentCookie ? { cookie: currentCookie } : {}), + }, + }); + let response = await request(cookie); + if ([401, 403].includes(response.status) && this.options.ensureSession) { + await this.options.ensureSession(instance.id, instance.origin, true); + response = await request(this.options.sessions.sessionFor(instance.id)?.cookie); + } + assertActionSuccess(response); + } + + private async deleteEndpoint(instance: Instance, path: string): Promise { + const cookie = await this.ensureOwnerCookie(instance); + const request = (currentCookie?: string) => + this.options.request({ + url: `${instance.origin}${path}`, + method: 'DELETE', + headers: { + accept: 'application/json', + ...(currentCookie ? { cookie: currentCookie } : {}), + }, + }); + let response = await request(cookie); + if ([401, 403].includes(response.status) && this.options.ensureSession) { + await this.options.ensureSession(instance.id, instance.origin, true); + response = await request(this.options.sessions.sessionFor(instance.id)?.cookie); + } + assertActionSuccess(response); + } +} + +function assertActionSuccess(response: UpstreamResponse): void { + if ( + response.status < 200 || + response.status >= 300 || + Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES + ) + throw new NotificationServiceError('UPSTREAM_FAILED'); + if (!hasSuccessStatus(response.body)) throw new NotificationServiceError('UPSTREAM_FAILED'); +} diff --git a/apps/api/src/application/notifications/smtp-sender.test.ts b/apps/api/src/application/notifications/smtp-sender.test.ts new file mode 100644 index 0000000..a37c44f --- /dev/null +++ b/apps/api/src/application/notifications/smtp-sender.test.ts @@ -0,0 +1,215 @@ +import { createServer, type Server, type Socket } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { ChannelDeliveryInput } from './channel-delivery.js'; +import { sendEmailNotification } from './smtp-sender.js'; + +interface FakePeer { + readonly port: number; + readonly lines: string[]; + stop(): Promise; +} + +const peers: FakePeer[] = []; + +afterEach(async () => { + for (const peer of peers.splice(0)) await peer.stop(); +}); + +/** + * Scripted SMTP peer. It answers one reply per command and stays quiet while a + * message body streams in, which is how a real server behaves after `354`. + */ +function fakeServer(script: readonly string[]): Promise { + return new Promise((resolve) => { + const lines: string[] = []; + const sockets = new Set(); + const server: Server = createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + let index = 0; + let buffered = ''; + let inData = false; + socket.write(`${script[index++] ?? '221 bye'}\r\n`); + socket.on('data', (chunk) => { + buffered += chunk.toString('utf8'); + let boundary = buffered.indexOf('\r\n'); + while (boundary >= 0) { + const line = buffered.slice(0, boundary); + buffered = buffered.slice(boundary + 2); + boundary = buffered.indexOf('\r\n'); + lines.push(line); + if (inData) { + if (line === '.') inData = false; + else continue; + } + if (line === 'DATA') inData = true; + // A silent peer is deliberate: it lets a test assert the client timeout. + const reply = script[index++]; + if (reply !== undefined) socket.write(`${reply}\r\n`); + } + }); + }); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const peer: FakePeer = { + port: typeof address === 'object' && address ? address.port : 0, + lines, + stop: () => + new Promise((done) => { + for (const socket of sockets) socket.destroy(); + server.close(() => done()); + }), + }; + peers.push(peer); + resolve(peer); + }); + }); +} + +function input(config: ChannelDeliveryInput['config']): ChannelDeliveryInput { + return { + type: 'email', + config, + title: '设备离线', + body: 'SIM 卡所在设备已断开', + eventType: 'device', + occurredAt: '2026-09-03T08:00:00.000Z', + }; +} + +// One reply per command: greeting, EHLO, AUTH LOGIN, user, password, MAIL FROM, +// two RCPT TO, DATA, the "." terminator, then QUIT. +const AUTH_SCRIPT = [ + '220 smtp ready', + '250 ehlo', + '334 ' + Buffer.from('Username:').toString('base64'), + '334 ' + Buffer.from('Password:').toString('base64'), + '235 authenticated', + '250 sender ok', + '250 recipient ok', + '250 recipient ok', + '354 send data', + '250 message accepted', + '221 bye', +]; + +const OPEN_SCRIPT = [ + '220 smtp ready', + '250 ehlo', + '250 sender ok', + '250 recipient ok', + '354 send data', + '250 message accepted', + '221 bye', +]; + +function messageLines(lines: readonly string[]): string[] { + const start = lines.indexOf('DATA'); + return start < 0 ? [] : lines.slice(start + 1); +} + +describe('sendEmailNotification', () => { + it('runs AUTH LOGIN and submits one UTF-8 message per recipient', async () => { + const peer = await fakeServer(AUTH_SCRIPT); + const result = await sendEmailNotification( + input({ + smtp_host: '127.0.0.1', + smtp_port: peer.port, + smtp_security: 'none', + username: 'notify@example.test', + password: 'p@ss', + sender_address: 'notify@example.test', + sender_name: 'SimAdmin 控制台', + receiver_addresses: 'ops@example.test, admin@example.test', + message_format: 'plain', + }), + 2_000, + ); + + expect(result).toEqual({ ok: true }); + expect(peer.lines[0]).toMatch(/^EHLO /u); + expect(peer.lines[1]).toBe('AUTH LOGIN'); + expect(peer.lines[2]).toBe(Buffer.from('notify@example.test').toString('base64')); + expect(peer.lines[3]).toBe(Buffer.from('p@ss').toString('base64')); + expect(peer.lines[4]).toBe('MAIL FROM:'); + expect(peer.lines.filter((line) => line.startsWith('RCPT TO:<'))).toEqual([ + 'RCPT TO:', + 'RCPT TO:', + ]); + + const message = messageLines(peer.lines); + expect(message.at(-1)).toBe('.'); + const headers = message.slice(0, message.indexOf('')); + expect(headers.join('\n')).toContain('Subject: =?UTF-8?B?'); + expect(headers.join('\n')).toContain('Content-Type: text/plain; charset=UTF-8'); + const payload = Buffer.from( + message.slice(message.indexOf('') + 1, message.length - 1).join(''), + 'base64', + ).toString('utf8'); + expect(payload).toBe('设备离线\n\nSIM 卡所在设备已断开'); + }); + + it('switches the MIME subtype when the channel asks for HTML', async () => { + const peer = await fakeServer(OPEN_SCRIPT); + const result = await sendEmailNotification( + input({ + smtp_host: '127.0.0.1', + smtp_port: peer.port, + smtp_security: 'none', + sender_address: 'a@example.test', + receiver_addresses: 'b@example.test', + message_format: 'html', + }), + 2_000, + ); + expect(result).toEqual({ ok: true }); + expect(messageLines(peer.lines).join('\n')).toContain('Content-Type: text/html'); + }); + + it('refuses to dial out when no receiver address is usable', async () => { + await expect( + sendEmailNotification( + input({ + smtp_host: '127.0.0.1', + smtp_port: 1, + sender_address: 'notify@example.test', + receiver_addresses: 'not-an-address', + }), + 500, + ), + ).resolves.toEqual({ ok: false, detail: '缺少有效的收件地址' }); + }); + + it('reports a rejected handshake instead of throwing', async () => { + const peer = await fakeServer(['220 smtp ready', '421 service denied']); + await expect( + sendEmailNotification( + input({ + smtp_host: '127.0.0.1', + smtp_port: peer.port, + smtp_security: 'none', + sender_address: 'a@example.test', + receiver_addresses: 'b@example.test', + }), + 2_000, + ), + ).resolves.toMatchObject({ ok: false, detail: 'SMTP 返回 421:421 service denied' }); + }); + + it('gives up with a readable detail when the server stops answering', async () => { + const peer = await fakeServer(['220 smtp ready']); + await expect( + sendEmailNotification( + input({ + smtp_host: '127.0.0.1', + smtp_port: peer.port, + smtp_security: 'none', + sender_address: 'a@example.test', + receiver_addresses: 'b@example.test', + }), + 300, + ), + ).resolves.toMatchObject({ ok: false, detail: 'SMTP 响应超时' }); + }); +}); diff --git a/apps/api/src/application/notifications/smtp-sender.ts b/apps/api/src/application/notifications/smtp-sender.ts new file mode 100644 index 0000000..24b394a --- /dev/null +++ b/apps/api/src/application/notifications/smtp-sender.ts @@ -0,0 +1,283 @@ +import { createConnection } from 'node:net'; +import { connect as connectTls, TLSSocket } from 'node:tls'; +import { hostname } from 'node:os'; + +import type { ChannelDeliveryInput, ChannelDeliveryResult } from './channel-delivery.js'; + +type Socket = ReturnType | TLSSocket; + +const CRLF = '\r\n'; +const MAX_REPLY_BYTES = 64 * 1024; + +function configText(config: ChannelDeliveryInput['config'], key: string): string { + const value = config[key]; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number') return String(value); + return ''; +} + +function configNumber( + config: ChannelDeliveryInput['config'], + key: string, + fallback: number, +): number { + const parsed = Number(configText(config, key)); + return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= 65_535 ? parsed : fallback; +} + +function configFlag(config: ChannelDeliveryInput['config'], key: string): boolean { + return config[key] === true || config[key] === 'true' || config[key] === 1; +} + +function splitAddresses(raw: string): string[] { + return raw + .split(/[,;,;\s]+/u) + .map((value) => value.trim()) + .filter((value) => /^[^\s@]+@[^\s@]+$/u.test(value)) + .slice(0, 50); +} + +function encodeAddress(name: string, address: string): string { + if (!name) return `<${address}>`; + return `${encodeWords(name)} <${address}>`; +} + +function encodeWords(value: string): string { + return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; +} + +/** Header values must not smuggle extra headers through raw newlines. */ +function foldHeader(value: string): string { + return value.replace(/[\r\n\u0000-\u0008\u000B\u000C\u000E-\u001F]/gu, ' ').slice(0, 500); +} + +function buildMessage(input: ChannelDeliveryInput, html: boolean, sender: string): string { + const headers = [ + `From: ${encodeAddress(foldHeader(configText(input.config, 'sender_name')), sender)}`, + `Subject: ${encodeWords(foldHeader(input.title))}`, + 'MIME-Version: 1.0', + `Date: ${new Date(input.occurredAt).toUTCString()}`, + `Content-Type: text/${html ? 'html' : 'plain'}; charset=UTF-8`, + 'Content-Transfer-Encoding: base64', + ]; + const body = html + ? `

${input.title}

${input.body}

` + : `${input.title}\n\n${input.body}`; + const encoded = + Buffer.from(body, 'utf8') + .toString('base64') + .match(/.{1,76}/gu) + ?.join(CRLF) ?? ''; + return `${headers.join(CRLF)}${CRLF}${CRLF}${encoded}${CRLF}`; +} + +class SmtpSession { + #socket: Socket; + #buffer = ''; + #waiters: (() => void)[] = []; + #closed = false; + #failure: Error | undefined; + + constructor(socket: Socket) { + this.#socket = socket; + this.#attach(socket); + } + + get socket(): Socket { + return this.#socket; + } + + #attach(socket: Socket): void { + socket.on('data', (chunk: Buffer) => { + this.#buffer += chunk.toString('utf8'); + if (this.#buffer.length > MAX_REPLY_BYTES) this.#fail(new Error('SMTP 响应过大')); + this.#wake(); + }); + socket.on('error', (error: Error) => this.#fail(error)); + socket.on('close', () => { + this.#closed = true; + this.#fail(new Error('SMTP 连接已关闭')); + }); + } + + /** Swaps the transport after STARTTLS without losing the reply reader. */ + replace(socket: Socket): void { + this.#socket.removeAllListeners('data'); + this.#socket.removeAllListeners('error'); + this.#socket.removeAllListeners('close'); + this.#buffer = ''; + this.#closed = false; + this.#socket = socket; + this.#attach(socket); + } + + #fail(error: Error): void { + this.#failure ??= error; + this.#wake(); + } + + #wake(): void { + const waiting = this.#waiters.splice(0); + for (const resolve of waiting) resolve(); + } + + write(line: string): void { + if (this.#failure) throw this.#failure; + this.#socket.write(line); + } + + #readReply(): { readonly code: number; readonly text: string } | undefined { + const lines = this.#buffer.split(CRLF); + const collected: string[] = []; + let consumed = 0; + for (const line of lines) { + if (!/^\d{3}[- ]/u.test(line)) break; + collected.push(line); + consumed += line.length + CRLF.length; + if (line[3] === ' ') { + this.#buffer = this.#buffer.slice(consumed); + const last = collected[collected.length - 1] ?? '0'; + return { code: Number(last.slice(0, 3)), text: collected.join('\n') }; + } + } + return undefined; + } + + async reply(timeoutMs: number): Promise<{ readonly code: number; readonly text: string }> { + const deadline = Date.now() + timeoutMs; + for (;;) { + const parsed = this.#readReply(); + if (parsed) return parsed; + if (this.#failure) throw this.#failure; + if (this.#closed) throw new Error('SMTP 连接已关闭'); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error('SMTP 响应超时'); + // The wake-up callback must resolve as well as clear the timer, or a reply + // that lands in this window would leave the session waiting forever. + await new Promise((resolve) => { + const timer = setTimeout(resolve, Math.min(remaining, 100)); + this.#waiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + } + + close(): void { + this.#socket.removeAllListeners(); + this.#socket.destroy(); + } +} + +function expect(reply: { code: number; text: string }, codes: readonly number[]): void { + if (!codes.includes(reply.code)) + throw new Error(`SMTP 返回 ${reply.code}:${reply.text.slice(0, 160)}`); +} + +function plainSocket(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ host, port }); + socket.setTimeout(timeoutMs); + socket.once('connect', () => resolve(socket)); + socket.once('timeout', () => { + socket.destroy(); + reject(new Error('SMTP 连接超时')); + }); + socket.once('error', reject); + }); +} + +function upgradeSocket(socket: Socket, host: string, rejectUnauthorized: boolean): Promise { + return new Promise((resolve, reject) => { + socket.removeAllListeners('data'); + const upgraded = connectTls({ socket, servername: host, rejectUnauthorized }, () => + resolve(upgraded), + ); + upgraded.once('error', reject); + }); +} + +/** + * Minimal SMTP client behind the email channel: implicit TLS, STARTTLS or plain, + * optional AUTH LOGIN, and one UTF-8 message per delivery. + */ +export async function sendEmailNotification( + input: ChannelDeliveryInput, + timeoutMs: number, +): Promise { + const host = configText(input.config, 'smtp_host'); + const sender = configText(input.config, 'sender_address'); + const receivers = splitAddresses(configText(input.config, 'receiver_addresses')); + if (!host) return { ok: false, detail: '缺少 SMTP 地址' }; + if (!sender) return { ok: false, detail: '缺少发件地址' }; + if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' }; + const port = configNumber(input.config, 'smtp_port', 465); + const security = configText(input.config, 'smtp_security') || 'implicit_tls'; + const rejectUnauthorized = !configFlag(input.config, 'allow_insecure_tls'); + const username = configText(input.config, 'username'); + const password = configText(input.config, 'password'); + + let session: SmtpSession | undefined; + try { + const initial: Socket = + security === 'implicit_tls' + ? await new Promise((resolve, reject) => { + const socket = connectTls({ + host, + port, + servername: host, + rejectUnauthorized, + timeout: timeoutMs, + }); + socket.once('secure', () => resolve(socket)); + socket.once('error', reject); + }) + : await plainSocket(host, port, timeoutMs); + session = new SmtpSession(initial); + expect(await session.reply(timeoutMs), [220]); + const clientName = hostname() || 'localhost'; + session.write(`EHLO ${clientName}${CRLF}`); + expect(await session.reply(timeoutMs), [250]); + + if (security === 'starttls') { + session.write(`STARTTLS${CRLF}`); + expect(await session.reply(timeoutMs), [220]); + session.replace(await upgradeSocket(session.socket, host, rejectUnauthorized)); + session.write(`EHLO ${clientName}${CRLF}`); + expect(await session.reply(timeoutMs), [250]); + } + + if (username && password) { + session.write(`AUTH LOGIN${CRLF}`); + expect(await session.reply(timeoutMs), [334]); + session.write(`${Buffer.from(username, 'utf8').toString('base64')}${CRLF}`); + expect(await session.reply(timeoutMs), [334]); + session.write(`${Buffer.from(password, 'utf8').toString('base64')}${CRLF}`); + expect(await session.reply(timeoutMs), [235]); + } + + session.write(`MAIL FROM:<${sender}>${CRLF}`); + expect(await session.reply(timeoutMs), [250]); + for (const receiver of receivers) { + session.write(`RCPT TO:<${receiver}>${CRLF}`); + expect(await session.reply(timeoutMs), [250, 251]); + } + session.write(`DATA${CRLF}`); + expect(await session.reply(timeoutMs), [354]); + const message = buildMessage( + input, + configText(input.config, 'message_format') === 'html', + sender, + ).replace(/^\./gmu, '..'); + session.write(`${message}.${CRLF}`); + expect(await session.reply(timeoutMs), [250]); + session.write(`QUIT${CRLF}`); + return { ok: true }; + } catch (error) { + const detail = error instanceof Error ? error.message : '邮件投递失败'; + return { ok: false, detail: detail.slice(0, 300) }; + } finally { + session?.close(); + } +} diff --git a/apps/api/src/application/operations/secure-operation-execution.ts b/apps/api/src/application/operations/secure-operation-execution.ts index d7782c1..34f9f84 100644 --- a/apps/api/src/application/operations/secure-operation-execution.ts +++ b/apps/api/src/application/operations/secure-operation-execution.ts @@ -37,6 +37,15 @@ const EXECUTABLE_OPERATIONS: Readonly> requestContentType: 'none', parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', }), + postBasebandRestart: Object.freeze({ + operationId: 'postBasebandRestart', + title: 'Restart Baseband', + riskLevel: 'R3', + method: 'POST', + pathTemplate: '/api/baseband/restart', + requestContentType: 'none', + parameterSchemaId: 'simadmin.58e2204.postBasebandRestart.parameters.v1', + }), postSystemReboot: Object.freeze({ operationId: 'postSystemReboot', title: 'Reboot System', @@ -56,7 +65,11 @@ export const secureOperationRegistry: SecureOperationRegistry = { }, }; -const ZERO_BODY_OPERATIONS = new Set(['postNetworkRegisterAuto', 'postServiceRestart']); +const ZERO_BODY_OPERATIONS = new Set([ + 'postNetworkRegisterAuto', + 'postServiceRestart', + 'postBasebandRestart', +]); const SYSTEM_REBOOT_OPERATION = 'postSystemReboot'; const SYSTEM_REBOOT_DELAY_SECONDS = 3; const ACTOR = 'loopback-control-plane'; @@ -437,7 +450,7 @@ export class SecureOperationExecution { return this.options.db.transaction(() => { const jobs = this.options.db .prepare( - "SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'", + "SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'", ) .all() as Array<{ id: string }>; for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED'); diff --git a/apps/api/src/application/organization/device-organization-service.test.ts b/apps/api/src/application/organization/device-organization-service.test.ts new file mode 100644 index 0000000..8f75977 --- /dev/null +++ b/apps/api/src/application/organization/device-organization-service.test.ts @@ -0,0 +1,118 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { + DeviceOrganizationError, + DeviceOrganizationService, +} from './device-organization-service.js'; + +function fixture(): { db: Database.Database; service: DeviceOrganizationService } { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + let counter = 0; + const service = new DeviceOrganizationService({ + db, + idFactory: () => `group-${++counter}`, + now: () => new Date('2026-09-03T10:00:00.000Z'), + }); + return { db, service }; +} + +function codeOf(operation: () => unknown): string { + try { + operation(); + } catch (error) { + return error instanceof DeviceOrganizationError ? error.code : 'UNEXPECTED'; + } + return 'NO_ERROR'; +} + +function insertInstance(db: Database.Database, id: string, name: string, groupId: string | null) { + db.prepare( + `INSERT INTO instances + (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at,group_id) + VALUES (?,?,?,'password',1,1,'2026-09-03T09:00:00.000Z','2026-09-03T09:00:00.000Z',?)`, + ).run(id, name, `http://${id}:8080`, groupId); +} + +describe('DeviceOrganizationService', () => { + it('creates, renames, and counts group members', () => { + const { db, service } = fixture(); + const created = service.createGroup({ name: '总部', description: '办公区' }); + expect(created).toEqual({ + id: 'group-1', + name: '总部', + description: '办公区', + deviceCount: 0, + createdAt: '2026-09-03T10:00:00.000Z', + updatedAt: '2026-09-03T10:00:00.000Z', + }); + insertInstance(db, 'i-1', 'Alpha', 'group-1'); + expect(service.listGroups()[0]?.deviceCount).toBe(1); + expect(service.updateGroup('group-1', { name: '上海总部' }).name).toBe('上海总部'); + expect(codeOf(() => service.createGroup({ name: '上海总部' }))).toBe('DUPLICATE_GROUP'); + service.deleteGroup('group-1'); + expect(service.listGroups()).toEqual([]); + expect( + ( + db.prepare('SELECT group_id FROM instances WHERE id=?').get('i-1') as { + group_id: string | null; + } + ).group_id, + ).toBeNull(); + }); + + it('reports missing groups instead of silently succeeding', () => { + const { service } = fixture(); + expect(codeOf(() => service.updateGroup('nope', { name: 'x' }))).toBe('GROUP_NOT_FOUND'); + expect(codeOf(() => service.deleteGroup('nope'))).toBe('GROUP_NOT_FOUND'); + }); + + it('synchronizes the tag registry with device tags and keeps unused entries', () => { + const { db, service } = fixture(); + insertInstance(db, 'i-1', 'Alpha', null); + db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)').run( + 'i-1', + 'office', + '2026-09-03T09:30:00.000Z', + ); + service.synchronizeTags(); + expect(service.listTags()).toEqual([ + { + tag: 'office', + color: '', + deviceCount: 1, + createdAt: '2026-09-03T09:30:00.000Z', + updatedAt: '2026-09-03T10:00:00.000Z', + }, + ]); + const tagged = service.createTag({ tag: 'spare', color: 'coral' }); + expect(tagged.color).toBe('coral'); + service.synchronizeTags(); + expect(service.listTags().map((tag) => tag.tag)).toEqual(['office', 'spare']); + }); + + it('detaches a deleted tag from every device', () => { + const { db, service } = fixture(); + insertInstance(db, 'i-1', 'Alpha', null); + db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)').run( + 'i-1', + 'lab', + '2026-09-03T09:30:00.000Z', + ); + service.synchronizeTags(); + service.deleteTag('lab'); + expect(service.listTags()).toEqual([]); + expect(db.prepare('SELECT COUNT(*) count FROM instance_tags').get()).toEqual({ count: 0 }); + expect(codeOf(() => service.deleteTag('lab'))).toBe('TAG_NOT_FOUND'); + }); + + it('rejects blank and oversized names', () => { + const { service } = fixture(); + expect(codeOf(() => service.createGroup({ name: ' ' }))).toBe('VALIDATION_FAILED'); + expect(codeOf(() => service.createGroup({ name: 'x'.repeat(101) }))).toBe('VALIDATION_FAILED'); + expect(codeOf(() => service.createTag({ tag: '' }))).toBe('VALIDATION_FAILED'); + }); +}); diff --git a/apps/api/src/application/organization/device-organization-service.ts b/apps/api/src/application/organization/device-organization-service.ts new file mode 100644 index 0000000..9539bda --- /dev/null +++ b/apps/api/src/application/organization/device-organization-service.ts @@ -0,0 +1,239 @@ +import { randomUUID } from 'node:crypto'; + +import type Database from 'better-sqlite3'; + +import type { + DeviceGroup, + DeviceGroupInput, + DeviceGroupPatch, + DeviceTag, + DeviceTagInput, + DeviceTagPatch, +} from '@multi-simadmin/contracts'; + +export type OrganizationErrorCode = + | 'VALIDATION_FAILED' + | 'GROUP_NOT_FOUND' + | 'TAG_NOT_FOUND' + | 'DUPLICATE_GROUP' + | 'DATABASE_FAILED'; + +export class DeviceOrganizationError extends Error { + constructor( + readonly code: OrganizationErrorCode, + message: string, + ) { + super(message); + this.name = 'DeviceOrganizationError'; + } +} + +interface GroupRow { + id: string; + name: string; + description: string; + device_count: number; + created_at: string; + updated_at: string; +} + +interface TagRow { + tag: string; + color: string; + device_count: number; + created_at: string; + updated_at: string; +} + +export interface DeviceOrganizationOptions { + readonly db: Database.Database; + readonly idFactory?: () => string; + readonly now?: () => Date; +} + +const MAX_GROUPS = 200; +const MAX_TAGS = 500; + +function invalid(message: string): never { + throw new DeviceOrganizationError('VALIDATION_FAILED', message); +} + +export class DeviceOrganizationService { + readonly #db: Database.Database; + readonly #id: () => string; + readonly #clock: () => Date; + + constructor(options: DeviceOrganizationOptions) { + this.#db = options.db; + this.#id = options.idFactory ?? randomUUID; + this.#clock = options.now ?? (() => new Date()); + } + + listGroups(): readonly DeviceGroup[] { + const rows = this.#db + .prepare( + `SELECT g.id,g.name,g.description,g.created_at,g.updated_at, + (SELECT COUNT(*) FROM instances i WHERE i.group_id=g.id) device_count + FROM device_groups g ORDER BY g.name COLLATE NOCASE ASC`, + ) + .all() as GroupRow[]; + return Object.freeze( + rows.map((row) => + Object.freeze({ + id: row.id, + name: row.name, + description: row.description, + deviceCount: row.device_count, + createdAt: row.created_at, + updatedAt: row.updated_at, + }), + ), + ); + } + + getGroup(groupId: string): DeviceGroup | undefined { + return this.listGroups().find((group) => group.id === groupId); + } + + createGroup(input: DeviceGroupInput): DeviceGroup { + const name = requireText(input.name, '分组名称'); + if ( + (this.#db.prepare('SELECT COUNT(*) count FROM device_groups').get() as { count: number }) + .count >= MAX_GROUPS + ) + invalid('分组数量已达上限'); + const description = optionalText(input.description ?? '', '分组说明', 500); + const now = this.#clock().toISOString(); + const id = this.#id(); + try { + this.#db + .prepare( + 'INSERT INTO device_groups (id,name,description,created_at,updated_at) VALUES (?,?,?,?,?)', + ) + .run(id, name, description, now, now); + } catch (error) { + if (isUnique(error)) throw new DeviceOrganizationError('DUPLICATE_GROUP', '分组名称已存在'); + throw new DeviceOrganizationError('DATABASE_FAILED', '无法创建分组'); + } + return this.getGroup(id)!; + } + + updateGroup(groupId: string, patch: DeviceGroupPatch): DeviceGroup { + const current = this.#db + .prepare('SELECT id,name,description FROM device_groups WHERE id=?') + .get(groupId) as { id: string; name: string; description: string } | undefined; + if (!current) throw new DeviceOrganizationError('GROUP_NOT_FOUND', '分组不存在'); + const name = patch.name === undefined ? current.name : requireText(patch.name, '分组名称'); + const description = + patch.description === undefined + ? current.description + : optionalText(patch.description, '分组说明', 500); + try { + this.#db + .prepare('UPDATE device_groups SET name=?,description=?,updated_at=? WHERE id=?') + .run(name, description, this.#clock().toISOString(), groupId); + } catch (error) { + if (isUnique(error)) throw new DeviceOrganizationError('DUPLICATE_GROUP', '分组名称已存在'); + throw new DeviceOrganizationError('DATABASE_FAILED', '无法更新分组'); + } + return this.getGroup(groupId)!; + } + + deleteGroup(groupId: string): void { + const changed = this.#db.prepare('DELETE FROM device_groups WHERE id=?').run(groupId).changes; + if (changed === 0) throw new DeviceOrganizationError('GROUP_NOT_FOUND', '分组不存在'); + } + + listTags(): readonly DeviceTag[] { + const rows = this.#db + .prepare( + `SELECT r.tag,r.color,r.created_at,r.updated_at, + (SELECT COUNT(*) FROM instance_tags t WHERE t.tag=r.tag) device_count + FROM tag_registry r ORDER BY r.tag COLLATE NOCASE ASC`, + ) + .all() as TagRow[]; + return Object.freeze( + rows.map((row) => + Object.freeze({ + tag: row.tag, + color: row.color, + deviceCount: row.device_count, + createdAt: row.created_at, + updatedAt: row.updated_at, + }), + ), + ); + } + + createTag(input: DeviceTagInput): DeviceTag { + const tag = requireText(input.tag, '标签'); + if ( + (this.#db.prepare('SELECT COUNT(*) count FROM tag_registry').get() as { count: number }) + .count >= MAX_TAGS + ) + invalid('标签数量已达上限'); + const color = optionalText(input.color ?? '', '标签颜色', 32); + const now = this.#clock().toISOString(); + try { + this.#db + .prepare('INSERT INTO tag_registry (tag,color,created_at,updated_at) VALUES (?,?,?,?)') + .run(tag, color, now, now); + } catch (error) { + if (isUnique(error)) invalid('标签已存在'); + throw new DeviceOrganizationError('DATABASE_FAILED', '无法创建标签'); + } + return this.listTags().find((entry) => entry.tag === tag)!; + } + + updateTag(tag: string, patch: DeviceTagPatch): DeviceTag { + const existing = this.#db.prepare('SELECT tag FROM tag_registry WHERE tag=?').get(tag); + if (!existing) throw new DeviceOrganizationError('TAG_NOT_FOUND', '标签不存在'); + const color = optionalText(patch.color ?? '', '标签颜色', 32); + this.#db + .prepare('UPDATE tag_registry SET color=?,updated_at=? WHERE tag=?') + .run(color, this.#clock().toISOString(), tag); + return this.listTags().find((entry) => entry.tag === tag)!; + } + + /** Removing a tag also detaches it from every device that carried it. */ + deleteTag(tag: string): void { + const changed = this.#db.prepare('DELETE FROM tag_registry WHERE tag=?').run(tag).changes; + if (changed === 0) throw new DeviceOrganizationError('TAG_NOT_FOUND', '标签不存在'); + this.#db.prepare('DELETE FROM instance_tags WHERE tag=?').run(tag); + } + + /** + * Instances may carry free-form tags that were never registered. Adopt them so the + * palette stays complete; unused registry entries are deliberate, so nothing is pruned. + */ + synchronizeTags(): void { + const now = this.#clock().toISOString(); + this.#db + .prepare( + `INSERT INTO tag_registry (tag,color,created_at,updated_at) + SELECT tag,'',MIN(created_at),? FROM instance_tags + WHERE tag NOT IN (SELECT tag FROM tag_registry) + GROUP BY tag`, + ) + .run(now); + } +} + +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') invalid(`${field}必须是字符串`); + const trimmed = value.trim(); + if (!trimmed) invalid(`${field}不能为空`); + if (trimmed.length > 100) invalid(`${field}最多 100 个字符`); + return trimmed; +} + +function optionalText(value: unknown, field: string, maximum: number): string { + if (typeof value !== 'string') invalid(`${field}必须是字符串`); + const trimmed = value.trim(); + if (trimmed.length > maximum) invalid(`${field}最多 ${maximum} 个字符`); + return trimmed; +} + +function isUnique(error: unknown): boolean { + return error instanceof Error && /UNIQUE constraint failed/i.test(error.message); +} diff --git a/apps/api/src/application/resources/instance-resource-service.test.ts b/apps/api/src/application/resources/instance-resource-service.test.ts index b9ea204..2b151e3 100644 --- a/apps/api/src/application/resources/instance-resource-service.test.ts +++ b/apps/api/src/application/resources/instance-resource-service.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest'; import { InstanceResourceService, + parseDevice, parseHealth, + parseNetwork, parseSim, parseStats, + parseSignalStrength, } from './instance-resource-service.js'; const response = (body: unknown) => ({ @@ -45,6 +48,21 @@ describe('instance resource allowlist parsing', () => { ).toMatchObject({ cpuPercent: 10, version: '1.8.7', platform: 'aarch64' }); }); + it('reads device uptime from the system block and rejects absurd values', () => { + expect( + parseStats( + response({ + data: { + system: { uptime_seconds: 93_784, boot_id: 'secret-boot' }, + }, + }), + ), + ).toEqual({ uptimeSeconds: 93_784 }); + expect(parseStats(response({ data: { uptime_seconds: 4_000_000_000 } }))).toEqual({}); + expect(parseStats(response({ data: { uptime_seconds: -5 } }))).toEqual({}); + expect(parseStats(response({ data: { uptime_seconds: '93784' } }))).toEqual({}); + }); + it('extracts the upstream SimAdmin version from the health endpoint', () => { expect( parseHealth( @@ -64,10 +82,119 @@ describe('instance resource allowlist parsing', () => { }, }), ), - ).toEqual({ phoneNumbers: ['+86 138-0000-0000'] }); + ).toEqual({ simPresent: true, phoneNumbers: ['+86 138-0000-0000'] }); + }); + + it('extracts device link state without exposing hardware identity', () => { + expect( + parseDevice(response({ data: { imei: 'secret', online: true, powered: true } })), + ).toEqual({ hardwareOnline: true, controlOnline: true }); + expect(parseDevice(response({ data: { online: false, powered: true } }))).toEqual({ + hardwareOnline: true, + controlOnline: false, + }); + expect(parseDevice(response({ data: { online: true, powered: false } }))).toEqual({ + hardwareOnline: false, + controlOnline: true, + }); + }); + + it('extracts carrier, registration, access technology and signal percentage', () => { + expect( + parseNetwork( + response({ + data: { + operator_name: 'China Mobile', + registration_status: 'registered_home', + technology_preference: 'LTE', + mcc: 460, + mnc: 0, + secret: 'drop', + }, + }), + ), + ).toEqual({ + carrier: 'China Mobile', + cellularRegistration: 'registered_home', + accessTechnology: 'LTE', + cellularOnline: true, + }); + expect(parseSignalStrength(response({ data: { strength: 80 } }))).toEqual({ + signalPercent: 80, + }); + }); + + it('marks SIM presence while retaining phone-number privacy rules', () => { + expect( + parseSim( + response({ + data: { + phone_numbers: ['+8613800000000'], + imsi: 'drop', + iccid: 'drop', + }, + }), + ), + ).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] }); }); it('probes passwordless instances without manufacturing a cookie', async () => { + const requests: Array<{ url: string; headers: Readonly> }> = []; + const service = new InstanceResourceService({ + instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never, + sessions: { sessionFor: () => undefined } as never, + request: async (request) => { + requests.push(request); + return response( + request.url.endsWith('/api/stats') + ? { data: { cpu_load: { load_percent: 12 }, memory: { used_percent: 34 } } } + : request.url.endsWith('/api/sim') + ? { data: { phone_numbers: ['13800000000'] } } + : request.url.endsWith('/api/device') + ? { data: { online: true, powered: true } } + : request.url.endsWith('/api/network') + ? { + data: { + operator_name: 'China Mobile', + registration_status: 'registered_home', + technology_preference: 'LTE', + }, + } + : request.url.endsWith('/api/network/signal-strength') + ? { data: { strength: 72 } } + : { status: 'ok', version: '2.0.1', platform: 'linux' }, + ); + }, + }); + await expect(service.get('device-1')).resolves.toEqual({ + cpuPercent: 12, + memoryPercent: 34, + phoneNumbers: ['13800000000'], + simPresent: true, + version: '2.0.1', + platform: 'linux', + hardwareOnline: true, + controlOnline: true, + carrier: 'China Mobile', + cellularRegistration: 'registered_home', + accessTechnology: 'LTE', + cellularOnline: true, + signalPercent: 72, + }); + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + '/api/stats', + '/api/sim', + '/api/health', + '/api/device', + '/api/network', + '/api/network/signal-strength', + ]); + expect(requests.map((request) => request.headers)).toEqual( + Array.from({ length: 6 }, () => ({ accept: 'application/json' })), + ); + }); + + it('keeps partial resource behavior when device telemetry endpoints are unavailable', async () => { const requests: Array<{ headers: Readonly> }> = []; const service = new InstanceResourceService({ instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never, @@ -87,13 +214,12 @@ describe('instance resource allowlist parsing', () => { cpuPercent: 12, memoryPercent: 34, phoneNumbers: ['13800000000'], + simPresent: true, version: '2.0.1', platform: 'linux', }); expect(requests.map((request) => request.headers)).toEqual([ - { accept: 'application/json' }, - { accept: 'application/json' }, - { accept: 'application/json' }, + ...Array.from({ length: 6 }, () => ({ accept: 'application/json' })), ]); }); diff --git a/apps/api/src/application/resources/instance-resource-service.ts b/apps/api/src/application/resources/instance-resource-service.ts index c3b0509..74dc45a 100644 --- a/apps/api/src/application/resources/instance-resource-service.ts +++ b/apps/api/src/application/resources/instance-resource-service.ts @@ -12,6 +12,16 @@ export interface InstanceResources { readonly phoneNumbers?: readonly string[]; readonly version?: string; readonly platform?: string; + readonly hardwareOnline?: boolean; + readonly controlOnline?: boolean; + readonly simPresent?: boolean; + readonly carrier?: string; + readonly cellularRegistration?: string; + readonly accessTechnology?: string; + readonly cellularOnline?: boolean; + readonly signalPercent?: number; + /** Device uptime in whole seconds, reported by the node stats module. */ + readonly uptimeSeconds?: number; } const MAX_BODY_BYTES = 32_768; @@ -42,6 +52,14 @@ const safeText = (value: unknown, maximum = 128): string | undefined => ? value : undefined; +const safeCode = (value: unknown, maximum = 64): string | undefined => + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + /^[A-Za-z0-9_-]+$/u.test(value) + ? value + : undefined; + export function parseHealth(response: UpstreamResponse): InstanceResources { if (response.status < 200 || response.status >= 300) return {}; if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return {}; @@ -78,26 +96,65 @@ export function parseStats(response: UpstreamResponse): InstanceResources { safeText(system?.app_version); const platform = safeText(value.platform) ?? safeText(system?.platform) ?? safeText(system?.architecture); + const uptimeSeconds = + ranged(system?.uptime_seconds, 0, 680_400_000) ?? ranged(value.uptime_seconds, 0, 680_400_000); return { ...(cpuPercent === undefined ? {} : { cpuPercent }), ...(memoryPercent === undefined ? {} : { memoryPercent }), ...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }), ...(version ? { version } : {}), ...(platform ? { platform } : {}), + ...(uptimeSeconds === undefined ? {} : { uptimeSeconds: Math.floor(uptimeSeconds) }), }; } export function parseSim(response: UpstreamResponse): InstanceResources { const value = data(response); - if (!value || !Array.isArray(value.phone_numbers)) return {}; - const phoneNumbers = value.phone_numbers.filter( + if (!value) return {}; + const numbers = Array.isArray(value.phone_numbers) ? value.phone_numbers : []; + const phoneNumbers = numbers.filter( (item): item is string => typeof item === 'string' && item.length > 0 && item.length <= 32 && /^[+0-9 ()-]+$/.test(item), ); - return phoneNumbers.length > 0 ? { phoneNumbers: [...new Set(phoneNumbers)].slice(0, 16) } : {}; + return { + simPresent: true, + ...(phoneNumbers.length > 0 ? { phoneNumbers: [...new Set(phoneNumbers)].slice(0, 16) } : {}), + }; +} + +export function parseDevice(response: UpstreamResponse): InstanceResources { + const value = data(response); + if (!value) return {}; + const hardwareOnline = typeof value.powered === 'boolean' ? value.powered : undefined; + const controlOnline = typeof value.online === 'boolean' ? value.online : undefined; + return { + ...(hardwareOnline === undefined ? {} : { hardwareOnline }), + ...(controlOnline === undefined ? {} : { controlOnline }), + }; +} + +export function parseNetwork(response: UpstreamResponse): InstanceResources { + const value = data(response); + if (!value) return {}; + const carrier = safeText(value.operator_name, 64); + const registration = safeCode(value.registration_status); + const accessTechnology = safeText(value.technology_preference, 32); + const cellularOnline = + registration === undefined ? undefined : registration.startsWith('registered'); + return { + ...(carrier ? { carrier } : {}), + ...(registration ? { cellularRegistration: registration } : {}), + ...(accessTechnology ? { accessTechnology } : {}), + ...(cellularOnline === undefined ? {} : { cellularOnline }), + }; +} + +export function parseSignalStrength(response: UpstreamResponse): InstanceResources { + const signalPercent = ranged(data(response)?.strength, 0, 100); + return signalPercent === undefined ? {} : { signalPercent }; } export class InstanceResourceService { @@ -128,7 +185,14 @@ export class InstanceResourceService { // Passwordless and temporarily unavailable credentials still permit anonymous reads. } } - const paths = ['/api/stats', '/api/sim', '/api/health'] as const; + const paths = [ + '/api/stats', + '/api/sim', + '/api/health', + '/api/device', + '/api/network', + '/api/network/signal-strength', + ] as const; const readAll = () => { const activeSession = this.options.sessions.sessionFor(instanceId); return Promise.allSettled( @@ -158,11 +222,14 @@ export class InstanceResourceService { // Return the safe partial result when re-authentication is unavailable. } } - const [stats, sim, health] = results; + const [stats, sim, health, device, network, signal] = results; return { ...(stats?.status === 'fulfilled' ? parseStats(stats.value) : {}), ...(sim?.status === 'fulfilled' ? parseSim(sim.value) : {}), ...(health?.status === 'fulfilled' ? parseHealth(health.value) : {}), + ...(device?.status === 'fulfilled' ? parseDevice(device.value) : {}), + ...(network?.status === 'fulfilled' ? parseNetwork(network.value) : {}), + ...(signal?.status === 'fulfilled' ? parseSignalStrength(signal.value) : {}), }; } } diff --git a/apps/api/src/application/status/status-snapshot-service.ts b/apps/api/src/application/status/status-snapshot-service.ts index a733add..b85886a 100644 --- a/apps/api/src/application/status/status-snapshot-service.ts +++ b/apps/api/src/application/status/status-snapshot-service.ts @@ -47,6 +47,18 @@ export interface StatusSnapshotServiceOptions { readonly maxStaleMs: number; readonly now?: () => Date; readonly idFactory?: () => string; + /** Observes every probe outcome, including ones fenced from persistence by a revision change. */ + readonly onProbe?: (result: HealthProbeResult) => void; +} + +export interface HealthProbeResult { + readonly instanceId: string; + readonly outcome: 'success' | 'stale' | 'failed' | 'unsupported'; + readonly state: HealthSnapshotState; + readonly errorCode: string | null; + readonly httpStatus: number | null; + readonly durationMs: number; + readonly observedAt: string; } export class StatusSnapshotError extends Error { @@ -193,6 +205,7 @@ export class StatusSnapshotService { readonly #maxStaleMs: number; readonly #now: () => Date; readonly #id: () => string; + readonly #onProbe: ((result: HealthProbeResult) => void) | undefined; readonly #latestGeneration = new Map(); #nextGeneration = 0; @@ -210,6 +223,7 @@ export class StatusSnapshotService { this.#maxStaleMs = options.maxStaleMs; this.#now = options.now ?? (() => new Date()); this.#id = options.idFactory ?? randomUUID; + this.#onProbe = options.onProbe; } async refreshHealth(instanceId: string): Promise { @@ -247,6 +261,7 @@ export class StatusSnapshotService { ); const { snapshot } = classified; + this.#observe(instanceId, snapshot, classified.persisted); if (this.#latestGeneration.get(instanceId) === generation) { if ( isValidOwner(instanceId, currentOwner) && @@ -260,6 +275,35 @@ export class StatusSnapshotService { return Object.freeze({ ...snapshot, payload: Object.freeze({ ...snapshot.payload }) }); } + /** A failed connection log write must never take a health probe down with it. */ + #observe(instanceId: string, snapshot: HealthSnapshot, persisted: PersistedHealthEnvelope): void { + if (!this.#onProbe) return; + const status = persisted.httpStatus; + const outcome: HealthProbeResult['outcome'] = + persisted.errorCode === 'UNSUPPORTED' + ? 'unsupported' + : snapshot.state === 'fresh' + ? 'success' + : snapshot.state === 'stale' + ? 'stale' + : status !== null && status >= 200 && status < 300 + ? 'success' + : 'failed'; + try { + this.#onProbe({ + instanceId, + outcome, + state: snapshot.state, + errorCode: persisted.errorCode, + httpStatus: status, + durationMs: persisted.durationMs, + observedAt: snapshot.observedAt, + }); + } catch { + // Diagnostics are advisory; the snapshot result stands on its own. + } + } + #previous(instanceId: string): SnapshotRow | undefined { return this.#db .prepare( diff --git a/apps/api/src/application/system/component-backup-service.test.ts b/apps/api/src/application/system/component-backup-service.test.ts new file mode 100644 index 0000000..4e0814f --- /dev/null +++ b/apps/api/src/application/system/component-backup-service.test.ts @@ -0,0 +1,228 @@ +import Database from 'better-sqlite3'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { + BACKUP_COMPONENT_KEYS, + ComponentBackupError, + ComponentBackupService, +} from './component-backup-service.js'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function database(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + return db; +} + +async function directory(): Promise { + const root = await mkdtemp(join(tmpdir(), 'component-backup-')); + roots.push(root); + return root; +} + +function seed(db: Database.Database, tag: string): void { + db.prepare('INSERT INTO device_groups (id,name,created_at,updated_at) VALUES (?,?,?,?)').run( + `group-${tag}`, + `机房 ${tag}`, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ); + db.prepare( + `INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?)`, + ).run( + `node-${tag}`, + `节点 ${tag}`, + `http://192.168.1.${tag}:8080`, + 'password', + 1, + 1, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ); + db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)').run( + `node-${tag}`, + `tag-${tag}`, + '2026-01-01T00:00:00.000Z', + ); + db.prepare('INSERT INTO tag_registry (tag,created_at,updated_at) VALUES (?,?,?)').run( + `tag-${tag}`, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ); + db.prepare( + `INSERT INTO audit_events + (id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + ).run( + `audit-${tag}`, + 'operator', + 'probe', + 'R0', + `${tag}-req`, + '[]', + 'succeeded', + 1, + '2026-01-01T00:00:00.000Z', + ); +} + +describe('ComponentBackupService', () => { + it('lists every catalog unit with a live row count', async () => { + const db = database(); + seed(db, '1'); + const service = new ComponentBackupService(db, { + version: 'test', + backupDirectory: await directory(), + }); + const catalog = service.catalog(); + expect(catalog.map((entry) => entry.key)).toEqual([...BACKUP_COMPONENT_KEYS]); + const devices = catalog.find((entry) => entry.key === 'devices'); + expect(devices?.rows).toBe(4); + expect(devices?.label).toBe('设备与分组'); + }); + + it('creates a component backup that the list endpoint can verify', async () => { + const db = database(); + seed(db, '1'); + const backupDirectory = await directory(); + const service = new ComponentBackupService(db, { version: 'test', backupDirectory }); + const created = await service.create(['devices', 'audit'], '升级前快照'); + expect(created.integrity).toBe('ok'); + expect(created.note).toBe('升级前快照'); + expect(created.components.map((entry) => entry.key)).toEqual(['devices', 'audit']); + expect((await readdir(backupDirectory)).length).toBe(1); + + const listed = await service.list(); + expect(listed).toHaveLength(1); + expect(listed[0]?.filename).toBe(created.filename); + expect(listed[0]?.integrity).toBe('ok'); + expect(listed[0]?.compatible).toBe(true); + expect(listed[0]?.components.find((entry) => entry.key === 'audit')?.rows).toBe(1); + }); + + it('merges a selected component into a fresh database and leaves the rest alone', async () => { + const source = database(); + seed(source, '1'); + seed(source, '2'); + const backupDirectory = await directory(); + const service = new ComponentBackupService(source, { version: 'test', backupDirectory }); + const created = await service.create(['devices', 'audit']); + + const target = database(); + seed(target, '9'); + const restoreService = new ComponentBackupService(target, { + version: 'test', + backupDirectory, + }); + const written = await restoreService.restore(created.filename, ['devices']); + expect(written).toEqual({ devices: 8 }); + const instances = target.prepare('SELECT id FROM instances ORDER BY id').all() as { + id: string; + }[]; + expect(instances.map((entry) => entry.id)).toEqual(['node-1', 'node-2', 'node-9']); + // The audit component was not selected, so the target keeps only its own row. + const audits = target.prepare('SELECT id FROM audit_events ORDER BY id').all() as { + id: string; + }[]; + expect(audits.map((entry) => entry.id)).toEqual(['audit-9']); + }); + + it('refuses a tampered archive', async () => { + const db = database(); + seed(db, '1'); + const backupDirectory = await directory(); + const service = new ComponentBackupService(db, { version: 'test', backupDirectory }); + const created = await service.create(['audit']); + const path = join(backupDirectory, created.filename); + const document = JSON.parse(await readFile(path, 'utf8')) as { + components: { audit: { audit_events: { rows: unknown[][] } } }; + }; + const row = document.components.audit.audit_events.rows[0]; + if (!row) throw new Error('expected a serialized audit row'); + row[3] = 'R3'; + await writeFile(path, JSON.stringify(document), 'utf8'); + + await expect(service.preview(created.filename)).rejects.toThrowError(ComponentBackupError); + await expect(service.restore(created.filename, ['audit'])).rejects.toMatchObject({ + code: 'INTEGRITY_FAILED', + }); + }); + + it('rejects unknown component keys and unsafe filenames', async () => { + const db = database(); + const service = new ComponentBackupService(db, { + version: 'test', + backupDirectory: await directory(), + }); + await expect(service.create(['nope' as never])).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + await expect(service.preview('../secret.json')).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + await expect(service.preview('multi-simadmin-components-missing.json')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('runs one scheduled backup per period and prunes to the retained count', async () => { + const db = database(); + seed(db, '1'); + const backupDirectory = await directory(); + let clock = new Date('2026-03-01T03:35:00.000Z'); + const service = new ComponentBackupService(db, { + version: 'test', + backupDirectory, + now: () => clock, + }); + service.updateAutoSettings({ + enabled: true, + components: ['devices'], + timeOfDay: '03:30', + weekday: -1, + maximumCount: 2, + }); + expect(await service.runDueAutoBackups()).toMatchObject({ automatic: true }); + expect(await service.runDueAutoBackups()).toBeNull(); + + clock = new Date('2026-03-02T03:31:00.000Z'); + expect(await service.runDueAutoBackups()).not.toBeNull(); + clock = new Date('2026-03-03T03:31:00.000Z'); + expect(await service.runDueAutoBackups()).not.toBeNull(); + const files = (await readdir(backupDirectory)).filter((name) => name.includes('-auto-')); + expect(files).toHaveLength(2); + expect(service.autoSettings()).toMatchObject({ enabled: true, maximumCount: 2, weekday: -1 }); + expect(service.autoSettings().lastRunAt).toBe('2026-03-03T03:31:00.000Z'); + }); + + it('keeps the auto settings readable when the stored payload is damaged', async () => { + const db = database(); + db.prepare( + 'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)', + ).run( + 'system.component_backup.auto', + '{not json', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ); + const service = new ComponentBackupService(db, { + version: 'test', + backupDirectory: await directory(), + }); + expect(service.autoSettings()).toMatchObject({ + enabled: false, + timeOfDay: '03:30', + components: ['devices', 'notifications', 'automation'], + }); + }); +}); diff --git a/apps/api/src/application/system/component-backup-service.ts b/apps/api/src/application/system/component-backup-service.ts new file mode 100644 index 0000000..4ceeb0c --- /dev/null +++ b/apps/api/src/application/system/component-backup-service.ts @@ -0,0 +1,604 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; + +/** + * Backup units the console can export and merge back. Each unit is closed under its own foreign + * keys, so restoring one never leaves a dangling reference behind. + */ +export type BackupComponentKey = + | 'devices' + | 'notifications' + | 'notificationRecords' + | 'automation' + | 'automationRecords' + | 'sms' + | 'settings' + | 'audit'; + +interface ComponentSpec { + readonly label: string; + readonly description: string; + /** Parent tables first so an upsert never writes a child before its owner exists. */ + readonly tables: readonly string[]; +} + +export const BACKUP_COMPONENTS: Readonly> = Object.freeze( + { + devices: { + label: '设备与分组', + description: '节点地址、分组、标签与能力快照;密钥材料不会被写入备份文件。', + tables: ['device_groups', 'tag_registry', 'instances', 'instance_tags', 'capabilities'], + }, + notifications: { + label: '通知配置', + description: '通知渠道与转发规则。', + tables: ['notification_channels', 'notification_rules'], + }, + notificationRecords: { + label: '通知记录', + description: '待办队列与历史投递结果。', + tables: ['notification_queue', 'notification_deliveries'], + }, + automation: { + label: '自动化配置', + description: '定时任务定义与执行计划。', + tables: ['scheduled_tasks'], + }, + automationRecords: { + label: '自动化记录', + description: '每次定时执行的状态与结果。', + tables: ['scheduled_runs'], + }, + sms: { + label: '短信记录', + description: '集中保存的跨设备短信正文与会话。', + tables: ['sms_messages'], + }, + settings: { + label: '系统设置', + description: '保留策略、备份计划与控制台安全配置。', + tables: ['app_settings', 'console_auth_config'], + }, + audit: { + label: '操作审计', + description: '设备操作审计账本。', + tables: ['audit_events'], + }, + } as const, +); + +export const BACKUP_COMPONENT_KEYS = Object.freeze( + Object.keys(BACKUP_COMPONENTS) as BackupComponentKey[], +); + +const FORMAT = 'multi-simadmin-component-backup'; +const FORMAT_VERSION = 1; +const AUTO_SETTINGS_KEY = 'system.component_backup.auto'; +const MAX_NOTE_LENGTH = 120; +const MAX_FILE_BYTES = 256 * 1024 * 1024; +const BACKUP_FILENAME = /^multi-simadmin-components-(?:auto-)?[A-Za-z0-9._-]{1,120}\.json$/u; + +interface TableSnapshot { + readonly columns: readonly string[]; + readonly rows: readonly (readonly unknown[])[]; +} + +interface BackupDocument { + readonly format: string; + readonly formatVersion: number; + readonly appVersion: string; + readonly createdAt: string; + readonly note: string; + readonly components: Readonly>>>; + readonly digest: string; +} + +export interface BackupComponentSummary { + readonly key: BackupComponentKey; + readonly label: string; + readonly description: string; + readonly rows: number; +} + +export interface BackupSummary { + readonly filename: string; + readonly createdAt: string; + readonly sizeBytes: number; + readonly appVersion: string; + readonly formatVersion: number; + readonly note: string; + readonly automatic: boolean; + readonly integrity: 'ok' | 'failed'; + readonly compatible: boolean; + readonly components: readonly BackupComponentSummary[]; +} + +export interface AutoBackupSettings { + readonly enabled: boolean; + readonly components: readonly BackupComponentKey[]; + readonly timeOfDay: string; + readonly weekday: number; + readonly maximumCount: number; + readonly lastRunAt: string | null; +} + +export class ComponentBackupError extends Error { + constructor( + readonly code: + | 'VALIDATION_FAILED' + | 'NOT_FOUND' + | 'INTEGRITY_FAILED' + | 'INCOMPATIBLE' + | 'TOO_LARGE', + ) { + super(code); + this.name = 'ComponentBackupError'; + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +function digestOf(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex'); +} + +function tableExists(db: SqliteDatabase, table: string): boolean { + return ( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as + | { name: string } + | undefined) !== undefined + ); +} + +function primaryKeyOf(db: SqliteDatabase, table: string): readonly string[] { + const info = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string; pk: number }[]; + return info.filter((column) => column.pk > 0).map((column) => column.name); +} + +function normalizeComponents(value: unknown): BackupComponentKey[] { + if (!Array.isArray(value) || value.length === 0) + throw new ComponentBackupError('VALIDATION_FAILED'); + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== 'string' || !(entry in BACKUP_COMPONENTS)) + throw new ComponentBackupError('VALIDATION_FAILED'); + seen.add(entry as BackupComponentKey); + } + // Emit in catalog order so the serialized document, and therefore its digest, is stable. + return BACKUP_COMPONENT_KEYS.filter((key) => seen.has(key)); +} + +function normalizeNote(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value !== 'string') throw new ComponentBackupError('VALIDATION_FAILED'); + const trimmed = value.trim(); + if (trimmed.length > MAX_NOTE_LENGTH) throw new ComponentBackupError('VALIDATION_FAILED'); + return trimmed; +} + +function normalizeTimeOfDay(value: unknown): string { + if (typeof value !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/u.test(value)) + throw new ComponentBackupError('VALIDATION_FAILED'); + return value; +} + +export interface ComponentBackupOptions { + readonly version: string; + readonly backupDirectory: string; + readonly now?: () => Date; +} + +/** Component-scoped export and merge, mirroring the Hub backup centre. */ +export class ComponentBackupService { + readonly #db: SqliteDatabase; + readonly #version: string; + readonly #directory: string; + readonly #now: () => Date; + + constructor(db: SqliteDatabase, options: ComponentBackupOptions) { + this.#db = db; + this.#version = options.version; + this.#directory = options.backupDirectory; + this.#now = options.now ?? (() => new Date()); + } + + /** Live row counts for every unit the console knows how to back up. */ + catalog(): readonly BackupComponentSummary[] { + return BACKUP_COMPONENT_KEYS.map((key) => ({ + key, + label: BACKUP_COMPONENTS[key].label, + description: BACKUP_COMPONENTS[key].description, + rows: this.#countComponent(key), + })); + } + + async create( + components: readonly BackupComponentKey[], + note: unknown = '', + automatic = false, + ): Promise { + const keys = normalizeComponents(components); + const comment = normalizeNote(note); + const createdAt = this.#now().toISOString(); + const payload: Record> = {}; + for (const key of keys) { + const tables: Record = {}; + for (const table of BACKUP_COMPONENTS[key].tables) { + const snapshot = this.#snapshot(table); + if (snapshot) tables[table] = snapshot; + } + payload[key] = tables; + } + const body = { format: FORMAT, formatVersion: FORMAT_VERSION, createdAt, components: payload }; + const document: BackupDocument = { + format: FORMAT, + formatVersion: FORMAT_VERSION, + appVersion: this.#version, + createdAt, + note: comment, + components: payload, + digest: digestOf(body), + }; + const serialized = JSON.stringify(document); + if (Buffer.byteLength(serialized, 'utf8') > MAX_FILE_BYTES) + throw new ComponentBackupError('TOO_LARGE'); + await mkdir(this.#directory, { recursive: true }); + const filename = `multi-simadmin-components-${automatic ? 'auto-' : ''}${createdAt.replaceAll(/[:.]/gu, '-')}.json`; + await writeFile(join(this.#directory, filename), serialized, { mode: 0o600 }); + return this.#summarize(filename, document, Buffer.byteLength(serialized, 'utf8')); + } + + async list(): Promise { + await mkdir(this.#directory, { recursive: true }); + const names = (await readdir(this.#directory)) + .filter((name) => BACKUP_FILENAME.test(name)) + .sort() + .reverse(); + const items: BackupSummary[] = []; + for (const filename of names) { + const parsed = await this.#read(filename).catch(() => undefined); + const details = await stat(join(this.#directory, filename)).catch(() => undefined); + if (!details?.isFile()) continue; + items.push( + parsed + ? await this.#summarize(filename, parsed.document, details.size) + : { + filename, + createdAt: details.mtime.toISOString(), + sizeBytes: details.size, + appVersion: '', + formatVersion: 0, + note: '', + automatic: filename.includes('-auto-'), + integrity: 'failed', + compatible: false, + components: [], + }, + ); + } + return items; + } + + async preview(filename: string): Promise { + const { document, sizeBytes } = await this.#read(this.#safeName(filename)); + return this.#summarize(this.#safeName(filename), document, sizeBytes); + } + + /** Merges the selected units back in; untouched components and rows stay exactly as they were. */ + async restore( + filename: string, + components: readonly BackupComponentKey[], + ): Promise>> { + const safe = this.#safeName(filename); + const keys = normalizeComponents(components); + const { document } = await this.#read(safe); + if (document.formatVersion > FORMAT_VERSION) throw new ComponentBackupError('INCOMPATIBLE'); + const written: Partial> = {}; + const restore = this.#db.transaction(() => { + for (const key of keys) { + const tables = isRecord(document.components[key]) + ? (document.components[key] as Record) + : {}; + let count = 0; + for (const table of BACKUP_COMPONENTS[key].tables) { + const snapshot = tables[table]; + if (!snapshot || !Array.isArray(snapshot.columns) || !Array.isArray(snapshot.rows)) + continue; + count += this.#merge(table, snapshot); + } + written[key] = count; + } + }); + restore(); + return written; + } + + async remove(filename: string): Promise<{ filename: string }> { + await rm(join(this.#directory, this.#safeName(filename)), { force: true }); + return { filename: this.#safeName(filename) }; + } + + autoSettings(): AutoBackupSettings { + return this.#readAuto(); + } + + updateAutoSettings(value: unknown): AutoBackupSettings { + const source = isRecord(value) ? value : {}; + if (typeof source.enabled !== 'boolean') throw new ComponentBackupError('VALIDATION_FAILED'); + const weekday = source.weekday; + if ( + typeof weekday !== 'number' || + !Number.isSafeInteger(weekday) || + weekday < -1 || + weekday > 6 + ) + throw new ComponentBackupError('VALIDATION_FAILED'); + const maximumCount = source.maximumCount; + if ( + typeof maximumCount !== 'number' || + !Number.isSafeInteger(maximumCount) || + maximumCount < 1 || + maximumCount > 500 + ) + throw new ComponentBackupError('VALIDATION_FAILED'); + const settings: AutoBackupSettings = { + enabled: source.enabled, + components: normalizeComponents(source.components), + timeOfDay: normalizeTimeOfDay(source.timeOfDay), + weekday, + maximumCount, + lastRunAt: this.#readAuto().lastRunAt, + }; + const now = this.#now().toISOString(); + this.#db + .prepare( + `INSERT INTO app_settings (key, value_json, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`, + ) + .run(AUTO_SETTINGS_KEY, JSON.stringify({ ...settings, lastRunAt: undefined }), now, now); + return settings; + } + + /** Creates the scheduled backup when the current period has not run yet. */ + async runDueAutoBackups(): Promise { + const settings = this.#readAuto(); + if (!settings.enabled || settings.components.length === 0) return null; + const dueAt = this.#mostRecentOccurrence(settings.timeOfDay, settings.weekday); + if (settings.lastRunAt && Date.parse(settings.lastRunAt) >= dueAt.getTime()) return null; + const summary = await this.create(settings.components, '定时自动备份', true); + const stamped = this.#now().toISOString(); + this.#db + .prepare( + `INSERT INTO app_settings (key, value_json, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`, + ) + .run( + AUTO_SETTINGS_KEY, + JSON.stringify({ ...settings, lastRunAt: stamped }), + stamped, + stamped, + ); + await this.#prune(settings.maximumCount); + return summary; + } + + #countComponent(key: BackupComponentKey): number { + let total = 0; + for (const table of BACKUP_COMPONENTS[key].tables) { + if (!tableExists(this.#db, table)) continue; + const row = this.#db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as + | { count: number } + | undefined; + total += Number(row?.count ?? 0); + } + return total; + } + + #snapshot(table: string): TableSnapshot | undefined { + if (!tableExists(this.#db, table)) return undefined; + const info = this.#db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + const columns = info.map((column) => column.name); + const rows = this.#db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all() as Record< + string, + unknown + >[]; + return { + columns, + rows: rows.map((row) => columns.map((column) => row[column] ?? null)), + }; + } + + /** Upserts one table; a conflict updates every non-key column instead of replacing the row. */ + #merge(table: string, snapshot: TableSnapshot): number { + if (!tableExists(this.#db, table)) return 0; + const columns = snapshot.columns.filter((column) => typeof column === 'string'); + if (columns.length === 0) return 0; + const keys = primaryKeyOf(this.#db, table); + const placeholders = columns.map(() => '?').join(', '); + const quoted = columns.map((column) => `"${column.replaceAll('"', '""')}"`).join(', '); + let sql = `INSERT INTO ${table} (${quoted}) VALUES (${placeholders})`; + const updates = columns.filter((column) => !keys.includes(column)); + if (keys.length > 0 && updates.length > 0) + sql += ` ON CONFLICT(${keys.map((key) => `"${key}"`).join(', ')}) DO UPDATE SET ${updates + .map((column) => `"${column}" = excluded."${column}"`) + .join(', ')}`; + else if (keys.length > 0) + sql += ` ON CONFLICT(${keys.map((key) => `"${key}"`).join(', ')}) DO NOTHING`; + const statement = this.#db.prepare(sql); + let written = 0; + for (const row of snapshot.rows) { + if (!Array.isArray(row) || row.length !== columns.length) continue; + statement.run(...row.map((value) => (typeof value === 'boolean' ? Number(value) : value))); + written += 1; + } + return written; + } + + #safeName(filename: string): string { + if (!BACKUP_FILENAME.test(filename) || basename(filename) !== filename) + throw new ComponentBackupError('VALIDATION_FAILED'); + return filename; + } + + async #read(filename: string): Promise<{ document: BackupDocument; sizeBytes: number }> { + const path = join(this.#directory, filename); + const content = await readFile(path).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') throw new ComponentBackupError('NOT_FOUND'); + throw error; + }); + if (content.byteLength > MAX_FILE_BYTES) throw new ComponentBackupError('TOO_LARGE'); + let parsed: unknown; + try { + parsed = JSON.parse(content.toString('utf8')); + } catch { + throw new ComponentBackupError('INTEGRITY_FAILED'); + } + const document = this.#validate(parsed); + return { document, sizeBytes: content.byteLength }; + } + + #validate(value: unknown): BackupDocument { + if (!isRecord(value) || value.format !== FORMAT) + throw new ComponentBackupError('INTEGRITY_FAILED'); + const formatVersion = Number(value.formatVersion); + const components = isRecord(value.components) ? value.components : {}; + const document: BackupDocument = { + format: FORMAT, + formatVersion: Number.isSafeInteger(formatVersion) ? formatVersion : 0, + appVersion: typeof value.appVersion === 'string' ? value.appVersion : '', + createdAt: typeof value.createdAt === 'string' ? value.createdAt : '', + note: typeof value.note === 'string' ? value.note : '', + components: components as BackupDocument['components'], + digest: typeof value.digest === 'string' ? value.digest : '', + }; + const expected = digestOf({ + format: document.format, + formatVersion: document.formatVersion, + createdAt: document.createdAt, + components: document.components, + }); + if (document.digest !== expected) throw new ComponentBackupError('INTEGRITY_FAILED'); + return document; + } + + async #summarize( + filename: string, + document: BackupDocument, + sizeBytes: number, + ): Promise { + const components: BackupComponentSummary[] = []; + for (const key of BACKUP_COMPONENT_KEYS) { + const tables = isRecord(document.components[key]) + ? (document.components[key] as Record) + : undefined; + if (!tables) continue; + let rows = 0; + for (const snapshot of Object.values(tables)) + rows += Array.isArray(snapshot?.rows) ? snapshot.rows.length : 0; + components.push({ + key, + label: BACKUP_COMPONENTS[key].label, + description: BACKUP_COMPONENTS[key].description, + rows, + }); + } + return { + filename, + createdAt: document.createdAt, + sizeBytes, + appVersion: document.appVersion, + formatVersion: document.formatVersion, + note: document.note, + automatic: filename.includes('-auto-'), + integrity: 'ok', + compatible: document.formatVersion <= FORMAT_VERSION, + components, + }; + } + + #readAuto(): AutoBackupSettings { + const row = this.#db + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get(AUTO_SETTINGS_KEY) as { value_json: string } | undefined; + let stored: Record | undefined; + try { + const parsed: unknown = row ? JSON.parse(row.value_json) : undefined; + stored = isRecord(parsed) ? parsed : undefined; + } catch { + stored = undefined; + } + const fallback = { + enabled: false, + components: ['devices', 'notifications', 'automation'] as BackupComponentKey[], + timeOfDay: '03:30', + weekday: -1, + maximumCount: 14, + }; + const source: Record = { ...fallback, ...(stored ?? {}) }; + let components: BackupComponentKey[] = fallback.components; + try { + components = normalizeComponents(source.components); + } catch { + components = fallback.components; + } + let timeOfDay = fallback.timeOfDay; + try { + timeOfDay = normalizeTimeOfDay(source.timeOfDay); + } catch { + timeOfDay = fallback.timeOfDay; + } + const weekday = + typeof source.weekday === 'number' && Number.isSafeInteger(source.weekday) + ? Math.min(6, Math.max(-1, source.weekday)) + : fallback.weekday; + const maximumCount = + typeof source.maximumCount === 'number' && Number.isSafeInteger(source.maximumCount) + ? Math.min(500, Math.max(1, source.maximumCount)) + : fallback.maximumCount; + return { + enabled: source.enabled === true, + components, + timeOfDay, + weekday, + maximumCount, + lastRunAt: typeof source.lastRunAt === 'string' ? source.lastRunAt : null, + }; + } + + #mostRecentOccurrence(timeOfDay: string, weekday: number): Date { + const [hour, minute] = timeOfDay.split(':').map((part) => Number(part)); + const now = this.#now(); + const candidate = new Date(now); + candidate.setHours(hour ?? 0, minute ?? 0, 0, 0); + if (candidate.getTime() > now.getTime()) candidate.setDate(candidate.getDate() - 1); + if (weekday >= 0) { + let guard = 0; + while (candidate.getDay() !== weekday && guard < 8) { + candidate.setDate(candidate.getDate() - 1); + guard += 1; + } + } + return candidate; + } + + /** Keeps the newest `limit` scheduled backups; a limit of zero leaves manual files alone. */ + async #prune(limit: number): Promise { + if (limit <= 0) return 0; + const names = (await readdir(this.#directory)) + .filter((name) => name.startsWith('multi-simadmin-components-auto-')) + .sort() + .reverse(); + let removed = 0; + for (const filename of names.slice(limit)) { + await rm(join(this.#directory, filename), { force: true }); + removed += 1; + } + return removed; + } +} diff --git a/apps/api/src/application/system/connection-log-service.test.ts b/apps/api/src/application/system/connection-log-service.test.ts new file mode 100644 index 0000000..6450758 --- /dev/null +++ b/apps/api/src/application/system/connection-log-service.test.ts @@ -0,0 +1,179 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { ConnectionLogService } from './connection-log-service.js'; + +const databases: Database.Database[] = []; + +function fixture(maximumRows?: number) { + 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'), + ('node-b','Node B','http://node-b.local',1,1,'2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`, + ).run(); + let counter = 0; + const service = new ConnectionLogService({ + db, + idFactory: () => `log-${++counter}`, + ...(maximumRows === undefined ? {} : { maximumRows }), + }); + return { db, service }; +} + +afterEach(() => { + for (const db of databases.splice(0)) db.close(); +}); + +describe('ConnectionLogService', () => { + it('records probes and lists them newest first with paging metadata', () => { + const { service } = fixture(); + service.record({ + instanceId: 'node-a', + outcome: 'success', + state: 'fresh', + errorCode: null, + httpStatus: 200, + durationMs: 12, + observedAt: '2026-09-02T00:00:00.000Z', + }); + service.record({ + instanceId: 'node-b', + outcome: 'failed', + state: 'unknown', + errorCode: 'ECONNREFUSED', + httpStatus: null, + durationMs: 30, + observedAt: '2026-09-03T00:00:00.000Z', + }); + + const page = service.list({ pageSize: 1 }); + expect(page.page).toEqual({ page: 1, pageSize: 1, total: 2 }); + expect(page.items.map((item) => item.instanceId)).toEqual(['node-b']); + expect(page.items[0]).toMatchObject({ errorCode: 'ECONNREFUSED', outcome: 'failed' }); + }); + + it('filters by outcome and free-text search', () => { + const { service } = fixture(); + service.record({ + instanceId: 'node-a', + outcome: 'stale', + state: 'stale', + errorCode: 'AUTH_REQUIRED', + httpStatus: 401, + durationMs: 8, + observedAt: '2026-09-02T00:00:00.000Z', + }); + service.record({ + instanceId: 'node-a', + outcome: 'success', + state: 'fresh', + errorCode: null, + httpStatus: 200, + durationMs: 9, + observedAt: '2026-09-02T01:00:00.000Z', + }); + expect(service.list({ outcome: 'stale' }).page.total).toBe(1); + expect(service.list({ search: 'auth' }).page.total).toBe(1); + expect(service.list({ search: 'node-a', outcome: 'success' }).page.total).toBe(1); + }); + + it('summarises availability per instance', () => { + const { service } = fixture(); + for (const [index, outcome] of ( + ['success', 'success', 'success', 'failed'] as const + ).entries()) { + service.record({ + instanceId: 'node-a', + outcome, + state: outcome === 'success' ? 'fresh' : 'unknown', + errorCode: outcome === 'success' ? null : 'UPSTREAM_TIMEOUT', + httpStatus: outcome === 'success' ? 200 : null, + durationMs: 10 + index, + observedAt: `2026-09-0${index + 1}T00:00:00.000Z`, + }); + } + const [summary] = service.summarize(['node-a']); + expect(summary).toMatchObject({ + instanceId: 'node-a', + total: 4, + success: 3, + failed: 1, + availabilityPercent: 75, + lastErrorCode: 'UPSTREAM_TIMEOUT', + lastObservedAt: '2026-09-04T00:00:00.000Z', + }); + expect(service.summarize()).toHaveLength(1); + }); + + it('drops the oldest rows once the rolling cap is reached', () => { + const { db, service } = fixture(100); + for (let index = 0; index < 105; index += 1) { + service.record({ + instanceId: 'node-a', + outcome: 'success', + state: 'fresh', + errorCode: null, + httpStatus: 200, + durationMs: index, + observedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + }); + } + const total = ( + db.prepare('SELECT COUNT(*) AS count FROM connection_logs').get() as { + count: number; + } + ).count; + expect(total).toBe(100); + expect(service.list({ pageSize: 100 }).items.at(-1)).toMatchObject({ durationMs: 5 }); + }); + + it('ignores malformed drafts instead of throwing', () => { + const { service } = fixture(); + expect( + service.record({ + instanceId: '', + outcome: 'success', + state: 'fresh', + durationMs: 1, + observedAt: '2026-09-02T00:00:00.000Z', + }), + ).toBeUndefined(); + expect( + service.record({ + instanceId: 'node-a', + outcome: 'nope' as never, + state: 'fresh', + durationMs: 1, + observedAt: '2026-09-02T00:00:00.000Z', + }), + ).toBeUndefined(); + expect(service.list().page.total).toBe(0); + }); + + it('prunes by cutoff and refuses an unbounded delete', () => { + const { service } = fixture(); + service.record({ + instanceId: 'node-a', + outcome: 'success', + state: 'fresh', + durationMs: 1, + observedAt: '2026-01-01T00:00:00.000Z', + }); + service.record({ + instanceId: 'node-a', + outcome: 'success', + state: 'fresh', + durationMs: 1, + observedAt: '2026-09-01T00:00:00.000Z', + }); + expect(() => service.prune({})).toThrow(/before or instanceId is required/u); + expect(service.prune({ before: '2026-06-01T00:00:00.000Z' })).toBe(1); + expect(service.list().page.total).toBe(1); + expect(service.prune({ instanceId: 'node-a' })).toBe(1); + }); +}); diff --git a/apps/api/src/application/system/connection-log-service.ts b/apps/api/src/application/system/connection-log-service.ts new file mode 100644 index 0000000..9d1752d --- /dev/null +++ b/apps/api/src/application/system/connection-log-service.ts @@ -0,0 +1,305 @@ +import { randomUUID } from 'node:crypto'; + +import type Database from 'better-sqlite3'; + +export type ConnectionOutcome = 'success' | 'stale' | 'failed' | 'unsupported'; + +/** Mirrors the status_snapshots state domain, which also covers an expired snapshot. */ +export type ProbeState = 'fresh' | 'stale' | 'expired' | 'unknown'; + +const OUTCOMES: readonly ConnectionOutcome[] = ['success', 'stale', 'failed', 'unsupported']; +const STATES: readonly ProbeState[] = ['fresh', 'stale', 'expired', 'unknown']; + +export interface ConnectionLogEntry { + readonly id: string; + readonly instanceId: string; + readonly outcome: ConnectionOutcome; + readonly state: ProbeState; + readonly errorCode: string | null; + readonly httpStatus: number | null; + readonly durationMs: number; + readonly observedAt: string; +} + +/** A probe outcome as produced by the status snapshot service on every health refresh. */ +export interface ConnectionLogDraft { + readonly instanceId: string; + readonly outcome: ConnectionOutcome; + readonly state: ProbeState; + readonly errorCode?: string | null; + readonly httpStatus?: number | null; + readonly durationMs: number; + readonly observedAt: string; +} + +export interface ConnectionLogQuery { + readonly page?: number; + readonly pageSize?: number; + readonly instanceId?: string | undefined; + readonly outcome?: ConnectionOutcome | undefined; + readonly search?: string | undefined; + readonly from?: string | undefined; + readonly to?: string | undefined; +} + +export interface ConnectionLogPage { + readonly items: readonly ConnectionLogEntry[]; + readonly page: { readonly page: number; readonly pageSize: number; readonly total: number }; +} + +export interface ConnectionSummary { + readonly instanceId: string; + readonly total: number; + readonly success: number; + readonly failed: number; + readonly averageDurationMs: number; + readonly lastObservedAt: string | null; + readonly lastErrorCode: string | null; + readonly availabilityPercent: number; +} + +export type ConnectionLogErrorCode = 'VALIDATION_FAILED'; + +export class ConnectionLogError extends Error { + constructor( + readonly code: ConnectionLogErrorCode, + message: string, + ) { + super(message); + this.name = 'ConnectionLogError'; + } +} + +export interface ConnectionLogOptions { + readonly db: Database.Database; + readonly idFactory?: () => string; + /** Rolling cap so the journal can never outgrow the rest of the control-plane database. */ + readonly maximumRows?: number; +} + +const DEFAULT_MAXIMUM_ROWS = 20_000; +const MAX_PAGE_SIZE = 200; + +function invalid(message: string): never { + throw new ConnectionLogError('VALIDATION_FAILED', message); +} + +function timestamp(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NaN; +} + +export class ConnectionLogService { + readonly #db: Database.Database; + readonly #id: () => string; + readonly #maximumRows: number; + + constructor(options: ConnectionLogOptions) { + this.#db = options.db; + this.#id = options.idFactory ?? randomUUID; + this.#maximumRows = Math.max(100, options.maximumRows ?? DEFAULT_MAXIMUM_ROWS); + } + + /** Best-effort write: a failed log insert must never break a health probe. */ + record(draft: ConnectionLogDraft): ConnectionLogEntry | undefined { + if (typeof draft.instanceId !== 'string' || draft.instanceId.length === 0) return undefined; + if (!OUTCOMES.includes(draft.outcome) || !STATES.includes(draft.state)) return undefined; + const observedAt = + typeof draft.observedAt === 'string' && Number.isFinite(timestamp(draft.observedAt)) + ? draft.observedAt + : new Date().toISOString(); + const entry: ConnectionLogEntry = { + id: this.#id(), + instanceId: draft.instanceId, + outcome: draft.outcome, + state: draft.state, + errorCode: typeof draft.errorCode === 'string' ? draft.errorCode.slice(0, 64) : null, + httpStatus: + Number.isSafeInteger(draft.httpStatus) && (draft.httpStatus ?? 0) >= 100 + ? (draft.httpStatus as number) + : null, + durationMs: Number.isSafeInteger(draft.durationMs) + ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, draft.durationMs)) + : 0, + observedAt, + }; + try { + this.#db + .prepare( + `INSERT INTO connection_logs + (id,instance_id,outcome,state,error_code,http_status,duration_ms,observed_at) + VALUES (@id,@instanceId,@outcome,@state,@errorCode,@httpStatus,@durationMs,@observedAt)`, + ) + .run(entry); + this.#enforceCap(); + } catch { + return undefined; + } + return entry; + } + + list(query: ConnectionLogQuery = {}): ConnectionLogPage { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 50; + if (!Number.isSafeInteger(page) || page < 1) invalid('page must be a positive integer'); + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE) + invalid(`pageSize must be between 1 and ${MAX_PAGE_SIZE}`); + if (query.outcome !== undefined && !OUTCOMES.includes(query.outcome)) + invalid('outcome is invalid'); + if (query.instanceId !== undefined && (query.instanceId.trim() === '' || !query.instanceId)) + invalid('instanceId is invalid'); + for (const key of ['from', 'to'] as const) { + const value = query[key]; + if (value !== undefined && !Number.isFinite(timestamp(value))) invalid(`${key} is invalid`); + } + if (query.search !== undefined && query.search.length > 200) invalid('search is too long'); + + const where: string[] = []; + const parameters: Record = {}; + if (query.instanceId) { + where.push('instance_id = @instanceId'); + parameters.instanceId = query.instanceId; + } + if (query.outcome) { + where.push('outcome = @outcome'); + parameters.outcome = query.outcome; + } + if (query.from) { + where.push('observed_at >= @from'); + parameters.from = new Date(timestamp(query.from)).toISOString(); + } + if (query.to) { + where.push('observed_at <= @to'); + parameters.to = new Date(timestamp(query.to)).toISOString(); + } + if (query.search) { + const needle = query.search.trim().toLowerCase(); + if (needle !== '') { + where.push( + `(lower(instance_id) LIKE @search OR lower(COALESCE(error_code,'')) LIKE @search + OR lower(outcome) LIKE @search OR lower(state) LIKE @search)`, + ); + parameters.search = `%${needle}%`; + } + } + const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''; + const total = ( + this.#db + .prepare(`SELECT COUNT(*) AS count FROM connection_logs${clause}`) + .get(parameters) as { count: number } + ).count; + const rows = this.#db + .prepare( + `SELECT id,instance_id,outcome,state,error_code,http_status,duration_ms,observed_at + FROM connection_logs${clause} + ORDER BY observed_at DESC, rowid DESC LIMIT @limit OFFSET @offset`, + ) + .all({ ...parameters, limit: pageSize, offset: (page - 1) * pageSize }) as Array< + Record + >; + return Object.freeze({ + items: Object.freeze(rows.map((row) => this.#entry(row))), + page: Object.freeze({ page, pageSize, total }), + }); + } + + /** Availability rollup over the retained window, newest probe first. */ + summarize(instanceIds?: readonly string[]): readonly ConnectionSummary[] { + const ids = instanceIds ? [...instanceIds] : undefined; + // better-sqlite3 cannot expand an array binding, so the IN list is built positionally. + const placeholders = ids ? ids.map((_id, index) => `@id${index}`).join(', ') : ''; + const clause = ids ? ` WHERE instance_id IN (${placeholders})` : ''; + const parameters: Record = {}; + if (ids) ids.forEach((id, index) => (parameters[`id${index}`] = id)); + const rows = this.#db + .prepare( + `SELECT instance_id, + COUNT(*) AS total, + SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) AS success, + SUM(CASE WHEN outcome IN ('failed','unsupported') THEN 1 ELSE 0 END) AS failed, + AVG(duration_ms) AS average_duration, + MAX(observed_at) AS last_observed_at + FROM connection_logs${clause} + GROUP BY instance_id`, + ) + .all(parameters) as Array>; + const summaries = rows.map((row) => { + const instanceId = String(row.instance_id ?? ''); + const total = Number(row.total ?? 0); + const success = Number(row.success ?? 0); + const lastObservedAt = row.last_observed_at === null ? null : String(row.last_observed_at); + const last = this.#db + .prepare( + `SELECT error_code FROM connection_logs + WHERE instance_id = ? ORDER BY observed_at DESC, rowid DESC LIMIT 1`, + ) + .get(instanceId) as { error_code: string | null } | undefined; + return Object.freeze({ + instanceId, + total, + success, + failed: Number(row.failed ?? 0), + averageDurationMs: Math.round(Number(row.average_duration ?? 0)), + lastObservedAt, + lastErrorCode: last?.error_code ?? null, + availabilityPercent: total > 0 ? Math.round((success / total) * 1000) / 10 : 0, + }) satisfies ConnectionSummary; + }); + return Object.freeze(summaries); + } + + prune(input: { + readonly before?: string | undefined; + readonly instanceId?: string | undefined; + }): number { + const where: string[] = []; + const parameters: Record = {}; + if (input.before) { + const cutoff = timestamp(input.before); + if (!Number.isFinite(cutoff)) invalid('before is invalid'); + where.push('observed_at < @before'); + parameters.before = new Date(cutoff).toISOString(); + } + if (input.instanceId) { + where.push('instance_id = @instanceId'); + parameters.instanceId = input.instanceId; + } + if (where.length === 0) invalid('before or instanceId is required'); + return Number( + this.#db.prepare(`DELETE FROM connection_logs WHERE ${where.join(' AND ')}`).run(parameters) + .changes, + ); + } + + #enforceCap(): void { + const total = ( + this.#db.prepare('SELECT COUNT(*) AS count FROM connection_logs').get() as { + count: number; + } + ).count; + if (total <= this.#maximumRows) return; + this.#db + .prepare( + `DELETE FROM connection_logs WHERE rowid IN ( + SELECT rowid FROM connection_logs ORDER BY observed_at DESC, rowid DESC + LIMIT -1 OFFSET @keep + )`, + ) + .run({ keep: this.#maximumRows }); + } + + #entry(row: Record): ConnectionLogEntry { + return Object.freeze({ + id: String(row.id), + instanceId: String(row.instance_id), + outcome: row.outcome as ConnectionOutcome, + state: row.state as ProbeState, + errorCode: + row.error_code === null || row.error_code === undefined ? null : String(row.error_code), + httpStatus: + row.http_status === null || row.http_status === undefined ? null : Number(row.http_status), + durationMs: Number(row.duration_ms ?? 0), + observedAt: String(row.observed_at), + }); + } +} diff --git a/apps/api/src/application/system/log-center-service.test.ts b/apps/api/src/application/system/log-center-service.test.ts new file mode 100644 index 0000000..8b08be4 --- /dev/null +++ b/apps/api/src/application/system/log-center-service.test.ts @@ -0,0 +1,189 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { ConnectionLogService } from './connection-log-service.js'; +import { LogCenterService } from './log-center-service.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,3,'2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`, + ).run(); + db.prepare( + `INSERT INTO instance_tags (instance_id,tag,created_at) VALUES ('node-a','lab','2026-09-01T00:00:00.000Z')`, + ).run(); + const connections = new ConnectionLogService({ db }); + return { db, connections, logs: new LogCenterService({ db, connections }) }; +} + +afterEach(() => { + for (const db of databases.splice(0)) db.close(); +}); + +function seed(db: Database.Database): void { + db.prepare( + `INSERT INTO event_journal (public_id,envelope_json) + VALUES ('evt-1',@envelope)`, + ).run({ + envelope: JSON.stringify({ + id: 'evt-1', + kind: 'instance.updated', + instanceId: 'node-a', + occurredAt: '2026-09-04T02:00:00.000Z', + }), + }); + db.prepare( + `INSERT INTO audit_events + (id,instance_id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at) + VALUES ('aud-1','node-a','console','restart-service','R1','req-1','{}','failed',42,'2026-09-04T03:00:00.000Z')`, + ).run(); + db.prepare( + `INSERT INTO scheduled_tasks + (id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, + misfire_policy,overlap_policy,retry_policy_json,created_by,updated_by,created_at,updated_at) + VALUES ('task-1','夜间重启','restart-service',1,1,'0 3 * * *','Asia/Shanghai','{}', + 'skip','skip','{"maximumAttempts":1}','console','console','2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`, + ).run(); + db.prepare( + `INSERT INTO scheduled_runs + (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,started_at,finished_at, + target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt) + VALUES ('run-1','task-1',1,'{"name":"夜间重启"}','2026-09-04T04:00:00.000Z','2026-09-04T04:00:00.000Z', + '2026-09-04T04:00:00.000Z','2026-09-04T04:00:05.000Z','[]','failed','UPSTREAM_TIMEOUT','[]','scheduled',1)`, + ).run(); + db.prepare( + `INSERT INTO notification_deliveries + (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at) + VALUES ('del-1',NULL,NULL,'node-a','sms','failed','smtp refused','2026-09-04T05:00:00.000Z',NULL)`, + ).run(); +} + +describe('LogCenterService', () => { + it('merges every persisted activity source into one timeline', () => { + const { db, logs } = fixture(); + seed(db); + const page = logs.listRuntimeLogs(); + expect(page.page.total).toBe(4); + expect(page.items.map((item) => item.source)).toEqual([ + 'delivery', + 'schedule', + 'audit', + 'event', + ]); + expect(page.counts).toEqual({ event: 1, audit: 1, schedule: 1, delivery: 1 }); + expect(page.items[1]).toMatchObject({ level: 'error', code: 'failed' }); + }); + + it('filters the timeline by level, source and search text', () => { + const { db, logs } = fixture(); + seed(db); + expect(logs.listRuntimeLogs({ level: 'error' }).items.map((item) => item.source)).toEqual([ + 'delivery', + 'schedule', + 'audit', + ]); + expect(logs.listRuntimeLogs({ source: 'event' }).page.total).toBe(1); + expect(logs.listRuntimeLogs({ search: 'smtp' }).page.total).toBe(1); + expect(logs.listRuntimeLogs({ from: '2026-09-04T04:30:00.000Z' }).page.total).toBe(1); + expect(logs.listRuntimeLogs({ instanceId: 'node-a' }).page.total).toBe(3); + }); + + it('rejects malformed pagination and unknown enum values', () => { + const { logs } = fixture(); + expect(() => logs.listRuntimeLogs({ page: 0 })).toThrow(/positive integer/u); + expect(() => logs.listRuntimeLogs({ pageSize: 500 })).toThrow(/between 1 and 200/u); + expect(() => logs.listRuntimeLogs({ source: 'nope' as never })).toThrow(/source is invalid/u); + expect(() => logs.listRuntimeLogs({ from: 'yesterday' })).toThrow(/from is invalid/u); + }); + + it('reports connection snapshots as device health for the live probe path', () => { + const { db, connections, logs } = fixture(); + db.prepare( + `INSERT INTO status_snapshots + (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) + VALUES ('snap-1','node-a','connection','fresh','{"reachable":true,"authenticated":false}', + '2026-09-04T06:00:00.000Z','2026-09-04T06:00:30.000Z','2026-09-04T06:00:00.000Z')`, + ).run(); + db.prepare( + `INSERT INTO capabilities + (instance_id,operation_id,state,observed_at,created_at,updated_at) + VALUES ('node-a','restart-service','supported','2026-09-04T06:00:00.000Z', + '2026-09-04T06:00:00.000Z','2026-09-04T06:00:00.000Z'), + ('node-a','reboot-system','unsupported','2026-09-04T06:00:00.000Z', + '2026-09-04T06:00:00.000Z','2026-09-04T06:00:00.000Z')`, + ).run(); + connections.record({ + instanceId: 'node-a', + outcome: 'stale', + state: 'stale', + errorCode: 'AUTH_REQUIRED', + httpStatus: 401, + durationMs: 15, + observedAt: '2026-09-04T06:00:00.000Z', + }); + const [device] = logs.diagnostics(); + expect(device).toMatchObject({ + instanceId: 'node-a', + name: 'Node A', + enabled: true, + revision: 3, + tags: ['lab'], + capabilities: { total: 2, supported: 1, unsupported: 1 }, + }); + expect(device?.health).toMatchObject({ + category: 'connection', + status: 'reachable', + authenticated: false, + errorCode: 'AUTH_REQUIRED', + }); + expect(device?.connections).toMatchObject({ total: 1, availabilityPercent: 0, failed: 0 }); + }); + + it('prefers a full health envelope when both snapshot categories exist', () => { + const { db, logs } = fixture(); + for (const [category, payload] of [ + ['connection', '{"reachable":true,"authenticated":true}'], + [ + 'health', + '{"schemaVersion":1,"data":{"status":"ok","version":"1.9.6","platform":"linux"},' + + '"errorCode":null,"httpStatus":200,"fetchedAt":"2026-09-04T06:00:00.000Z","durationMs":21,' + + '"freshness":"fresh","supported":true,"dataFetchedAt":null}', + ], + ] as const) { + db.prepare( + `INSERT INTO status_snapshots + (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) + VALUES (?, 'node-a', ?, 'fresh', ?, '2026-09-04T06:00:00.000Z', NULL, '2026-09-04T06:00:00.000Z')`, + ).run(`snap-${category}`, category, payload); + } + const [device] = logs.diagnostics(); + expect(device?.health).toMatchObject({ + category: 'health', + version: '1.9.6', + platform: 'linux', + status: 'ok', + durationMs: 21, + httpStatus: 200, + }); + expect(device?.snapshots.map((snapshot) => snapshot.category)).toEqual([ + 'connection', + 'health', + ]); + }); + + it('surfaces the most recent failed operations per device', () => { + const { db, logs } = fixture(); + seed(db); + const [device] = logs.diagnostics(); + expect(device?.recentFailures).toEqual([ + { occurredAt: '2026-09-04T03:00:00.000Z', code: 'failed', message: 'restart-service' }, + ]); + }); +}); diff --git a/apps/api/src/application/system/log-center-service.ts b/apps/api/src/application/system/log-center-service.ts new file mode 100644 index 0000000..b7f5bc3 --- /dev/null +++ b/apps/api/src/application/system/log-center-service.ts @@ -0,0 +1,445 @@ +import type Database from 'better-sqlite3'; + +import type { ConnectionLogService, ConnectionSummary } from './connection-log-service.js'; + +export type LogSource = 'event' | 'audit' | 'schedule' | 'delivery'; +export type LogLevel = 'info' | 'warning' | 'error'; + +const SOURCES: readonly LogSource[] = ['event', 'audit', 'schedule', 'delivery']; +const LEVELS: readonly LogLevel[] = ['info', 'warning', 'error']; + +export interface RuntimeLogEntry { + readonly id: string; + readonly source: LogSource; + readonly level: LogLevel; + readonly occurredAt: string; + readonly instanceId: string | null; + readonly message: string; + readonly code: string | null; + readonly actor: string | null; + readonly durationMs: number | null; +} + +export interface RuntimeLogQuery { + readonly page?: number; + readonly pageSize?: number; + readonly source?: LogSource | undefined; + readonly level?: LogLevel | undefined; + readonly instanceId?: string | undefined; + readonly search?: string | undefined; + readonly from?: string | undefined; + readonly to?: string | undefined; +} + +export interface RuntimeLogPage { + readonly items: readonly RuntimeLogEntry[]; + readonly page: { readonly page: number; readonly pageSize: number; readonly total: number }; + readonly counts: Readonly>; +} + +export interface InstanceDiagnostics { + readonly instanceId: string; + readonly name: string; + readonly origin: string; + readonly enabled: boolean; + readonly revision: number; + readonly tags: readonly string[]; + readonly health: { + readonly category: 'health' | 'connection'; + readonly state: string; + readonly observedAt: string; + readonly errorCode: string | null; + readonly httpStatus: number | null; + readonly durationMs: number | null; + readonly version: string | null; + readonly platform: string | null; + readonly status: string | null; + readonly authenticated: boolean | null; + } | null; + readonly snapshots: readonly { + readonly category: string; + readonly state: string; + readonly observedAt: string; + }[]; + readonly capabilities: { + readonly total: number; + readonly supported: number; + readonly unsupported: number; + readonly authRequired: number; + readonly degraded: number; + readonly unknown: number; + }; + readonly connections: ConnectionSummary | null; + readonly recentFailures: readonly { + readonly occurredAt: string; + readonly code: string | null; + readonly message: string; + }[]; +} + +export type LogCenterErrorCode = 'VALIDATION_FAILED'; + +export class LogCenterError extends Error { + constructor( + readonly code: LogCenterErrorCode, + message: string, + ) { + super(message); + this.name = 'LogCenterError'; + } +} + +export interface LogCenterOptions { + readonly db: Database.Database; + readonly connections: ConnectionLogService; +} + +const MAX_PAGE_SIZE = 200; + +function invalid(message: string): never { + throw new LogCenterError('VALIDATION_FAILED', message); +} + +function instant(value: string): string | undefined { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; +} + +/** + * One UNION over the tables that already persist control-plane activity. Nothing new is + * written here, so the timeline stays consistent with the pages that own each source. + */ +const TIMELINE_SQL = ` + SELECT id, source, level, occurred_at, instance_id, message, code, actor, duration_ms FROM ( + SELECT e.public_id || ':' || e.sequence AS id, + 'event' AS source, + 'info' AS level, + json_extract(e.envelope_json,'$.occurredAt') AS occurred_at, + json_extract(e.envelope_json,'$.instanceId') AS instance_id, + '事件 ' || json_extract(e.envelope_json,'$.kind') || ' ' || json_extract(e.envelope_json,'$.id') + AS message, + json_extract(e.envelope_json,'$.kind') AS code, + NULL AS actor, + NULL AS duration_ms + FROM event_journal e + UNION ALL + SELECT a.id, + 'audit', + CASE WHEN a.result_code = 'success' THEN 'info' ELSE 'error' END, + a.created_at, + a.instance_id, + '操作 ' || a.operation_id || ' 结果 ' || a.result_code, + a.result_code, + a.actor, + a.duration_ms + FROM audit_events a + UNION ALL + SELECT r.id, + 'schedule', + CASE + WHEN r.outcome IN ('failed','needs-attention') THEN 'error' + WHEN r.outcome IN ('partially-succeeded','skipped','no-targets') THEN 'warning' + ELSE 'info' + END, + COALESCE(r.finished_at, r.due_at), + NULL, + '定时任务 ' || COALESCE(json_extract(r.task_snapshot_json,'$.name'), r.scheduled_task_id) + || ' 结果 ' || COALESCE(r.outcome, 'running') + || COALESCE(' 原因 ' || r.reason, ''), + COALESCE(r.outcome, 'running'), + NULL, + CAST((julianday(COALESCE(r.finished_at, r.claimed_at)) - julianday(r.started_at)) + * 86400000 AS INTEGER) + FROM scheduled_runs r + UNION ALL + SELECT d.id, + 'delivery', + CASE + WHEN d.status = 'failed' THEN 'error' + WHEN d.status IN ('unmatched','no_available_channel','quiet_hours','rate_limited') + THEN 'warning' + ELSE 'info' + END, + d.created_at, + d.instance_id, + '通知 ' || d.event_type || ' 状态 ' || d.status + || COALESCE(' 详情 ' || d.detail, ''), + d.status, + NULL, + NULL + FROM notification_deliveries d + )`; + +export class LogCenterService { + readonly #db: Database.Database; + readonly #connections: ConnectionLogService; + + constructor(options: LogCenterOptions) { + this.#db = options.db; + this.#connections = options.connections; + } + + listRuntimeLogs(query: RuntimeLogQuery = {}): RuntimeLogPage { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 50; + if (!Number.isSafeInteger(page) || page < 1) invalid('page must be a positive integer'); + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE) + invalid(`pageSize must be between 1 and ${MAX_PAGE_SIZE}`); + if (query.source !== undefined && !SOURCES.includes(query.source)) invalid('source is invalid'); + if (query.level !== undefined && !LEVELS.includes(query.level)) invalid('level is invalid'); + if (query.search !== undefined && query.search.length > 200) invalid('search is too long'); + if (query.instanceId !== undefined && query.instanceId.trim() === '') + invalid('instanceId is invalid'); + const bounds: Record = {}; + for (const key of ['from', 'to'] as const) { + const value = query[key]; + if (value === undefined) continue; + const normalized = instant(value); + if (!normalized) invalid(`${key} is invalid`); + bounds[key] = normalized; + } + + const where: string[] = []; + const parameters: Record = {}; + if (query.source) { + where.push('source = @source'); + parameters.source = query.source; + } + if (query.level) { + where.push('level = @level'); + parameters.level = query.level; + } + if (query.instanceId) { + where.push('instance_id = @instanceId'); + parameters.instanceId = query.instanceId; + } + if (bounds.from) { + where.push('occurred_at >= @from'); + parameters.from = bounds.from; + } + if (bounds.to) { + where.push('occurred_at <= @to'); + parameters.to = bounds.to; + } + const needle = query.search?.trim().toLowerCase() ?? ''; + if (needle !== '') { + where.push( + `(lower(message) LIKE @search OR lower(COALESCE(code,'')) LIKE @search + OR lower(COALESCE(actor,'')) LIKE @search OR lower(COALESCE(instance_id,'')) LIKE @search)`, + ); + parameters.search = `%${needle}%`; + } + const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''; + + const total = ( + this.#db + .prepare(`SELECT COUNT(*) AS count FROM (${TIMELINE_SQL})${clause}`) + .get(parameters) as { count: number } + ).count; + const rows = this.#db + .prepare( + `SELECT id, source, level, occurred_at, instance_id, message, code, actor, duration_ms + FROM (${TIMELINE_SQL})${clause} + ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset`, + ) + .all({ ...parameters, limit: pageSize, offset: (page - 1) * pageSize }) as Array< + Record + >; + + const counts = Object.fromEntries(SOURCES.map((source) => [source, 0])) as Record< + LogSource, + number + >; + const grouped = this.#db + .prepare(`SELECT source, COUNT(*) AS count FROM (${TIMELINE_SQL}) GROUP BY source`) + .all() as Array<{ source: string; count: number }>; + for (const row of grouped) { + if (SOURCES.includes(row.source as LogSource)) counts[row.source as LogSource] = row.count; + } + + return Object.freeze({ + items: Object.freeze(rows.map((row) => this.#entry(row))), + page: Object.freeze({ page, pageSize, total }), + counts: Object.freeze(counts), + }); + } + + /** Per-instance diagnostics for the whole fleet, newest failure surfaced first. */ + diagnostics(): readonly InstanceDiagnostics[] { + const instances = this.#db + .prepare( + `SELECT id,name,base_url,enabled,config_revision FROM instances ORDER BY name COLLATE NOCASE`, + ) + .all() as Array>; + const tagRows = this.#db.prepare('SELECT instance_id, tag FROM instance_tags').all() as Array<{ + instance_id: string; + tag: string; + }>; + const tagsByInstance = new Map(); + for (const row of tagRows) { + const bucket = tagsByInstance.get(row.instance_id) ?? []; + bucket.push(row.tag); + tagsByInstance.set(row.instance_id, bucket); + } + const summaries = new Map( + this.#connections.summarize().map((summary) => [summary.instanceId, summary]), + ); + + return Object.freeze( + instances.map((instance) => + Object.freeze(this.#diagnostics(instance, tagsByInstance, summaries)), + ), + ); + } + + #diagnostics( + instance: Record, + tagsByInstance: ReadonlyMap, + summaries: ReadonlyMap, + ): InstanceDiagnostics { + const instanceId = String(instance.id); + // The control plane persists 'health' envelopes only when the snapshot service runs; the + // live reachability path writes 'connection' rows, so diagnostics reads whichever exists. + const snapshot = this.#db + .prepare( + `SELECT category, state, payload_json, observed_at FROM status_snapshots + WHERE instance_id = ? AND category IN ('health','connection') + ORDER BY CASE WHEN category = 'health' THEN 0 ELSE 1 END LIMIT 1`, + ) + .get(instanceId) as + | { category: string; state: string; payload_json: string; observed_at: string } + | undefined; + const envelope = parseRecord(snapshot?.payload_json); + const data = parseRecord(envelope?.data) ?? envelope; + const capability = this.#db + .prepare( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN state = 'supported' THEN 1 ELSE 0 END) AS supported, + SUM(CASE WHEN state = 'unsupported' THEN 1 ELSE 0 END) AS unsupported, + SUM(CASE WHEN state = 'auth-required' THEN 1 ELSE 0 END) AS auth_required, + SUM(CASE WHEN state = 'degraded' THEN 1 ELSE 0 END) AS degraded, + SUM(CASE WHEN state = 'unknown' THEN 1 ELSE 0 END) AS unknown_state + FROM capabilities WHERE instance_id = ?`, + ) + .get(instanceId) as Record | undefined; + const snapshots = this.#db + .prepare( + `SELECT category, state, observed_at FROM status_snapshots + WHERE instance_id = ? ORDER BY category`, + ) + .all(instanceId) as Array<{ category: string; state: string; observed_at: string }>; + const failures = this.#db + .prepare( + `SELECT id, operation_id, result_code, created_at FROM audit_events + WHERE instance_id = ? AND result_code <> 'success' + ORDER BY created_at DESC LIMIT 5`, + ) + .all(instanceId) as Array<{ + id: string; + operation_id: string; + result_code: string; + created_at: string; + }>; + + return { + instanceId, + name: String(instance.name ?? instanceId), + origin: String(instance.base_url ?? ''), + enabled: Number(instance.enabled ?? 0) === 1, + revision: Number(instance.config_revision ?? 1), + tags: Object.freeze([...(tagsByInstance.get(instanceId) ?? [])]), + health: snapshot ? Object.freeze(this.#health(snapshot, envelope, data)) : null, + snapshots: Object.freeze( + snapshots.map((row) => + Object.freeze({ category: row.category, state: row.state, observedAt: row.observed_at }), + ), + ), + capabilities: Object.freeze({ + total: Number(capability?.total ?? 0), + supported: Number(capability?.supported ?? 0), + unsupported: Number(capability?.unsupported ?? 0), + authRequired: Number(capability?.auth_required ?? 0), + degraded: Number(capability?.degraded ?? 0), + unknown: Number(capability?.unknown_state ?? 0), + }), + connections: summaries.get(instanceId) ?? null, + recentFailures: Object.freeze( + failures.map((row) => + Object.freeze({ + occurredAt: row.created_at, + code: row.result_code, + message: row.operation_id, + }), + ), + ), + }; + } + + #entry(row: Record): RuntimeLogEntry { + return Object.freeze({ + id: String(row.id), + source: row.source as LogSource, + level: row.level as LogLevel, + occurredAt: String(row.occurred_at ?? ''), + instanceId: + row.instance_id === null || row.instance_id === undefined ? null : String(row.instance_id), + message: String(row.message ?? ''), + code: row.code === null || row.code === undefined ? null : String(row.code), + actor: row.actor === null || row.actor === undefined ? null : String(row.actor), + durationMs: + row.duration_ms === null || row.duration_ms === undefined ? null : Number(row.duration_ms), + }); + } + + #health( + snapshot: { category: string; state: string; observed_at: string }, + envelope: Record | undefined, + data: Record | undefined, + ): NonNullable { + const category = snapshot.category === 'connection' ? 'connection' : 'health'; + if (category === 'connection') { + const authenticated = + typeof envelope?.authenticated === 'boolean' ? envelope.authenticated : null; + const reachable = typeof envelope?.reachable === 'boolean' ? envelope.reachable : null; + return Object.freeze({ + category, + state: snapshot.state, + observedAt: snapshot.observed_at, + errorCode: authenticated === false ? 'AUTH_REQUIRED' : null, + httpStatus: null, + durationMs: null, + version: null, + platform: null, + status: reachable === null ? null : reachable ? 'reachable' : 'unreachable', + authenticated, + }); + } + return Object.freeze({ + category, + state: snapshot.state, + observedAt: snapshot.observed_at, + errorCode: typeof envelope?.errorCode === 'string' ? envelope.errorCode : null, + httpStatus: typeof envelope?.httpStatus === 'number' ? envelope.httpStatus : null, + durationMs: typeof envelope?.durationMs === 'number' ? envelope.durationMs : null, + version: typeof data?.version === 'string' ? data.version : null, + platform: typeof data?.platform === 'string' ? data.platform : null, + status: typeof data?.status === 'string' ? data.status : null, + authenticated: null, + }); + } +} + +/** Accepts either a JSON string or an already-decoded object so both snapshot shapes work. */ +function parseRecord(value: unknown): Record | undefined { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) + return value as Record; + if (typeof value !== 'string' || value === '') return undefined; + try { + const parsed: unknown = JSON.parse(value); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + return parsed as Record; + } catch { + return undefined; + } +} diff --git a/apps/api/src/application/system/system-maintenance-service.test.ts b/apps/api/src/application/system/system-maintenance-service.test.ts new file mode 100644 index 0000000..f7702d0 --- /dev/null +++ b/apps/api/src/application/system/system-maintenance-service.test.ts @@ -0,0 +1,243 @@ +import Database from 'better-sqlite3'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { SystemMaintenanceService } from './system-maintenance-service.js'; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function database(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + return db; +} + +function insertAudit(db: Database.Database, id: string, createdAt: string): void { + db.prepare( + `INSERT INTO audit_events + (id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + ).run(id, 'operator', 'probe', 'R0', `${id}-request`, '{}', 'success', 1, createdAt); +} + +function insertJob(db: Database.Database, id: string, createdAt: string): void { + db.prepare( + `INSERT INTO jobs + (id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + ).run( + id, + 'probe', + 'R0', + 'succeeded', + 'operator', + `${id}-request`, + '0'.repeat(64), + createdAt, + createdAt, + ); +} + +function insertEvent(db: Database.Database, publicId: string, envelope: unknown): void { + db.prepare('INSERT INTO event_journal (public_id,envelope_json) VALUES (?,?)').run( + publicId, + JSON.stringify(envelope), + ); +} + +describe('SystemMaintenanceService', () => { + it('reports runtime storage, component counts, and Hub-compatible default retention', async () => { + const db = database(); + insertAudit(db, 'audit-1', '2026-01-01T00:00:00.000Z'); + insertJob(db, 'job-1', '2026-01-01T00:00:00.000Z'); + insertEvent(db, 'event-1', { kind: 'test' }); + const service = new SystemMaintenanceService(db, { version: '1.9.9' }); + + const overview = await service.overview(); + + expect(overview.runtime).toMatchObject({ + version: '1.9.9', + platform: process.platform, + arch: process.arch, + }); + expect(overview.runtime.uptimeSeconds).toBeGreaterThanOrEqual(0); + expect(overview.storage.databaseBytes).toBeGreaterThan(0); + expect( + Object.fromEntries(overview.storage.components.map((item) => [item.key, item.count])), + ).toEqual({ + instances: 0, + statusSnapshots: 0, + jobs: 1, + auditEvents: 1, + scheduledRuns: 0, + notificationQueue: 0, + smsMessages: 0, + eventJournal: 1, + connectionLogs: 0, + }); + expect(overview.retention.auditEvents).toEqual({ + enabled: true, + days: 180, + maximumCount: 50_000, + }); + expect(overview.retention.jobs).toEqual({ + enabled: true, + days: 90, + maximumCount: 5_000, + }); + }); + + it('persists validated retention policies and cleans selected components', async () => { + const db = database(); + const task = { + name: 'Night restart', + operationType: 'restart-service' as const, + cronExpression: '0 2 * * *', + timezone: 'Asia/Shanghai' as const, + targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a'] }, + misfirePolicy: 'skip' as const, + overlapPolicy: 'skip' as const, + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: false, + }; + db.prepare( + `INSERT INTO scheduled_tasks + (id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, + misfire_policy,overlap_policy,retry_policy_json,created_by,updated_by,created_at,updated_at) + VALUES ('task-1',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + task.name, + task.operationType, + 0, + 1, + task.cronExpression, + task.timezone, + JSON.stringify(task.targetSelector), + task.misfirePolicy, + task.overlapPolicy, + JSON.stringify(task.retryPolicy), + 'operator', + 'operator', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ); + db.prepare( + `INSERT INTO scheduled_runs + (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at, + target_snapshot_json,job_ids_json,trigger_source,attempt) + VALUES ('run-old','task-1',1,?,'2026-01-01T02:00:00.000Z','2026-01-01T02:00:00.000Z','[]','[]','scheduled',1)`, + ).run(JSON.stringify(task)); + insertAudit(db, 'audit-old', '2026-01-01T00:00:00.000Z'); + insertAudit(db, 'audit-new', '2026-09-01T00:00:00.000Z'); + insertJob(db, 'job-old', '2026-01-01T00:00:00.000Z'); + insertEvent(db, 'event-old', { kind: 'old' }); + const service = new SystemMaintenanceService(db, { + version: '1.9.9', + now: () => new Date('2026-09-03T00:00:00.000Z'), + }); + + await service.updateRetention({ + auditEvents: { enabled: true, days: 30, maximumCount: 1 }, + jobs: { enabled: true, days: 1, maximumCount: 100 }, + eventJournal: { enabled: true, days: 1, maximumCount: 100 }, + }); + + const result = await service.cleanup(['auditEvents', 'jobs', 'eventJournal', 'scheduledRuns']); + expect(result).toEqual({ + auditEvents: 1, + jobs: 1, + eventJournal: 1, + scheduledRuns: 1, + }); + const counts = (table: string) => + (db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count; + expect(counts('audit_events')).toBe(1); + expect(counts('jobs')).toBe(0); + expect(counts('event_journal')).toBe(0); + expect(counts('scheduled_runs')).toBe(0); + expect((await service.overview()).retention.auditEvents).toEqual({ + enabled: true, + days: 30, + maximumCount: 1, + }); + }); + + it('rejects unknown components and invalid retention values without touching settings', async () => { + const db = database(); + const service = new SystemMaintenanceService(db, { version: '1.9.9' }); + await expect(service.cleanup(['unknown' as never])).rejects.toThrow('Unknown data component'); + await expect( + service.updateRetention({ auditEvents: { enabled: true, days: 0, maximumCount: 1 } }), + ).rejects.toThrow('Retention days must be between'); + expect(await service.getRetention()).toEqual(await service.defaultRetention()); + }); + + it('optimizes SQLite and creates validated, listed backups with retention pruning', async () => { + const db = database(); + const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-')); + roots.push(backupRoot); + const service = new SystemMaintenanceService(db, { + version: '1.9.9', + backupDirectory: backupRoot, + maximumBackups: 2, + }); + + expect(await service.optimize()).toEqual({ + checkpointed: true, + vacuumed: true, + analyzed: true, + }); + const first = await service.createBackup(); + const second = await service.createBackup(); + const third = await service.createBackup(); + const backups = await service.listBackups(); + + expect(backups).toHaveLength(2); + expect(backups.map((item) => item.filename)).toEqual([third.filename, second.filename]); + expect(first.sha256).toMatch(/^[a-f0-9]{64}$/u); + // Retention is enforced on disk, not just in the listing. + await expect(readFile(first.path)).rejects.toThrow(); + expect(await readFile(second.path)).toBeTruthy(); + expect(backups[0]).toEqual( + expect.objectContaining({ + filename: third.filename, + sha256: third.sha256, + sizeBytes: expect.any(Number), + createdAt: expect.any(String), + }), + ); + }); + + it('serves and deletes single backups and refuses names outside the directory', async () => { + const db = database(); + const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-')); + roots.push(backupRoot); + const service = new SystemMaintenanceService(db, { + version: '1.9.9', + backupDirectory: backupRoot, + }); + + const backup = await service.createBackup(); + expect(await service.backupFile(backup.filename)).toEqual({ + filename: backup.filename, + path: backup.path, + sizeBytes: backup.sizeBytes, + createdAt: expect.any(String), + }); + expect(await service.backupFile('multi-simadmin-1970-01-01T00-00-00-000.db')).toBeUndefined(); + + await expect(service.backupFile('multi-simadmin-../secret.db')).rejects.toThrow(); + await expect(service.backupFile('multi-simadmin-notes.txt')).rejects.toThrow(); + await expect(service.deleteBackup('multi-simadmin-%00.db')).rejects.toThrow(); + + expect(await service.deleteBackup(backup.filename)).toEqual({ filename: backup.filename }); + expect(await service.listBackups()).toEqual([]); + }); +}); diff --git a/apps/api/src/application/system/system-maintenance-service.ts b/apps/api/src/application/system/system-maintenance-service.ts new file mode 100644 index 0000000..71e0c2c --- /dev/null +++ b/apps/api/src/application/system/system-maintenance-service.ts @@ -0,0 +1,384 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; + +export type DataComponentKey = + | 'instances' + | 'statusSnapshots' + | 'jobs' + | 'auditEvents' + | 'scheduledRuns' + | 'notificationQueue' + | 'smsMessages' + | 'eventJournal' + | 'connectionLogs'; + +export interface RetentionPolicy { + readonly enabled: boolean; + readonly days: number; + readonly maximumCount: number; +} + +export interface MaintenanceOverview { + readonly runtime: { + readonly version: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly uptimeSeconds: number; + }; + readonly storage: { + readonly databaseBytes: number; + /** Write-ahead log file size; 0 when the database is not in WAL mode. */ + readonly walBytes: number; + /** Total size of the retained backup files. */ + readonly backupBytes: number; + /** Free pages that "整理空间" can hand back to the filesystem. */ + readonly reclaimableBytes: number; + readonly components: readonly { + readonly key: DataComponentKey; + readonly count: number; + readonly bytes: number; + }[]; + }; + readonly retention: Readonly>; +} + +export interface MaintenanceBackup { + readonly filename: string; + readonly path: string; + readonly sizeBytes: number; + readonly sha256: string; + readonly createdAt: string; +} + +/** Download metadata without the full-file hash so large backups stay cheap to serve. */ +export interface MaintenanceBackupFile { + readonly filename: string; + readonly path: string; + readonly sizeBytes: number; + readonly createdAt: string; +} + +interface ServiceOptions { + readonly version: string; + readonly now?: () => Date; + readonly backupDirectory?: string; + readonly maximumBackups?: number; +} + +const COMPONENT_TABLES: Readonly> = Object.freeze({ + instances: 'instances', + statusSnapshots: 'status_snapshots', + jobs: 'jobs', + auditEvents: 'audit_events', + scheduledRuns: 'scheduled_runs', + notificationQueue: 'notification_queue', + smsMessages: 'sms_messages', + eventJournal: 'event_journal', + connectionLogs: 'connection_logs', +}); + +const RETENTION_SETTING_KEY = 'system.retention'; +const DEFAULT_RETENTION: Readonly> = Object.freeze({ + instances: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }), + statusSnapshots: Object.freeze({ enabled: false, days: 30, maximumCount: 0 }), + jobs: Object.freeze({ enabled: true, days: 90, maximumCount: 5_000 }), + auditEvents: Object.freeze({ enabled: true, days: 180, maximumCount: 50_000 }), + scheduledRuns: Object.freeze({ enabled: true, days: 180, maximumCount: 5_000 }), + notificationQueue: Object.freeze({ enabled: true, days: 180, maximumCount: 10_000 }), + smsMessages: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }), + eventJournal: Object.freeze({ enabled: true, days: 30, maximumCount: 10_000 }), + // The probe journal already self-caps, so retention here is opt-in by day window. + connectionLogs: Object.freeze({ enabled: true, days: 30, maximumCount: 20_000 }), +}); + +function cloneRetention( + value: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, policy]) => [key, { ...policy }]), + ) as Record; +} + +function validateRetention(value: unknown): Record { + const source = + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + const merged = cloneRetention(DEFAULT_RETENTION); + for (const key of Object.keys(DEFAULT_RETENTION) as DataComponentKey[]) { + const current = source[key]; + if (current === undefined) continue; + const policy = current as Partial; + if ( + typeof policy.enabled !== 'boolean' || + !Number.isSafeInteger(policy.days) || + (policy.days ?? 0) < 1 || + (policy.days ?? 0) > 3_650 || + !Number.isSafeInteger(policy.maximumCount) || + (policy.maximumCount ?? 0) < 0 + ) + throw new RangeError('Retention days must be between 1 and 3650 and limits must be valid'); + merged[key] = { + enabled: policy.enabled, + days: policy.days!, + maximumCount: policy.maximumCount!, + }; + } + if (Object.keys(source).some((key) => !(key in DEFAULT_RETENTION))) + throw new TypeError('Unknown data component'); + return merged; +} + +function tableExists(db: SqliteDatabase, table: string): boolean { + return Boolean( + db.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get('table', table), + ); +} + +function countTable(db: SqliteDatabase, table: string): number { + if (!tableExists(db, table)) return 0; + return Number( + (db.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get() as { count?: number } | undefined) + ?.count ?? 0, + ); +} + +/** Per-table page usage from the dbstat virtual table; 0 when SQLite was built without it. */ +function tableBytes(db: SqliteDatabase, table: string): number { + if (!tableExists(db, table)) return 0; + try { + const row = db + .prepare('SELECT COALESCE(SUM(pgsize), 0) AS bytes FROM dbstat WHERE name = ?') + .get(table) as { bytes?: number } | undefined; + return Number(row?.bytes ?? 0); + } catch { + return 0; + } +} + +export class SystemMaintenanceService { + readonly #db: SqliteDatabase; + readonly #version: string; + readonly #now: () => Date; + readonly #backupDirectory: string; + readonly #maximumBackups: number; + + constructor(db: SqliteDatabase, options: ServiceOptions) { + this.#db = db; + this.#version = options.version; + this.#now = options.now ?? (() => new Date()); + this.#backupDirectory = options.backupDirectory ?? './data/backups'; + this.#maximumBackups = Math.max(1, options.maximumBackups ?? 10); + } + + async overview(): Promise { + const pageCount = Number(this.#db.pragma('page_count', { simple: true })); + const pageSize = Number(this.#db.pragma('page_size', { simple: true })); + const databaseFile = String( + (this.#db.prepare('PRAGMA database_list').get() as { file?: string } | undefined)?.file ?? '', + ); + const walBytes = databaseFile + ? await stat(`${databaseFile}-wal`) + .then((details) => (details.isFile() ? details.size : 0)) + .catch(() => 0) + : 0; + const freelist = Number(this.#db.pragma('freelist_count', { simple: true })); + return { + runtime: { + version: this.#version, + platform: process.platform, + arch: process.arch, + uptimeSeconds: Math.floor(process.uptime()), + }, + storage: { + databaseBytes: pageCount * pageSize, + walBytes, + backupBytes: await this.#backupBytes(), + reclaimableBytes: freelist * pageSize, + components: (Object.keys(COMPONENT_TABLES) as DataComponentKey[]).map((key) => ({ + key, + count: countTable(this.#db, COMPONENT_TABLES[key]), + bytes: tableBytes(this.#db, COMPONENT_TABLES[key]), + })), + }, + retention: await this.getRetention(), + }; + } + + async #backupBytes(): Promise { + const names = await readdir(this.#backupDirectory).catch(() => [] as string[]); + let total = 0; + for (const name of names.filter((entry) => entry.startsWith('multi-simadmin-'))) { + const details = await stat(join(this.#backupDirectory, name)).catch(() => undefined); + if (details?.isFile()) total += details.size; + } + return total; + } + + defaultRetention(): Readonly> { + return cloneRetention(DEFAULT_RETENTION); + } + + async getRetention(): Promise>> { + const row = this.#db + .prepare('SELECT value_json FROM app_settings WHERE key = ?') + .get(RETENTION_SETTING_KEY) as { value_json?: string } | undefined; + if (!row?.value_json) return this.defaultRetention(); + return validateRetention(JSON.parse(row.value_json)); + } + + async updateRetention( + value: unknown, + ): Promise>> { + const policy = validateRetention(value); + const now = this.#now().toISOString(); + this.#db + .prepare( + `INSERT INTO app_settings (key,value_json,created_at,updated_at) + VALUES (?,?,?,?) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, + updated_at = excluded.updated_at`, + ) + .run(RETENTION_SETTING_KEY, JSON.stringify(policy), now, now); + return policy; + } + + async cleanup( + components: readonly DataComponentKey[], + ): Promise>> { + const known = new Set(Object.keys(COMPONENT_TABLES)); + if (components.some((component) => !known.has(component))) + throw new Error('Unknown data component'); + const retention = await this.getRetention(); + const result: Partial> = {}; + + for (const component of components) { + const policy = retention[component]; + const table = COMPONENT_TABLES[component]; + if (!policy.enabled || !tableExists(this.#db, table)) continue; + const cutoff = new Date(this.#now().getTime() - policy.days * 86_400_000).toISOString(); + if (component === 'auditEvents') + result[component] = this.#delete(`DELETE FROM ${table} WHERE created_at < ?`, cutoff); + else if (component === 'jobs') + result[component] = this.#delete( + `DELETE FROM ${table} WHERE created_at < ? AND status NOT IN ('running','pending')`, + cutoff, + ); + else if (component === 'scheduledRuns') + result[component] = this.#delete(`DELETE FROM ${table} WHERE due_at < ?`, cutoff); + else if (component === 'eventJournal') + result[component] = this.#delete(`DELETE FROM ${table}`); + else if (component === 'connectionLogs') + result[component] = this.#delete(`DELETE FROM ${table} WHERE observed_at < ?`, cutoff); + else if (policy.maximumCount > 0) + result[component] = this.#delete( + `DELETE FROM ${table} WHERE rowid IN ( + SELECT rowid FROM ${table} LIMIT ? + )`, + Math.max(0, countTable(this.#db, table) - policy.maximumCount), + ); + } + return result; + } + + async optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }> { + this.#db.pragma('wal_checkpoint(TRUNCATE)'); + this.#db.exec('VACUUM'); + this.#db.exec('ANALYZE'); + return { checkpointed: true, vacuumed: true, analyzed: true }; + } + + async createBackup(): Promise { + await mkdir(this.#backupDirectory, { recursive: true }); + const filename = `multi-simadmin-${this.#now().toISOString().replaceAll(/[:.]/gu, '-')}.db`; + const path = join(this.#backupDirectory, filename); + await this.#db.backup(path); + await chmod(path, 0o600); + const details = await stat(path); + await this.#pruneBackups(); + return { + filename, + path, + sizeBytes: details.size, + sha256: await readFile(path).then((content) => + createHash('sha256').update(content).digest('hex'), + ), + createdAt: this.#now().toISOString(), + }; + } + + async listBackups(): Promise { + await mkdir(this.#backupDirectory, { recursive: true }); + const names = (await readdir(this.#backupDirectory)) + .filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db')) + .sort() + .reverse(); + const backups: MaintenanceBackup[] = []; + for (const filename of names.slice(0, this.#maximumBackups)) { + const path = join(this.#backupDirectory, filename); + const details = await stat(path); + if (!details.isFile()) continue; + backups.push({ + filename, + path, + sizeBytes: details.size, + sha256: createHash('sha256') + .update(await readFile(path)) + .digest('hex'), + createdAt: details.mtime.toISOString(), + }); + } + return backups; + } + + /** Resolves a listed backup name to a path that stays inside the backup directory. */ + #backupPath(filename: string): string { + if ( + !/^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u.test(filename) || + basename(filename) !== filename + ) + throw new TypeError('Backup filename is invalid'); + return join(this.#backupDirectory, filename); + } + + async backupFile(filename: string): Promise { + const path = this.#backupPath(filename); + const details = await stat(path).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + if (!details?.isFile()) return undefined; + return { + filename, + path, + sizeBytes: details.size, + createdAt: details.mtime.toISOString(), + }; + } + + async deleteBackup(filename: string): Promise<{ filename: string }> { + await rm(this.#backupPath(filename), { force: true }); + return { filename }; + } + + /** Names sort newest-first because they embed an ISO timestamp. */ + async #pruneBackups(): Promise { + const names = (await readdir(this.#backupDirectory)) + .filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db')) + .sort() + .reverse(); + let removed = 0; + for (const filename of names.slice(this.#maximumBackups)) { + await rm(join(this.#backupDirectory, filename), { force: true }); + removed += 1; + } + return removed; + } + + #delete(statement: string, ...parameters: readonly unknown[]): number { + return Number(this.#db.prepare(statement).run(...parameters).changes); + } +} diff --git a/apps/api/src/canary-gateway.test.ts b/apps/api/src/canary-gateway.test.ts index e80632a..f804056 100644 --- a/apps/api/src/canary-gateway.test.ts +++ b/apps/api/src/canary-gateway.test.ts @@ -75,7 +75,11 @@ async function rawSocketRequest(origin: string, request: string): Promise { - const gateway = createCanaryGateway({ distDir, port: 0, upstreamPort }); + const gateway = createCanaryGateway({ + distDir, + port: 0, + upstreamPort, + }); gateways.push(gateway); await gateway.start(); return gateway; diff --git a/apps/api/src/cli-runtime.test.ts b/apps/api/src/cli-runtime.test.ts index 9c1c0de..067eaa8 100644 --- a/apps/api/src/cli-runtime.test.ts +++ b/apps/api/src/cli-runtime.test.ts @@ -91,6 +91,31 @@ async function expectPortFree(port: number): Promise { await closeServer(server); } +async function isPortFree(port: number): Promise { + try { + const server = await listenOn(port); + await closeServer(server); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') return false; + throw error; + } +} + +/** + * Reserves a port the OS reports as free. The canary gateway accepts a configured + * port, so its test does not need to fight a live deployment for 8789. + */ +async function reserveFreePort(): Promise { + const server = await listenOn(0); + const address = server.address(); + if (address === null || typeof address === 'string') + throw new Error('Could not reserve a free port'); + const port = address.port; + await closeServer(server); + return port; +} + async function listenerPids(port: number): Promise { try { const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']); @@ -134,7 +159,10 @@ describe.sequential('executable package runtimes', () => { expect(acknowledged.child.exitCode).not.toBe(0); }, 20_000); - it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => { + // The production executable refuses any port other than 8790 on purpose, so this + // check only runs when a live deployment is not already holding that port. + it('starts the exact production command on 8790 and shuts down on SIGTERM', async (context) => { + if (!(await isPortFree(8790))) return context.skip(); await expectPortFree(8790); const legacyListeners = await listenerPids(8788); const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-')); @@ -166,8 +194,9 @@ describe.sequential('executable package runtimes', () => { expect(command.output()).not.toContain(gatewayToken); }, 20_000); - it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => { - const port = 8789; + it('starts the exact canary command with synthetic dist and shuts down on SIGTERM', async () => { + const port = await reserveFreePort(); + const upstreamPort = await reserveFreePort(); await expectPortFree(port); const legacyListeners = await listenerPids(8788); const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-')); @@ -177,7 +206,7 @@ describe.sequential('executable package runtimes', () => { const command = startPackageCommand('canary', { CANARY_DIST_DIR: directory, CANARY_PORT: String(port), - CANARY_UPSTREAM_PORT: '8790', + CANARY_UPSTREAM_PORT: String(upstreamPort), MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken, }); await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`); diff --git a/apps/api/src/control-plane.operations.test.ts b/apps/api/src/control-plane.operations.test.ts index b1cb732..a2e1e6d 100644 --- a/apps/api/src/control-plane.operations.test.ts +++ b/apps/api/src/control-plane.operations.test.ts @@ -27,6 +27,10 @@ it('registers the read-only operations catalog without touching upstream', async upstreamCalls += 1; throw new Error('unexpected'); }, + postBasebandRestart: async () => { + upstreamCalls += 1; + throw new Error('unexpected'); + }, postSystemReboot: async () => { upstreamCalls += 1; throw new Error('unexpected'); diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index 372b1b4..bf42e3a 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -78,6 +78,7 @@ describe('buildControlPlaneApp', () => { request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async () => ({ status: 200 }), }, now: () => new Date('2026-07-30T02:00:00.000Z'), @@ -103,6 +104,7 @@ describe('buildControlPlaneApp', () => { request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async () => ({ status: 200 }), }, }); @@ -121,6 +123,71 @@ describe('buildControlPlaneApp', () => { await app.close(); }); + it('serves native system maintenance from the control plane composition', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async () => ({ status: 200, headers: {}, body: '' }), + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + }); + + const response = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance' }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + runtime: { version: '0.1.0', platform: process.platform, arch: process.arch }, + storage: { components: expect.any(Array) }, + retention: { auditEvents: { enabled: true, days: 180, maximumCount: 50_000 } }, + }); + await app.close(); + }); + + it('serves central notification configuration from the control plane composition', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async () => ({ status: 200, headers: {}, body: '' }), + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + }); + + const created = await app.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.body).not.toContain('opaque-device-key'); + const rules = await app.inject('/api/v1/notifications/rules'); + expect(rules.statusCode).toBe(200); + expect(rules.json()).toEqual({ items: [], page: { page: 1, pageSize: 50, total: 0 } }); + await app.close(); + }); + it('passes an explicit event-stream authenticator to durable cursor replay', async () => { const db = new Database(':memory:'); db.pragma('foreign_keys=ON'); @@ -134,6 +201,7 @@ describe('buildControlPlaneApp', () => { request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async () => ({ status: 200 }), }, authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed', @@ -257,6 +325,7 @@ describe('buildControlPlaneApp', () => { }, postNetworkRegisterAuto: async () => ({ status: 200 }), postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async () => ({ status: 200 }), }, }); @@ -287,6 +356,664 @@ describe('buildControlPlaneApp', () => { await app.close(); }); + it('keeps the hub namespace closed while serving a native fleet overview aggregate', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async () => ({ status: 200, headers: {}, body: '{"status":"success"}' }), + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + }); + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' }, + }); + expect(created.statusCode).toBe(201); + const instance = created.json(); + + const hubResponse = await app.inject({ method: 'GET', url: '/api/v1/hub/overview' }); + expect(hubResponse.statusCode).toBe(404); + const overview = await app.inject({ method: 'GET', url: '/api/v1/fleet/overview' }); + expect(overview.statusCode).toBe(200); + expect(overview.json()).toEqual({ + items: [ + { + ...instance, + connection: { + probed: false, + reachable: false, + authenticated: false, + checkedAt: null, + }, + resources: {}, + }, + ], + }); + + // A live heartbeat snapshot turns the same row into a reported online device. + db.prepare( + `INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) + VALUES ('snap-1',?, 'connection','fresh',?,?,?,?)`, + ).run( + instance.id, + JSON.stringify({ reachable: true, authenticated: true }), + '2026-07-16T12:00:00.000Z', + '2099-01-01T00:00:00.000Z', + '2026-07-16T12:00:00.000Z', + ); + const probed = await app.inject({ method: 'GET', url: '/api/v1/fleet/overview' }); + expect(probed.json().items[0].connection).toEqual({ + probed: true, + reachable: true, + authenticated: true, + checkedAt: '2026-07-16T12:00:00.000Z', + }); + + await app.close(); + }); + + it('serves cross-node message management from the Fleet domain', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async (request) => { + if (request.url.endsWith('/api/auth/login')) + return { + status: 200, + headers: { 'set-cookie': 'simadmin_session=fleet-msg-session' }, + body: '{"status":"success"}', + }; + if (request.url.includes('/api/sms/list')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + messages: [ + { + id: 2, + direction: 'incoming', + phone_number: '10086', + content: 'Alpha balance updated', + timestamp: '2026-07-17T01:02:00.000Z', + status: 'received', + transport: 'gsm', + }, + { + id: 1, + direction: 'outgoing', + phone_number: '13900139000', + content: 'Hello Fleet', + timestamp: '2026-07-17T00:59:00.000Z', + status: 'delivered', + transport: 'modem', + }, + ], + }, + }), + }; + if (request.url.endsWith('/api/sms/send')) + return { status: 200, headers: {}, body: '{"status":"success"}' }; + if (request.url.endsWith('/api/sms/batch-delete')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { deleted: 2 }, + }), + }; + return { status: 200, headers: {}, body: '{"status":"success","data":{}}' }; + }, + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + now: () => new Date('2026-07-16T12:00:00.000Z'), + }); + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { + name: 'Message Node', + origin: 'http://192.168.1.40:8080', + password: { action: 'set', password: '[REDACTED]' }, + }, + }); + expect(created.statusCode).toBe(201); + const instanceId = created.json().id as string; + + const list = await app.inject({ + method: 'GET', + url: '/api/v1/fleet/messages?limit=10&offset=0&search=Alpha', + }); + + expect(list.statusCode).toBe(200); + expect(list.json()).toEqual({ + messages: [ + expect.objectContaining({ + instanceId, + instanceName: 'Message Node', + phoneNumber: '10086', + content: 'Alpha balance updated', + }), + ], + devices: [{ id: instanceId, name: 'Message Node', availability: 'online' }], + total: 1, + }); + + const conversations = await app.inject({ + method: 'GET', + url: '/api/v1/fleet/messages/conversations?limit=10&offset=0&direction=incoming', + }); + expect(conversations.statusCode).toBe(200); + expect(conversations.json()).toEqual({ + conversations: [ + expect.objectContaining({ + instanceId, + instanceName: 'Message Node', + phoneNumber: '10086', + messageCount: 1, + incomingCount: 1, + lastMessage: expect.objectContaining({ content: 'Alpha balance updated' }), + }), + ], + total: 1, + // Direction counters describe the whole filtered archive, not just the returned page. + stats: { incoming: 1, outgoing: 1, total: 2 }, + }); + + const badDirection = await app.inject({ + method: 'GET', + url: '/api/v1/fleet/messages/conversations?direction=both', + }); + expect(badDirection.statusCode).toBe(400); + // The plain message list has no direction concept and must keep rejecting the parameter. + expect( + ( + await app.inject({ + method: 'GET', + url: '/api/v1/fleet/messages?limit=10&offset=0&direction=incoming', + }) + ).statusCode, + ).toBe(400); + + const sent = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/send', + payload: { + instanceId, + phoneNumber: '13900139000', + content: 'Verified via Fleet', + }, + }); + expect(sent.statusCode).toBe(200); + expect(sent.json()).toEqual({ sent: true, queued: false, instanceId }); + + const deleted = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/delete', + payload: { + items: [ + { instanceId, id: 2 }, + { instanceId, id: 1 }, + ], + }, + }); + expect(deleted.statusCode).toBe(200); + expect(deleted.json()).toEqual({ + requested: 2, + deleted: 2, + failed: 0, + failures: [], + }); + + const missing = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/send', + payload: { + instanceId: 'missing-instance', + phoneNumber: '13900139000', + content: 'No target', + }, + }); + expect(missing.statusCode).toBe(404); + expect(missing.headers['content-type']).toContain('application/problem+json'); + expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' }); + await app.close(); + }); + + it('queues fleet sends for offline nodes and drains them from the outbox routes', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const delivered: string[] = []; + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async (request) => { + if (request.url.endsWith('/api/auth/login')) + return { + status: 200, + headers: { 'set-cookie': 'simadmin_session=fleet-outbox-session' }, + body: '{\"status\":\"success\"}', + }; + if (request.url.endsWith('/api/sms/send')) { + delivered.push(request.body ?? ''); + return { status: 200, headers: {}, body: '{\"status\":\"success\"}' }; + } + return { status: 200, headers: {}, body: '{\"status\":\"success\",\"data\":{}}' }; + }, + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + now: () => new Date('2026-07-16T12:00:00.000Z'), + }); + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { + name: 'Sleeping Node', + origin: 'http://192.168.1.41:8080', + password: { action: 'set', password: '[REDACTED]' }, + }, + }); + expect(created.statusCode).toBe(201); + const instanceId = created.json().id as string; + + // The heartbeat journal says this node dropped off the LAN before the fixed clock time. + db.prepare( + `INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at) + VALUES ('snap-outbox',?, 'connection','fresh',?,?,?,?)`, + ).run( + instanceId, + JSON.stringify({ reachable: true, authenticated: true }), + '2026-07-16T11:58:00.000Z', + '2026-07-16T11:59:00.000Z', + '2026-07-16T11:58:00.000Z', + ); + + const queued = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/send', + payload: { instanceId, phoneNumber: '13900139000', content: 'Wait for the node' }, + }); + expect(queued.statusCode).toBe(200); + expect(queued.json()).toEqual({ + sent: false, + queued: true, + instanceId, + queueId: expect.any(String), + }); + expect(delivered).toHaveLength(0); + const queueId = queued.json().queueId as string; + + const page = await app.inject({ + method: 'GET', + url: '/api/v1/fleet/messages/outbox?status=open&limit=10&offset=0', + }); + expect(page.statusCode).toBe(200); + expect(page.json()).toEqual({ + items: [ + expect.objectContaining({ + id: queueId, + instanceId, + instanceName: 'Sleeping Node', + phoneNumber: '13900139000', + content: 'Wait for the node', + status: 'queued', + attempts: 0, + }), + ], + total: 1, + summary: { queued: 1, sending: 0, failed: 0, sent: 0 }, + }); + + // Still offline, so a manual drain defers the item instead of burning an attempt. + const deferred = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/outbox/flush', + }); + expect(deferred.statusCode).toBe(200); + expect(deferred.json()).toEqual({ + attempted: 1, + delivered: 0, + deferred: 1, + failed: 0, + remaining: 1, + }); + expect(delivered).toHaveLength(0); + + const cancelled = await app.inject({ + method: 'POST', + url: `/api/v1/fleet/messages/outbox/${queueId}/cancel`, + }); + expect(cancelled.statusCode).toBe(200); + expect(cancelled.json()).toMatchObject({ id: queueId, status: 'cancelled' }); + + const retried = await app.inject({ + method: 'POST', + url: `/api/v1/fleet/messages/outbox/${queueId}/retry`, + }); + expect(retried.statusCode).toBe(200); + expect(retried.json()).toMatchObject({ id: queueId, status: 'queued', attempts: 1 }); + + // The node comes back: the snapshot is refreshed and the queue drains for real. + db.prepare(`UPDATE status_snapshots SET expires_at=? WHERE id='snap-outbox'`).run( + '2099-01-01T00:00:00.000Z', + ); + const flushed = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/outbox/flush', + }); + expect(flushed.statusCode).toBe(200); + expect(flushed.json()).toEqual({ + attempted: 1, + delivered: 1, + deferred: 0, + failed: 0, + remaining: 0, + }); + expect(delivered).toHaveLength(1); + + // A reachable node keeps the old fast path: no queue row at all. + const direct = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/send', + payload: { instanceId, phoneNumber: '13900139000', content: 'Delivered now' }, + }); + expect(direct.json()).toEqual({ sent: true, queued: false, instanceId }); + expect(delivered).toHaveLength(2); + + const dropped = await app.inject({ + method: 'DELETE', + url: `/api/v1/fleet/messages/outbox/${queueId}`, + }); + expect(dropped.statusCode).toBe(200); + expect(dropped.json()).toEqual({ removed: true, queueId }); + expect( + (await app.inject({ method: 'GET', url: '/api/v1/fleet/messages/outbox?status=all' })).json(), + ).toMatchObject({ total: 0 }); + + const badId = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/outbox/not%20an%20id/cancel', + }); + expect(badId.statusCode).toBe(400); + const missing = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/messages/outbox/q-999/retry', + }); + expect(missing.statusCode).toBe(404); + expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' }); + await app.close(); + }); + + it('aggregates notification channels, rules, logs and queue in the Fleet center', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async (request) => { + if (request.url.endsWith('/api/auth/login')) + return { + status: 200, + headers: { 'set-cookie': 'simadmin_session=fleet-notification-session' }, + body: '{"status":"success"}', + }; + if (request.url.endsWith('/api/notifications/config')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + channels: [ + { type: 'webhook', enabled: true }, + { type: 'bark', enabled: false }, + { type: 'wecom_robot', enabled: true }, + ], + rules: [{ enabled: true }, { enabled: false }], + }, + }), + }; + if (request.url.includes('/api/notifications/logs')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + total: 5, + logs: [ + { + id: 'log-1', + event_type: 'balance', + status: 'success', + rule_name: 'Balance Alert', + channel_name: 'webhook', + created_at: '2026-07-17T01:02:00.000Z', + }, + { + id: 'log-2', + event_type: 'low-signal', + status: 'failed', + rule_name: 'Signal Alert', + channel_name: 'bark', + created_at: '2026-07-17T00:59:00.000Z', + }, + { + id: 'log-3', + event_type: 'quiet', + status: 'quiet_hours', + created_at: '2026-07-17T00:58:00.000Z', + }, + { + id: 'log-4', + event_type: 'unknown', + status: 'unmatched', + created_at: '2026-07-17T00:57:00.000Z', + }, + { + id: 'log-5', + event_type: 'no-channel', + status: 'no_available_channel', + created_at: '2026-07-17T00:56:00.000Z', + }, + ], + }, + }), + }; + if ( + request.url.includes('/api/notifications/queue') && + (request.method === 'POST' || request.method === 'DELETE') + ) + return { + status: 200, + headers: {}, + body: '{"status":"success"}', + }; + if (request.url.includes('/api/notifications/queue')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + total: 3, + items: [ + { + id: 'q-1', + status: 'pending', + event_type: 'balance', + rule_name: 'Balance Alert', + channel_name: 'webhook', + created_at: '2026-07-17T01:03:00.000Z', + }, + { + id: 'q-2', + status: 'scheduled', + event_type: 'report', + created_at: '2026-07-17T01:04:00.000Z', + }, + { + id: 'q-3', + status: 'retrying', + event_type: 'restart', + created_at: '2026-07-17T01:05:00.000Z', + }, + ], + }, + }), + }; + return { status: 200, headers: {}, body: '{"status":"success","data":{}}' }; + }, + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + now: () => new Date('2026-07-16T12:00:00.000Z'), + }); + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { + name: 'Notification Node', + origin: 'http://192.168.1.50:8080', + password: { action: 'set', password: '[REDACTED]' }, + }, + }); + expect(created.statusCode).toBe(201); + const instanceId = created.json().id as string; + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/fleet/notifications', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + observedAt: expect.any(String), + deviceCount: 1, + readyCount: 1, + unavailableCount: 0, + devices: [{ id: instanceId, name: 'Notification Node', state: 'ready' }], + config: { + channelCount: 3, + channelEnabled: 2, + ruleCount: 2, + ruleEnabled: 1, + channelTypes: [ + { type: 'webhook', total: 1, enabled: 1 }, + { type: 'bark', total: 1, enabled: 0 }, + { type: 'wecom_robot', total: 1, enabled: 1 }, + ], + }, + logs: { + total: 5, + success: 1, + failed: 1, + quietHours: 1, + unmatched: 1, + noAvailableChannel: 1, + other: 0, + }, + queue: { + total: 3, + pending: 1, + scheduled: 1, + retrying: 1, + sending: 0, + failed: 0, + }, + }); + expect(response.json().logs.recent[0]).toMatchObject({ + id: 'log-1', + eventType: 'balance', + status: 'success', + ruleName: 'Balance Alert', + channelName: 'webhook', + }); + expect(response.json().queue.recent[0]).toMatchObject({ + id: 'q-1', + eventType: 'balance', + status: 'pending', + }); + + const requested = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/notifications/queue/retry-all', + }); + expect(requested.statusCode).toBe(200); + expect(requested.json()).toEqual({ + requested: 1, + succeeded: 1, + failed: 0, + skipped: 0, + failures: [], + }); + + const retried = await app.inject({ + method: 'POST', + url: `/api/v1/fleet/notifications/queue/${instanceId}/items/q-1/retry`, + }); + expect(retried.statusCode).toBe(200); + expect(retried.json()).toEqual({ retried: true }); + + const deleted = await app.inject({ + method: 'DELETE', + url: `/api/v1/fleet/notifications/queue/${instanceId}/items/q-1`, + }); + expect(deleted.statusCode).toBe(200); + expect(deleted.json()).toEqual({ deleted: true }); + + const missing = await app.inject({ + method: 'POST', + url: '/api/v1/fleet/notifications/queue/missing-instance/items/q-1/retry', + }); + expect(missing.statusCode).toBe(404); + expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' }); + + const invalidQueueId = await app.inject({ + method: 'POST', + url: `/api/v1/fleet/notifications/queue/${instanceId}/items/bad%20id/retry`, + }); + expect(invalidQueueId.statusCode).toBe(400); + expect(invalidQueueId.json()).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' }); + await app.close(); + }); + it('attaches an origin-bound session cookie when executing restart operations', async () => { const db = new Database(':memory:'); db.pragma('foreign_keys=ON'); @@ -313,6 +1040,7 @@ describe('buildControlPlaneApp', () => { calls.push({ kind: 'restart', origin, cookie }); return { status: 200 }; }, + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async (origin, delaySeconds, cookie) => { calls.push({ kind: 'reboot', origin, delaySeconds, cookie }); return { status: 200 }; @@ -386,6 +1114,7 @@ describe('buildControlPlaneApp', () => { request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async () => ({ status: 200 }), }, }); @@ -410,4 +1139,81 @@ describe('buildControlPlaneApp', () => { expect(response.body).not.toContain('simadmin_session'); await app.close(); }); + + it('keeps a failed SMS node visible instead of pretending it has no messages', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + let smsListCall = 0; + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async (request) => { + if (request.url.endsWith('/api/auth/login')) + return { + status: 200, + headers: { 'set-cookie': 'simadmin_session=fleet-availability-session' }, + body: '{"status":"success"}', + }; + if (request.url.includes('/api/sms/list') && ++smsListCall === 1) + return { status: 502, headers: {}, body: '{"status":"error"}' }; + if (request.url.includes('/api/sms/list')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + messages: [ + { + id: 1, + direction: 'incoming', + phone_number: '10086', + content: 'Healthy node', + timestamp: '2026-07-17T01:00:00.000Z', + status: 'received', + }, + ], + }, + }), + }; + return { status: 200, headers: {}, body: '{"status":"success","data":{}}' }; + }, + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + }); + + for (const [index, name] of ['Healthy', 'Broken'].entries()) { + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { + name, + origin: `http://192.168.1.${40 + index}:8080`, + password: { action: 'set', password: '[REDACTED]' }, + }, + }); + expect(created.statusCode).toBe(201); + } + + const response = await app.inject({ method: 'GET', url: '/api/v1/fleet/messages' }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.devices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Healthy', availability: 'online' }), + expect.objectContaining({ name: 'Broken', availability: 'unavailable' }), + ]), + ); + expect(body.messages).toEqual([expect.objectContaining({ content: 'Healthy node' })]); + expect(body.total).toBe(1); + await app.close(); + }); }); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index de0e1e6..a9d80ee 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -12,6 +12,8 @@ import { ConnectionProbe, type ConnectionTransport, } from './application/connections/connection-probe.js'; +import { ConnectionSettingsService } from './application/connections/connection-settings-service.js'; +import { FleetHeartbeatCoordinator } from './application/connections/fleet-heartbeat.js'; import { InstanceCredentialResolver } from './application/connections/instance-credential-resolver.js'; import { InstanceLoginService } from './application/connections/instance-login-service.js'; import { @@ -20,11 +22,13 @@ import { type UpstreamSessionClientOptions, } from './application/connections/upstream-session-client.js'; import { InstanceService } from './application/instances/instance-service.js'; +import { DeviceDiscoveryService } from './application/instances/device-discovery-service.js'; import { DeleteInstanceOperation } from './application/operations/delete-instance-operation.js'; import type { SecretStore } from './infrastructure/secrets/secret-store.js'; import { registerInstanceRoutes } from './interface/http/instance-routes.js'; import { registerEventRoutes } from './interface/http/event-routes.js'; import { JobQueryService } from './application/jobs/job-query-service.js'; +import { JobReconcileService } from './application/jobs/job-reconcile-service.js'; import { registerJobRoutes } from './interface/http/job-routes.js'; import { AuditQueryService } from './application/audit/audit-query-service.js'; import { registerAuditRoutes } from './interface/http/audit-routes.js'; @@ -32,9 +36,28 @@ import { InstanceResourceService } from './application/resources/instance-resour import { ConsoleAuthService } from './application/auth/console-auth-service.js'; import { registerConsoleAuth } from './interface/http/console-auth-routes.js'; import { InstanceMessageService } from './application/messages/instance-message-service.js'; +import { HubMessageService } from './application/messages/hub-message-service.js'; +import { SmsOutboxService } from './application/messages/sms-outbox-service.js'; import { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js'; import { ScheduledTaskService } from './application/automation/scheduled-task-service.js'; import { registerAutomationRoutes } from './interface/http/automation-routes.js'; +import { registerFleetRoutes } from './interface/http/fleet-routes.js'; +import { SystemMaintenanceService } from './application/system/system-maintenance-service.js'; +import { ComponentBackupService } from './application/system/component-backup-service.js'; +import { registerSystemRoutes } from './interface/http/system-routes.js'; +import { InstanceNotificationService } from './application/notifications/instance-notification-service.js'; +import { CentralNotificationService } from './application/notifications/central-notification-service.js'; +import { registerCentralNotificationRoutes } from './interface/http/central-notification-routes.js'; +import { DeviceOrganizationService } from './application/organization/device-organization-service.js'; +import { registerOrganizationRoutes } from './interface/http/organization-routes.js'; +import { ConnectionLogService } from './application/system/connection-log-service.js'; +import { LogCenterService } from './application/system/log-center-service.js'; +import { registerLogCenterRoutes } from './interface/http/log-center-routes.js'; +import { InstanceModuleService } from './application/instances/instance-module-service.js'; +import { registerInstanceModuleRoutes } from './interface/http/instance-module-routes.js'; +import { DeviceActionService } from './application/instances/device-action-service.js'; +import { registerDeviceActionRoutes } from './interface/http/device-action-routes.js'; +import { registerDiscoveryRoutes } from './interface/http/discovery-routes.js'; import { ScheduledOperationDispatcher } from './application/automation/scheduled-operation-dispatcher.js'; import { SchedulerCoordinator } from './application/automation/scheduler-coordinator.js'; @@ -42,6 +65,7 @@ export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>; postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>; + postBasebandRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>; postSystemReboot( origin: string, delaySeconds: number, @@ -54,14 +78,78 @@ export interface ControlPlaneOptions { readonly upstream: SafeControlPlaneUpstream; readonly now?: () => Date; readonly authenticateEventStream?: (request: FastifyRequest) => boolean; + readonly runtimeVersion?: string; + readonly backupDirectory?: string; + readonly smsSyncIntervalMs?: number; + readonly queueDrainIntervalMs?: number; + readonly logPruneIntervalMs?: number; + readonly autoBackupIntervalMs?: number; + /** Keep device online state fresh on its own; off in tests, on for the production gateway. */ + readonly heartbeatEnabled?: boolean; readonly app?: Omit; } + +const DEFAULT_SMS_SYNC_INTERVAL_MS = 300_000; +const DEFAULT_QUEUE_DRAIN_INTERVAL_MS = 15_000; +const DEFAULT_LOG_PRUNE_INTERVAL_MS = 900_000; +const DEFAULT_AUTO_BACKUP_INTERVAL_MS = 300_000; + +interface HubDeviceSummary { + readonly id: string; + readonly name: string; + readonly state: 'ready' | 'unavailable' | 'unknown'; + readonly tags: readonly string[]; +} + +// Device reachability comes from the last ConnectionProbe result, never from a live probe: +// the notification overview must stay cheap enough to poll. +function createHubDeviceReader( + db: Database.Database, + instances: InstanceService, +): () => Promise { + const readConnections = db.prepare( + `SELECT instance_id, state FROM status_snapshots + WHERE category = 'connection' AND expires_at > ?`, + ); + return async () => { + const now = new Date().toISOString(); + const reachable = new Set( + (readConnections.all(now) as readonly { instance_id: string; state: string }[]) + .filter((row) => row.state === 'fresh' || row.state === 'stale') + .map((row) => row.instance_id), + ); + const devices: HubDeviceSummary[] = []; + let page = 1; + while (devices.length < 200) { + const current = await instances.list({ page, pageSize: 100 }); + for (const instance of current.items.slice(0, 200 - devices.length)) { + devices.push({ + id: instance.id, + name: instance.name, + state: reachable.has(instance.id) ? 'ready' : 'unknown', + tags: [...instance.tags], + }); + } + if (current.items.length < 100) break; + page += 1; + } + return devices; + }; +} + export interface ControlPlaneApp extends FastifyInstance { retryPendingSecretCleanup(): Promise; } export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp { const eventJournal = new EventJournal(options.db); const jobs = new JobQueryService(options.db); + const jobReconcile = new JobReconcileService({ + db: options.db, + ...(options.now ? { now: options.now } : {}), + }); + // Nothing can be mid-flight before the first request is served, so a startup sweep closes every + // job row a previous process left behind. + jobReconcile.reconcile(0); const audit = new AuditQueryService(options.db); const scheduledTasks = new ScheduledTaskRepository(options.db); scheduledTasks.reconcileInterruptedRuns((options.now?.() ?? new Date()).toISOString()); @@ -73,15 +161,36 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane const instances = options.now ? new InstanceService({ db: options.db, store: options.store, now: options.now }) : new InstanceService({ db: options.db, store: options.store }); + const connectionLogs = new ConnectionLogService({ db: options.db }); + const connectionSettings = options.now + ? new ConnectionSettingsService({ db: options.db, now: options.now }) + : new ConnectionSettingsService({ db: options.db }); const connections = options.now ? new ConnectionProbe({ db: options.db, instances, transport: options.upstream, now: options.now, + connectionLogs, + snapshotTtlMs: () => connectionSettings.snapshotTtlMs, }) - : new ConnectionProbe({ db: options.db, instances, transport: options.upstream }); + : new ConnectionProbe({ + db: options.db, + instances, + transport: options.upstream, + connectionLogs, + snapshotTtlMs: () => connectionSettings.snapshotTtlMs, + }); + const heartbeat = new FleetHeartbeatCoordinator({ + instances, + probe: connections, + settings: connectionSettings, + ...(options.now ? { now: options.now } : {}), + }); const sessions = new InstanceSessionStore(); + const discovery = options.now + ? new DeviceDiscoveryService({ transport: options.upstream, instances, now: options.now }) + : new DeviceDiscoveryService({ transport: options.upstream, instances }); const client = new UpstreamSessionClient({ sessions, request: options.upstream.request }); const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store }); const login = options.now @@ -119,10 +228,52 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane request: options.upstream.request, ensureSession, }); + const notifications = new InstanceNotificationService({ + instances, + sessions, + request: options.upstream.request, + ensureSession, + }); + const centralNotifications = new CentralNotificationService(options.db, { + store: options.store, + ...(options.now ? { now: options.now } : {}), + }); + const hubMessages = new HubMessageService(options.db, { + instances, + messages, + ...(options.now ? { now: options.now } : {}), + onIncomingMessage: async (instanceId, message) => { + const instance = await instances.get(instanceId); + await centralNotifications.enqueueEvent('sms', { + instanceId, + instanceTags: instance?.tags ?? [], + fields: { + sender: message.phoneNumber, + content: message.content, + title: `来自 ${message.phoneNumber} 的新短信`, + status: message.status, + }, + }); + }, + }); + // Offline sends wait here instead of failing, exactly like the Hub hands a message to a device + // that has just come back on the LAN. + const smsOutbox = new SmsOutboxService(options.db, { + send: (instanceId, input) => messages.send(instanceId, input), + offlineInstances: () => { + const offline = new Set(); + for (const [instanceId, state] of connections.reachability()) + if (!state.reachable) offline.add(instanceId); + return offline; + }, + ...(options.now ? { now: options.now } : {}), + }); + smsOutbox.reconcileInterrupted(); const deletion = options.now ? new DeleteInstanceOperation({ db: options.db, instances, now: options.now }) : new DeleteInstanceOperation({ db: options.db, instances }); deletion.reconcileInterruptedJobs(); + const listHubDevices = createHubDeviceReader(options.db, instances); const resolveOperationCookie = async ( instanceId: string, origin: string, @@ -164,6 +315,10 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane const response = await options.upstream.postServiceRestart(origin, cookie); return { status: response.status }; } + if (path === '/api/baseband/restart') { + const response = await options.upstream.postBasebandRestart(origin, cookie); + return { status: response.status }; + } if (path === '/api/system/reboot') { if (contentType !== 'application/json' || body !== JSON.stringify({ delay_seconds: 3 })) throw new Error('UPSTREAM_REQUEST_INVALID'); @@ -176,10 +331,21 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane ...(options.now ? { now: options.now } : {}), }); secureExecution.reconcileInterruptedJobs(); + const maintenance = new SystemMaintenanceService(options.db, { + version: options.runtimeVersion ?? '0.1.0', + ...(options.now ? { now: options.now } : {}), + ...(options.backupDirectory ? { backupDirectory: options.backupDirectory } : {}), + }); + const componentBackups = new ComponentBackupService(options.db, { + version: options.runtimeVersion ?? '0.1.0', + backupDirectory: options.backupDirectory ?? './data/backups', + ...(options.now ? { now: options.now } : {}), + }); const scheduledDispatcher = new ScheduledOperationDispatcher({ db: options.db, operations: secureExecution, messages, + maintenance, store: options.store, repository: scheduledTasks, ...(options.now ? { now: options.now } : {}), @@ -195,6 +361,24 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane db: options.db, ...(options.now ? { now: options.now } : {}), }); + const organization = new DeviceOrganizationService({ + db: options.db, + ...(options.now ? { now: options.now } : {}), + }); + const logCenter = new LogCenterService({ db: options.db, connections: connectionLogs }); + const instanceModules = new InstanceModuleService({ + instances, + sessions, + request: options.upstream.request, + ensureSession, + }); + const deviceActions = new DeviceActionService({ + instances, + sessions, + request: options.upstream.request, + db: options.db, + ensureSession, + }); const app = buildApp({ ...options.app, registerRoutes: (app) => { @@ -208,8 +392,20 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane messages, registerDeletionPreparationRoute: false, }); + registerFleetRoutes(app, { + instances, + resources, + messages: hubMessages, + notifications, + connections, + outbox: smsOutbox, + }); + registerCentralNotificationRoutes(app, { + notifications: centralNotifications, + devices: listHubDevices, + }); registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion); - registerJobRoutes(app, { jobs }); + registerJobRoutes(app, { jobs, reconcile: jobReconcile }); registerAuditRoutes(app, { audit }); registerAutomationRoutes(app, { service: automation, @@ -222,12 +418,73 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane ? { authenticate: options.authenticateEventStream } : {}), }); + registerSystemRoutes(app, { + maintenance, + componentBackups, + connectionSettings, + heartbeat, + }); + registerOrganizationRoutes(app, { organization }); + registerLogCenterRoutes(app, { logs: logCenter, connections: connectionLogs }); + registerInstanceModuleRoutes(app, { modules: instanceModules }); + registerDeviceActionRoutes(app, { actions: deviceActions }); + registerDiscoveryRoutes(app, { discovery }); }, }); Object.assign(app, { retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(), }); - app.addHook('onClose', async () => scheduler.stop()); + // Central Hub loops: pull device SMS into the archive and drain the delivery queue. + const smsSyncIntervalMs = options.smsSyncIntervalMs ?? DEFAULT_SMS_SYNC_INTERVAL_MS; + const queueDrainIntervalMs = options.queueDrainIntervalMs ?? DEFAULT_QUEUE_DRAIN_INTERVAL_MS; + const logPruneIntervalMs = options.logPruneIntervalMs ?? DEFAULT_LOG_PRUNE_INTERVAL_MS; + const autoBackupIntervalMs = options.autoBackupIntervalMs ?? DEFAULT_AUTO_BACKUP_INTERVAL_MS; + const messageSyncInterval = + smsSyncIntervalMs > 0 + ? setInterval(() => { + void hubMessages.syncAll().catch(() => undefined); + }, smsSyncIntervalMs) + : undefined; + const queueDrainInterval = + queueDrainIntervalMs > 0 + ? setInterval(() => { + void centralNotifications.processQueue().catch(() => undefined); + // Queued SMS ride the same drain tick so a returning device is picked up quickly. + void smsOutbox.flush().catch(() => undefined); + }, queueDrainIntervalMs) + : undefined; + const logPruneInterval = + logPruneIntervalMs > 0 + ? setInterval(() => { + void Promise.resolve() + .then(() => centralNotifications.pruneLogs()) + .catch(() => undefined); + }, logPruneIntervalMs) + : undefined; + // Scheduled component backups: the tick is cheap and only writes when a period is overdue. + const autoBackupInterval = + autoBackupIntervalMs > 0 + ? setInterval(() => { + void Promise.resolve() + .then(() => componentBackups.runDueAutoBackups()) + .catch(() => undefined); + }, autoBackupIntervalMs) + : undefined; + messageSyncInterval?.unref?.(); + queueDrainInterval?.unref?.(); + logPruneInterval?.unref?.(); + autoBackupInterval?.unref?.(); + app.addHook('onClose', async () => { + scheduler.stop(); + if (messageSyncInterval) clearInterval(messageSyncInterval); + if (queueDrainInterval) clearInterval(queueDrainInterval); + if (logPruneInterval) clearInterval(logPruneInterval); + if (autoBackupInterval) clearInterval(autoBackupInterval); + }); scheduler.start(); + if (options.heartbeatEnabled) heartbeat.start(); + app.addHook('onClose', async () => { + heartbeat.stop(); + }); return app as ControlPlaneApp; } diff --git a/apps/api/src/infrastructure/database/database.test.ts b/apps/api/src/infrastructure/database/database.test.ts index e746442..8f9c085 100644 --- a/apps/api/src/infrastructure/database/database.test.ts +++ b/apps/api/src/infrastructure/database/database.test.ts @@ -62,21 +62,30 @@ describe('database migrations', () => { 'app_settings', 'audit_events', 'capabilities', + 'connection_logs', 'console_auth_config', 'console_auth_sessions', + 'device_groups', 'event_journal', 'instance_tags', 'instances', 'job_attempts', 'job_items', 'jobs', + 'notification_channels', + 'notification_deliveries', + 'notification_queue', + 'notification_rules', 'operation_preparations', 'scheduled_runs', 'scheduled_tasks', 'schema_migrations', 'secret_cleanup_tasks', 'secret_references', + 'sms_messages', + 'sms_outbox', 'status_snapshots', + 'tag_registry', ]); expect(database.pragma('foreign_keys', { simple: true })).toBe(1); diff --git a/apps/api/src/infrastructure/database/migrations-upgrade.test.ts b/apps/api/src/infrastructure/database/migrations-upgrade.test.ts new file mode 100644 index 0000000..efef6f8 --- /dev/null +++ b/apps/api/src/infrastructure/database/migrations-upgrade.test.ts @@ -0,0 +1,103 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { MIGRATIONS, migrateDatabase } from './migrations.js'; + +function seeded(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db, MIGRATIONS.slice(0, 10)); + db.prepare( + `INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) + VALUES ('i-1','Node One','http://a','password',1,1,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')`, + ).run(); + db.prepare( + `INSERT INTO instance_tags (instance_id,tag,created_at) VALUES ('i-1','office','2026-01-01T00:00:00.000Z')`, + ).run(); + db.prepare( + `INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone, + target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at, + misfire_policy,overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by, + created_at,updated_at,deleted_at) + VALUES ('t-1','Nightly','reboot-system',1,3,'0 3 * * *','Asia/Shanghai','{"mode":"fixed","instanceIds":["i-1"]}', + NULL,NULL,NULL,NULL,'skip','skip','{"maxRetries":2,"intervalSeconds":60}', + '2026-09-04T03:00:00.000Z',NULL,'sys','sys','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z',NULL)`, + ).run(); + db.prepare( + `INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at, + started_at,finished_at,target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt) + VALUES ('r-1','t-1',3,'{}','2026-09-03T03:00:00.000Z','2026-09-03T03:00:00.000Z', + '2026-09-03T03:00:01.000Z','2026-09-03T03:00:02.000Z','["i-1"]','succeeded',NULL,'[]','scheduled',1)`, + ).run(); + return db; +} + +describe('migration upgrade path', () => { + it('preserves automation rows and registers existing tags when upgrading from migration 10', () => { + const db = seeded(); + expect(() => migrateDatabase(db)).not.toThrow(); + const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id=?').get('t-1') as Record< + string, + unknown + >; + expect(task).toMatchObject({ name: 'Nightly', operation_type: 'reboot-system', version: 3 }); + expect(task.trigger_json).toBeNull(); + const run = db.prepare('SELECT * FROM scheduled_runs WHERE id=?').get('r-1') as Record< + string, + unknown + >; + expect(run).toMatchObject({ scheduled_task_id: 't-1', outcome: 'succeeded' }); + expect(db.prepare('SELECT tag FROM tag_registry ORDER BY tag').all()).toEqual([ + { tag: 'office' }, + ]); + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]); + db.prepare( + `INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone, + target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at, + misfire_policy,overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by, + created_at,updated_at,deleted_at,trigger_json) + VALUES ('t-2','Baseband','restart-baseband',1,1,'0 4 * * *','Asia/Shanghai','{"mode":"all"}', + NULL,NULL,NULL,NULL,'skip','skip','{"maxRetries":0,"intervalSeconds":60}',NULL,NULL,'sys','sys', + '2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z',NULL,'{"kind":"interval","value":6,"unit":"hours"}')`, + ).run(); + expect( + ( + db.prepare('SELECT trigger_json FROM scheduled_tasks WHERE id=?').get('t-2') as { + trigger_json: string; + } + ).trigger_json, + ).toContain('interval'); + const names = ( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_scheduled%' ORDER BY name", + ) + .all() as Array<{ + name: string; + }> + ).map((row) => row.name); + expect(names).toEqual([ + 'idx_scheduled_runs_outcome_finished', + 'idx_scheduled_runs_task_due', + 'idx_scheduled_tasks_enabled_next_due', + ]); + db.close(); + }); + + it('applies cleanly to an empty database', () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]); + const applied = db.prepare('SELECT id,name FROM schema_migrations ORDER BY id').all() as Array<{ + id: number; + name: string; + }>; + // The ledger must match the declared migration list exactly, in order, so a new migration + // can never be added without being applied here. + expect(applied).toEqual( + MIGRATIONS.map((migration) => ({ id: migration.id, name: migration.name })), + ); + db.close(); + }); +}); diff --git a/apps/api/src/infrastructure/database/migrations.ts b/apps/api/src/infrastructure/database/migrations.ts index 6cf03ee..b2ab1a5 100644 --- a/apps/api/src/infrastructure/database/migrations.ts +++ b/apps/api/src/infrastructure/database/migrations.ts @@ -356,6 +356,288 @@ export const MIGRATIONS: readonly Migration[] = [ 'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)', ], }, + { + id: 9, + name: 'central-notifications', + statements: [ + `CREATE TABLE notification_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ( + 'webhook','bark','pushplus','wecom_app','wecom_robot','dingtalk_robot','dingtalk_app', + 'feishu_robot','telegram','email','serverchan' + )), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + config_json TEXT NOT NULL, + secret_reference TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE notification_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ( + 'sms','ddns','version','system','device','automation' + )), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + condition_json TEXT NOT NULL, + scope_json TEXT NOT NULL, + channel_ids_json TEXT NOT NULL, + templates_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE notification_deliveries ( + id TEXT PRIMARY KEY, + rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL, + channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, + instance_id TEXT, + event_type TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ( + 'success','failed','pending','sending','retrying','unmatched','no_available_channel','quiet_hours' + )), + detail TEXT, + created_at TEXT NOT NULL, + sent_at TEXT + )`, + 'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)', + 'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)', + ], + }, + { + id: 10, + name: 'hub-message-and-notification-queue-persistence', + statements: [ + `CREATE TABLE sms_messages ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL, + upstream_id TEXT NOT NULL, + direction TEXT NOT NULL CHECK (direction IN ('incoming','outgoing','received','sent','unknown')), + phone_number TEXT NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + transport TEXT NOT NULL, + synced_at TEXT NOT NULL, + UNIQUE (instance_id, upstream_id) + )`, + 'CREATE INDEX idx_sms_messages_instance_timestamp ON sms_messages(instance_id, timestamp DESC)', + 'CREATE INDEX idx_sms_messages_timestamp ON sms_messages(timestamp DESC)', + 'CREATE INDEX idx_sms_messages_phone_timestamp ON sms_messages(phone_number, timestamp DESC)', + `CREATE TABLE notification_queue ( + id TEXT PRIMARY KEY, + instance_id TEXT, + event_type TEXT NOT NULL, + rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL, + channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending','sending','succeeded','failed','cancelled' + )), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts > 0), + payload_json TEXT NOT NULL, + last_error TEXT, + available_at TEXT NOT NULL, + delivered_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + 'CREATE INDEX idx_notification_queue_status_available_at ON notification_queue(status, available_at)', + 'CREATE INDEX idx_notification_queue_created_at ON notification_queue(created_at DESC)', + ], + }, + { + id: 11, + name: 'hub-groups-tags-and-automation-triggers', + statements: [ + `CREATE TABLE device_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + 'ALTER TABLE instances ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL', + 'CREATE INDEX idx_instances_group_id ON instances(group_id)', + `CREATE TABLE tag_registry ( + tag TEXT PRIMARY KEY, + color TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `INSERT INTO tag_registry (tag, created_at, updated_at) + SELECT tag, MIN(created_at), MAX(created_at) FROM instance_tags GROUP BY tag`, + // scheduled_tasks carries an operation_type CHECK that has to widen, so both the + // task table and its only child are rebuilt; renaming the child first keeps the + // RESTRICT edge from firing while the parent is swapped out. + 'ALTER TABLE scheduled_runs RENAME TO scheduled_runs_old', + 'ALTER TABLE scheduled_tasks RENAME TO scheduled_tasks_old', + `CREATE TABLE scheduled_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + operation_type TEXT NOT NULL CHECK (operation_type IN ( + 'restart-service','reboot-system','send-sms','restart-baseband','backup-data' + )), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + cron_expression TEXT NOT NULL, + timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'), + target_selector_json TEXT NOT NULL, + sms_secret_reference TEXT, + sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0), + effective_start_at TEXT, + effective_end_at TEXT, + misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')), + overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')), + retry_policy_json TEXT NOT NULL, + next_due_at TEXT, + last_evaluated_at TEXT, + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + trigger_json TEXT, + CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)), + CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL)) + )`, + `CREATE TABLE scheduled_runs ( + id TEXT PRIMARY KEY, + scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT, + schedule_version INTEGER NOT NULL CHECK (schedule_version > 0), + task_snapshot_json TEXT NOT NULL, + due_at TEXT NOT NULL, + claimed_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + target_snapshot_json TEXT NOT NULL, + outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')), + reason TEXT, + job_ids_json TEXT NOT NULL DEFAULT '[]', + trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')), + attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0), + UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source) + )`, + `INSERT INTO scheduled_tasks (id,name,operation_type,enabled,version,cron_expression,timezone, + target_selector_json,sms_secret_reference,sms_recipient_count,effective_start_at, + effective_end_at,misfire_policy,overlap_policy,retry_policy_json,next_due_at, + last_evaluated_at,created_by,updated_by,created_at,updated_at,deleted_at,trigger_json) + SELECT id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, + sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy, + overlap_policy,retry_policy_json,next_due_at,last_evaluated_at,created_by,updated_by, + created_at,updated_at,deleted_at,NULL FROM scheduled_tasks_old`, + `INSERT INTO scheduled_runs (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at, + claimed_at,started_at,finished_at,target_snapshot_json,outcome,reason,job_ids_json, + trigger_source,attempt) + SELECT id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,started_at, + finished_at,target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt + FROM scheduled_runs_old`, + 'DROP TABLE scheduled_runs_old', + 'DROP TABLE scheduled_tasks_old', + 'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL', + 'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)', + 'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)', + ], + }, + { + id: 12, + name: 'schedule-action-delay', + statements: [ + `ALTER TABLE scheduled_tasks ADD COLUMN delay_seconds INTEGER + CHECK (delay_seconds IS NULL OR (delay_seconds >= 0 AND delay_seconds <= 3600))`, + ], + }, + { + id: 13, + name: 'notification-rule-rate-limit-and-quiet-hours', + statements: [ + `ALTER TABLE notification_rules ADD COLUMN rate_limit_json TEXT NOT NULL DEFAULT + '{"enabled":false,"maxMessages":20,"windowSeconds":60}'`, + `ALTER TABLE notification_rules ADD COLUMN quiet_hours_json TEXT NOT NULL DEFAULT '[]'`, + // Suppressions (quiet hours, rate limit) are attributed to a rule rather than a channel, + // so channel_id becomes optional. The status CHECK also has to widen, which means the + // table is rebuilt; nothing references notification_deliveries, so the swap is contained. + `CREATE TABLE notification_deliveries_new ( + id TEXT PRIMARY KEY, + rule_id TEXT REFERENCES notification_rules(id) ON DELETE SET NULL, + channel_id TEXT REFERENCES notification_channels(id) ON DELETE CASCADE, + instance_id TEXT, + event_type TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ( + 'success','failed','pending','sending','retrying','unmatched','no_available_channel', + 'quiet_hours','rate_limited' + )), + detail TEXT, + created_at TEXT NOT NULL, + sent_at TEXT + )`, + `INSERT INTO notification_deliveries_new + (id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at) + SELECT id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at + FROM notification_deliveries`, + 'DROP TABLE notification_deliveries', + 'ALTER TABLE notification_deliveries_new RENAME TO notification_deliveries', + 'CREATE INDEX idx_notification_deliveries_created_at ON notification_deliveries(created_at DESC)', + 'CREATE INDEX idx_notification_deliveries_status_created_at ON notification_deliveries(status, created_at DESC)', + ], + }, + { + id: 14, + name: 'connection-log-history', + statements: [ + // The Hub keeps a rolling connection history per device; the snapshot table only holds + // the latest probe, so outcomes need their own bounded journal. + `CREATE TABLE connection_logs ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + outcome TEXT NOT NULL CHECK (outcome IN ('success','stale','failed','unsupported')), + state TEXT NOT NULL CHECK (state IN ('fresh','stale','expired','unknown')), + error_code TEXT, + http_status INTEGER, + duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0), + observed_at TEXT NOT NULL + )`, + 'CREATE INDEX idx_connection_logs_observed_at ON connection_logs(observed_at DESC)', + 'CREATE INDEX idx_connection_logs_instance_observed_at ON connection_logs(instance_id, observed_at DESC)', + 'CREATE INDEX idx_connection_logs_outcome_observed_at ON connection_logs(outcome, observed_at DESC)', + ], + }, + { + id: 15, + name: 'sms-offline-outbox', + statements: [ + // The Hub keeps sending while a device is offline and delivers when it returns; the + // central archive alone cannot hold a write, so outbound SMS need their own queue. + `CREATE TABLE sms_outbox ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + phone_number TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ( + 'queued','sending','sent','failed','cancelled' + )), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + max_attempts INTEGER NOT NULL DEFAULT 24 CHECK (max_attempts > 0), + last_error TEXT, + available_at TEXT NOT NULL, + sent_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + 'CREATE INDEX idx_sms_outbox_status_available_at ON sms_outbox(status, available_at)', + 'CREATE INDEX idx_sms_outbox_instance_status ON sms_outbox(instance_id, status)', + 'CREATE INDEX idx_sms_outbox_created_at ON sms_outbox(created_at DESC)', + ], + }, + { + id: 16, + name: 'notification-channel-secret-fields', + statements: [ + // Hub channels keep several credentials (DingTalk needs an access token *and* a signing + // key), so the secret store holds a JSON map and the row records which keys it covers. + "ALTER TABLE notification_channels ADD COLUMN secret_fields TEXT NOT NULL DEFAULT ''", + ], + }, ]; const createMigrationsTable = `CREATE TABLE schema_migrations ( diff --git a/apps/api/src/infrastructure/transport/pinned-http-requester.ts b/apps/api/src/infrastructure/transport/pinned-http-requester.ts index 2c40a8a..fb053ef 100644 --- a/apps/api/src/infrastructure/transport/pinned-http-requester.ts +++ b/apps/api/src/infrastructure/transport/pinned-http-requester.ts @@ -10,7 +10,7 @@ import { asUpstreamError, UpstreamError } from './upstream-error.js'; export interface PinnedDispatchOptions { readonly url: URL; - readonly method: 'GET' | 'POST'; + readonly method: 'GET' | 'POST' | 'DELETE'; readonly headers: Readonly>; readonly body?: string; readonly lookup: LookupFunction; diff --git a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts index 05749a8..5679584 100644 --- a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts +++ b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts @@ -6,6 +6,7 @@ export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>; postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>; + postBasebandRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>; postSystemReboot( origin: string, delaySeconds: number, @@ -21,6 +22,7 @@ export function createSafeControlPlaneUpstream( request: (request) => gateway.request(request), postNetworkRegisterAuto: (origin, cookie) => gateway.postNetworkRegisterAuto(origin, cookie), postServiceRestart: (origin, cookie) => gateway.postServiceRestart(origin, cookie), + postBasebandRestart: (origin, cookie) => gateway.postBasebandRestart(origin, cookie), postSystemReboot: (origin, delaySeconds, cookie) => gateway.postSystemReboot(origin, delaySeconds, cookie), }; diff --git a/apps/api/src/infrastructure/transport/safe-instance-transport.ts b/apps/api/src/infrastructure/transport/safe-instance-transport.ts index d031919..fc8c207 100644 --- a/apps/api/src/infrastructure/transport/safe-instance-transport.ts +++ b/apps/api/src/infrastructure/transport/safe-instance-transport.ts @@ -12,7 +12,7 @@ export interface TransportResponse { } export interface PinnedRequest { readonly url: string; - readonly method: 'GET' | 'POST'; + readonly method: 'GET' | 'POST' | 'DELETE'; readonly headers: Readonly>; readonly body?: string; } @@ -80,6 +80,12 @@ export class SafeInstanceTransport { ): Promise { return this.send({ url: raw, method: 'POST', headers, body }); } + async delete( + raw: string, + headers: Readonly> = {}, + ): Promise { + return this.send({ url: raw, method: 'DELETE', headers }); + } private async send(request: PinnedRequest): Promise { const parsed = origin(request.url); const dialHost = parsed.hostname.replace(/^\[|\]$/g, ''); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts index cb82930..57683d7 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; import { SafeUpstreamGateway } from './safe-upstream-gateway.js'; +import type { TransportResponse } from './safe-instance-transport.js'; + +/** Spies typed with the transport signature so `mock.calls` keeps its argument tuple. */ +type GetSpy = (url: string, headers: Record) => Promise; +type PostSpy = ( + url: string, + headers: Record, + body: string, +) => Promise; describe('SafeUpstreamGateway', () => { it('dispatches only the audited zero-body network registration operation through pinned POST', async () => { @@ -135,6 +144,48 @@ describe('SafeUpstreamGateway', () => { }); }); + it('allows bounded device, network and cellular-signal reads', async () => { + const get = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{}', + })); + const gateway = new SafeUpstreamGateway({ transport: { get, post: vi.fn() } }); + + for (const path of ['/api/device', '/api/network', '/api/network/signal-strength']) { + await gateway.request({ + url: `http://192.168.1.20:8080${path}`, + method: 'GET', + headers: { accept: 'application/json' }, + }); + } + + expect( + get.mock.calls.map(([url]) => String(url).replace('http://192.168.1.20:8080', '')), + ).toEqual(['/api/device', '/api/network', '/api/network/signal-strength']); + + for (const request of [ + { + url: 'http://192.168.1.20:8080/api/network?refresh=true', + method: 'GET' as const, + headers: { accept: 'application/json' }, + }, + { + url: 'http://192.168.1.20:8080/api/device', + method: 'GET' as const, + headers: { accept: 'application/json', cookie: 'simadmin_session=bad;other=1' }, + }, + { + url: 'http://192.168.1.20:8080/api/device', + method: 'GET' as const, + headers: { accept: 'application/json' }, + secret: '[REDACTED]', + }, + ]) { + await expect(gateway.request(request)).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + } + }); + it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => { const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); @@ -158,6 +209,26 @@ describe('SafeUpstreamGateway', () => { ); }); + it('serializes an explicit SMS batch-delete payload', async () => { + const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get, post } }); + + await gateway.request({ + url: 'http://192.168.1.20:8080/api/sms/batch-delete', + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + smsBatchDelete: { ids: [987, 654, 0] }, + }); + + expect(post).toHaveBeenCalledWith( + 'http://192.168.1.20:8080/api/sms/batch-delete', + { accept: 'application/json', 'content-type': 'application/json' }, + '{"ids":[987,654,0]}', + ); + expect(get).not.toHaveBeenCalled(); + }); + it('dispatches an SMS at the 2,000-character automation limit', async () => { const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); const gateway = new SafeUpstreamGateway({ @@ -199,6 +270,50 @@ describe('SafeUpstreamGateway', () => { expect(post).not.toHaveBeenCalled(); }); + it('rejects malformed SMS batch-delete payloads before transport', async () => { + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ + transport: { get: vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })), post }, + }); + const base = { + url: 'http://192.168.1.20:8080/api/sms/batch-delete', + method: 'POST' as const, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + }; + await expect(gateway.request({ ...base, smsBatchDelete: { ids: [] } })).rejects.toThrow( + 'UPSTREAM_REQUEST_INVALID', + ); + await expect( + gateway.request({ + ...base, + smsBatchDelete: { ids: [1, -1] }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + await expect( + gateway.request({ + ...base, + headers: { accept: 'application/json' }, + smsBatchDelete: { ids: [1] }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + await expect( + gateway.request({ + ...base, + smsBatchDelete: { ids: [1] }, + body: '[REDACTED]', + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + await expect( + gateway.request({ + url: 'http://192.168.1.20:8080/api/sms/batch-delete?limit=1', + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + smsBatchDelete: { ids: [1] }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + expect(post).not.toHaveBeenCalled(); + }); + it('does not allow a supplied redacted body marker to become a network request body', async () => { const gateway = new SafeUpstreamGateway({ transport: { @@ -269,4 +384,225 @@ describe('SafeUpstreamGateway', () => { gateway.postServiceRestart('http://192.168.1.20:8080', 'simadmin_session=bad; extra'), ).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false }); }); + + it('sends a parseable empty JSON document when restarting the device baseband', async () => { + const calls: unknown[] = []; + const gateway = new SafeUpstreamGateway({ + transport: { + get: async () => ({ status: 200, headers: {}, body: '' }), + post: async (url, headers, body) => { + calls.push({ url, headers, body }); + return { status: 200, headers: {}, body: '{}' }; + }, + }, + }); + await gateway.postBasebandRestart('http://192.168.1.20:8080', 'simadmin_session=opaque-token'); + expect(calls).toEqual([ + { + url: 'http://192.168.1.20:8080/api/baseband/restart', + headers: { 'content-type': 'application/json', cookie: 'simadmin_session=opaque-token' }, + body: '{}', + }, + ]); + await expect( + gateway.postBasebandRestart('https://device.example.test/api/baseband/restart'), + ).rejects.toMatchObject({ dispatched: false }); + }); + + it('dispatches every console device-module read through the pinned GET transport', async () => { + const get = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{}', + })); + const gateway = new SafeUpstreamGateway({ transport: { get, post: vi.fn() } }); + const paths = [ + '/api/connectivity', + '/api/band-lock', + '/api/cell-lock', + '/api/work-mode', + '/api/hub', + '/api/auth/settings', + '/api/call/history?limit=25', + '/api/esim/profiles?cached=1', + '/api/esim/euicc?live=1', + '/api/backup/options', + '/api/notifications/queue?limit=50', + '/api/automation/logs', + '/api/ota/status', + '/api/vowifi/profiles', + '/api/vowifi/diagnostics?limit=50', + '/api/vowifi/events?limit=50', + '/api/vowifi/soak?limit=20', + '/api/vowifi/sms/delivery?limit=20', + '/api/vowifi/esim-restore/status', + '/api/device-network/wlan/profiles', + ]; + for (const path of paths) { + await gateway.request({ + url: `http://192.168.1.20:8080${path}`, + method: 'GET', + headers: { accept: 'application/json' }, + }); + } + const requested = get.mock.calls.map(([url]) => + String(url).replace('http://192.168.1.20:8080', ''), + ); + expect(requested).toEqual(paths); + }); + + it('rejects a query shape the module catalog never emits', async () => { + const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get, post: vi.fn() } }); + for (const url of [ + 'http://192.168.1.20:8080/api/band-lock?refresh=true', + 'http://192.168.1.20:8080/api/notifications/queue?limit=9999', + 'http://192.168.1.20:8080/api/call/history', + 'http://192.168.1.20:8080/api/unknown/module', + ]) { + await expect( + gateway.request({ url, method: 'GET', headers: { accept: 'application/json' } }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + } + expect(get).not.toHaveBeenCalled(); + }); + + it('serializes an allowlisted device action body canonically', async () => { + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post } }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/band-lock', + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + deviceAction: { body: { nr_tdd_bands: ['41'], lte_fdd_bands: ['3'] } }, + }); + expect(post).toHaveBeenCalledWith( + 'http://192.168.1.20:8080/api/band-lock', + { accept: 'application/json', 'content-type': 'application/json' }, + '{"lte_fdd_bands":["3"],"nr_tdd_bands":["41"]}', + ); + }); + + it('dispatches zero-body device actions, parametric paths, and deletions', async () => { + const post = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{}', + })); + const del = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{}', + })); + const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post, delete: del } }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/cell-lock/unlock-all', + method: 'POST', + headers: { accept: 'application/json' }, + deviceAction: { body: {} }, + }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/esim/profiles/89882020202220963176/rename', + method: 'POST', + headers: { 'content-type': 'application/json' }, + deviceAction: { body: { name: 'work' } }, + }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/backup/files/backup-2026.tar.gz', + method: 'DELETE', + headers: { accept: 'application/json' }, + deviceAction: { body: undefined }, + }); + expect(post.mock.calls.map((call) => [call[0], call[2]])).toEqual([ + ['http://192.168.1.20:8080/api/cell-lock/unlock-all', '{}'], + ['http://192.168.1.20:8080/api/esim/profiles/89882020202220963176/rename', '{"name":"work"}'], + ]); + expect(del).toHaveBeenCalledWith( + 'http://192.168.1.20:8080/api/backup/files/backup-2026.tar.gz', + { accept: 'application/json' }, + ); + }); + + it('refuses device actions outside the allowlist or with an unshapeable body', async () => { + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post } }); + const headers = { accept: 'application/json', 'content-type': 'application/json' }; + const cases: { + url: string; + deviceAction: { body: Readonly> | undefined }; + }[] = [ + { url: 'http://192.168.1.20:8080/api/system/reboot', deviceAction: { body: {} } }, + { url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { nested: { deep: 1 } } } }, + { url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { 'Bad Key': 'x' } } }, + { url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { apn: 'a\u0001b' } } }, + { + url: 'http://192.168.1.20:8080/api/apn', + deviceAction: { body: { apn: 'x'.repeat(600) } }, + }, + { url: 'http://192.168.1.20:8080/api/apn', deviceAction: { body: { apn: [1, 2] } } }, + ]; + for (const entry of cases) { + await expect(gateway.request({ ...entry, method: 'POST', headers })).rejects.toThrow( + 'UPSTREAM_REQUEST_INVALID', + ); + } + expect(post).not.toHaveBeenCalled(); + }); + + it('never lets a device action borrow another request marker', async () => { + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get, post } }); + await expect( + gateway.request({ + url: 'http://192.168.1.20:8080/api/apn', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '[REDACTED]', + deviceAction: { body: { apn: 'internet' } }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + await expect( + gateway.request({ + url: 'http://192.168.1.20:8080/api/apn', + method: 'GET', + headers: { accept: 'application/json' }, + deviceAction: { body: { apn: 'internet' } }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + await expect( + gateway.request({ + url: 'http://192.168.1.20:8080/api/backup/files/backup-2026.tar.gz', + method: 'DELETE', + headers: { accept: 'application/json' }, + deviceAction: { body: undefined }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + expect(post).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + }); + + it('forwards the session cookie on a device action but rejects a foreign cookie shape', async () => { + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get: vi.fn(), post } }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/radio-mode', + method: 'POST', + headers: { 'content-type': 'application/json', cookie: 'simadmin_session=opaque' }, + deviceAction: { body: { mode: 'lte' } }, + }); + expect(post).toHaveBeenCalledWith( + 'http://192.168.1.20:8080/api/radio-mode', + { 'content-type': 'application/json', cookie: 'simadmin_session=opaque' }, + '{"mode":"lte"}', + ); + await expect( + gateway.request({ + url: 'http://192.168.1.20:8080/api/radio-mode', + method: 'POST', + headers: { 'content-type': 'application/json', cookie: 'simadmin_session=a; csrf=b' }, + deviceAction: { body: { mode: 'lte' } }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + }); }); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts index 0f4506f..557a4db 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts @@ -13,6 +13,7 @@ export interface SafeUpstreamTransport { headers: Readonly>, body: string, ): Promise; + delete?(url: string, headers: Readonly>): Promise; } /** Literal private hosts only — hostnames that still need DNS stay HTTPS for auth secrets. */ @@ -33,6 +34,228 @@ const isLiteralPrivateHost = (hostname: string): boolean => { ); }; +/** + * Every device read the console modules perform, pinned to the exact query shape the module + * catalog emits. A device module can only ever widen this list through a code review, never + * through a value that came back from the network. + */ +const DEVICE_READ_PATHS: Readonly> = { + '/api/health': /^$/u, + '/api/device': /^$/u, + '/api/sim': /^$/u, + '/api/network': /^$/u, + '/api/stats': /^$/u, + '/api/stats/cpu': /^$/u, + '/api/connectivity': /^$/u, + '/api/data': /^$/u, + '/api/apn': /^$/u, + '/api/band-lock': /^$/u, + '/api/cell-lock': /^$/u, + '/api/calls': /^$/u, + '/api/cell-monitor/status': /^$/u, + '/api/cells': /^$/u, + '/api/location/cell-info': /^$/u, + '/api/roaming': /^$/u, + '/api/radio-mode': /^$/u, + '/api/airplane-mode': /^$/u, + '/api/work-mode': /^$/u, + '/api/hub': /^$/u, + '/api/auth/settings': /^$/u, + '/api/auth/status': /^$/u, + '/api/baseband/restart/status': /^$/u, + '/api/call/history': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/call/settings': /^$/u, + '/api/call/forwarding': /^$/u, + '/api/call/volume': /^$/u, + '/api/voicemail/status': /^$/u, + '/api/ims/status': /^$/u, + '/api/network/operators': /^$/u, + '/api/network/signal-strength': /^$/u, + '/api/network/interfaces': /^$/u, + '/api/network/connection-addresses': /^$/u, + '/api/device-network/wlan/status': /^$/u, + '/api/device-network/wlan/profiles': /^$/u, + '/api/device-network/ddns/status': /^$/u, + '/api/device-network/ddns/config': /^$/u, + '/api/device-network/ddns/logs': /^$/u, + '/api/esim/euicc': /^(?:live=1)?$/u, + '/api/esim/profiles': /^(?:cached=1)?$/u, + '/api/esim/config': /^$/u, + '/api/esim/lpac/status': /^$/u, + '/api/backup/files': /^$/u, + '/api/backup/config': /^$/u, + '/api/backup/options': /^$/u, + '/api/notifications/config': /^$/u, + '/api/notifications/queue': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/notifications/logs': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/automation/config': /^$/u, + '/api/automation/logs': /^$/u, + '/api/ota/status': /^$/u, + '/api/sms/conversation': + /^phone_number=%2B?[0-9][0-9 ()-]{1,30}&limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/vowifi/status': /^$/u, + '/api/vowifi/control': /^$/u, + '/api/vowifi/profile': /^$/u, + '/api/vowifi/profiles': /^$/u, + // VoWiFi diagnostics: one aggregate read plus the narrower feeds the Hub polls separately. + '/api/vowifi/diagnostics': + /^(?:|limit=(?:[1-9]|[1-9][0-9]|100)|trace_id=[A-Za-z0-9_.:-]{1,64}|limit=(?:[1-9]|[1-9][0-9]|100)&trace_id=[A-Za-z0-9_.:-]{1,64})$/u, + '/api/vowifi/events': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/vowifi/soak': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/vowifi/sms/delivery': /^limit=(?:[1-9]|[1-9][0-9]|100)$/u, + '/api/vowifi/esim-restore/status': /^$/u, + '/api/sms/stats': /^$/u, +}; + +/** Device mutations the console may dispatch; each entry is a fixed path with a fixed shape. */ +const DEVICE_ACTION_POSTS: Readonly> = { + '/api/sim/details/refresh': 'empty', + '/api/sim/cache': 'json', + '/api/band-lock': 'json', + '/api/cell-lock': 'json', + '/api/cell-lock/unlock-all': 'empty', + '/api/apn': 'json', + '/api/radio-mode': 'json', + '/api/roaming': 'json', + '/api/airplane-mode': 'json', + '/api/data': 'json', + '/api/work-mode': 'json', + '/api/auth/settings': 'json', + '/api/auth/password': 'json', + '/api/network/register-auto': 'empty', + '/api/hub': 'json', + '/api/hub/unbind': 'empty', + '/api/baseband/restart': 'empty', + '/api/cell-monitor/start': 'empty', + '/api/cell-monitor/stop': 'empty', + '/api/call/dial': 'json', + '/api/call/answer': 'json', + '/api/call/hangup': 'json', + '/api/call/hangup-all': 'empty', + '/api/call/settings': 'json', + '/api/call/forwarding': 'json', + '/api/call/volume': 'json', + '/api/call/history/clear': 'empty', + '/api/network/register-manual': 'json', + '/api/network/operators/scan': 'empty', + '/api/device-network/wlan/enabled': 'json', + '/api/device-network/wlan/scan': 'empty', + '/api/device-network/wlan/connect': 'json', + '/api/device-network/wlan/disconnect': 'empty', + '/api/device-network/wlan/forget': 'json', + '/api/device-network/wlan/profile': 'json', + '/api/device-network/ddns/sync': 'empty', + '/api/device-network/ddns/config': 'json', + '/api/device-network/ddns/logs/clear': 'empty-json', + '/api/esim/config': 'json', + '/api/esim/lpac/repair': 'json', + '/api/esim/profiles': 'json', + '/api/vowifi/feature': 'json', + '/api/vowifi/connection': 'json', + '/api/vowifi/connect': 'empty', + '/api/backup/config': 'json', + '/api/backup/export-local': 'json', + '/api/backup/data/clear': 'json', + '/api/notifications/config': 'json', + '/api/notifications/logs/clear': 'empty-json', + '/api/notifications/queue/clear': 'empty', + '/api/notifications/queue/retry-all': 'empty', + '/api/automation/config': 'json', + '/api/automation/logs/clear': 'empty-json', + '/api/ota/cancel': 'empty', + '/api/ota/apply': 'json', + '/api/ota/latest-release': 'json', + '/api/ota/online-prepare': 'json', + '/api/sms/clear': 'empty', +}; + +/** Actions whose device path carries one opaque identifier segment, such as an ICCID. */ +const DEVICE_ACTION_PARAMETRIC_POSTS: readonly { + readonly pattern: RegExp; + readonly shape: DeviceActionShape; + readonly query?: RegExp; +}[] = [ + { pattern: /^\/api\/esim\/profiles\/[A-Za-z0-9_.\-]{1,128}\/enable$/u, shape: 'empty' }, + { pattern: /^\/api\/esim\/profiles\/[A-Za-z0-9_.\-]{1,128}\/rename$/u, shape: 'json' }, + { pattern: /^\/api\/notifications\/test\/[A-Za-z0-9_.\-]{1,64}$/u, shape: 'empty' }, + { pattern: /^\/api\/automation\/test\/[A-Za-z0-9_.\-]{1,64}$/u, shape: 'empty' }, + { + pattern: /^\/api\/backup\/files\/[A-Za-z0-9_.\-]{1,128}\/apply$/u, + shape: 'empty', + query: /^mode=(?:replace|merge)&components=[A-Za-z0-9_,.\-]{1,512}$/u, + }, +]; + +const DEVICE_ACTION_PARAMETRIC_DELETES: readonly RegExp[] = [ + /^\/api\/backup\/files\/[A-Za-z0-9_.\-]{1,128}$/u, + /^\/api\/esim\/profiles\/[A-Za-z0-9_.\-]{1,128}$/u, +]; + +/** The one device mutation that carries a query string: a restore mode plus a component list. */ +const DEVICE_ACTION_QUERY_POSTS: readonly { + readonly path: RegExp; + readonly query: RegExp; +}[] = [ + { + path: /^\/api\/backup\/files\/[A-Za-z0-9_.\-]{1,128}\/apply$/u, + query: /^mode=(?:replace|merge)&components=[A-Za-z0-9_,.\-]{1,512}$/u, + }, +]; + +const ACTION_BODY_KEY = /^[a-z][a-z0-9_]{0,31}$/u; +const MAX_ACTION_BODY_KEYS = 16; +const MAX_ACTION_BODY_BYTES = 4_096; + +/** + * `empty` sends no body, `json` requires a caller-supplied object, and `empty-json` always ships a + * literal `{}` document, which is what some device endpoints expect even though they take no input. + */ +type DeviceActionShape = 'empty' | 'json' | 'empty-json'; + +const isRecordLike = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Resolves the fixed payload shape a device mutation path accepts, or undefined if unknown. */ +function deviceActionShape(pathname: string): DeviceActionShape | undefined { + const literal = DEVICE_ACTION_POSTS[pathname]; + if (literal !== undefined) return literal; + for (const entry of DEVICE_ACTION_PARAMETRIC_POSTS) + if (entry.pattern.test(pathname)) return entry.shape; + return undefined; +} + +/** Only flat, bounded JSON is ever handed to a device; anything else never leaves the process. */ +function canonicalActionBody(value: unknown): string | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const entries = Object.entries(value as Record); + if (entries.length > MAX_ACTION_BODY_KEYS) return undefined; + const sorted: [string, unknown][] = entries + .filter(([key]) => ACTION_BODY_KEY.test(key)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + if (sorted.length !== entries.length) return undefined; + const output: Record = {}; + for (const [key, entry] of sorted) { + if (typeof entry === 'string') { + if (entry.length > 512 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(entry)) + return undefined; + output[key] = entry; + } else if (typeof entry === 'number') { + if (!Number.isSafeInteger(entry) || Math.abs(entry) > 1_000_000_000) return undefined; + output[key] = entry; + } else if (typeof entry === 'boolean') output[key] = entry; + else if (entry === null) output[key] = null; + else if ( + Array.isArray(entry) && + entry.length <= 128 && + entry.every((item) => typeof item === 'string' && item.length <= 128) + ) + output[key] = entry as readonly string[]; + else return undefined; + } + const serialized = JSON.stringify(output); + return Buffer.byteLength(serialized, 'utf8') > MAX_ACTION_BODY_BYTES ? undefined : serialized; +} + export class SafeUpstreamGateway { constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {} private assertOrigin(origin: string): string { @@ -90,6 +313,28 @@ export class SafeUpstreamGateway { return this.postZeroBody(origin, '/api/service/restart', cookie); } + /** + * The baseband restart endpoint is a JSON handler that takes no fields, so it still expects a + * parseable `{}` document plus a content type; a zero-byte POST is rejected by the device. + */ + async postBasebandRestart(origin: string, cookie?: string): Promise { + const base = this.assertOrigin(origin); + try { + return await this.options.transport.post( + `${base}/api/baseband/restart`, + this.sessionHeaders(cookie, { 'content-type': 'application/json' }), + '{}', + ); + } catch (error) { + if ( + error instanceof UpstreamError && + (error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION') + ) + throw new OperationNotDispatchedError(error.code); + throw error; + } + } + async postSystemReboot( origin: string, delaySeconds: number, @@ -123,16 +368,17 @@ export class SafeUpstreamGateway { url.search.slice(1), ) : null; + const readQuery = DEVICE_READ_PATHS[url.pathname]; + const allowedRead = readQuery !== undefined && readQuery.test(url.search.slice(1)); if ( request.secret !== undefined || request.body !== undefined || request.sms !== undefined || + request.smsBatchDelete !== undefined || + request.deviceAction !== undefined || (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') || - (url.pathname !== '/api/stats' && - url.pathname !== '/api/sim' && - url.pathname !== '/api/health' && - !smsQuery) || - (!smsList && url.search) || + (!smsQuery && !allowedRead) || + (smsList && !smsQuery) || url.hash || url.username || url.password || @@ -149,6 +395,8 @@ export class SafeUpstreamGateway { request.method !== 'POST' || request.secret !== undefined || request.body !== undefined || + request.smsBatchDelete !== undefined || + request.deviceAction !== undefined || !sms || (headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') || !/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) || @@ -168,8 +416,69 @@ export class SafeUpstreamGateway { JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }), ); } + if (url.pathname === '/api/sms/batch-delete') { + const ids = request.smsBatchDelete?.ids; + if ( + request.method !== 'POST' || + request.secret !== undefined || + request.body !== undefined || + request.sms !== undefined || + request.deviceAction !== undefined || + !Array.isArray(ids) || + ids.length < 1 || + ids.length > 500 || + ids.some((id) => !Number.isSafeInteger(id) || id < 0) || + (headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') || + url.search || + url.hash || + url.username || + url.password || + (request.headers.cookie !== undefined && + (typeof request.headers.cookie !== 'string' || + !/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie))) + ) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.post(request.url, request.headers, JSON.stringify({ ids })); + } + if ( + request.deviceAction === undefined && + (request.method === 'DELETE' || + (request.method === 'POST' && url.pathname.startsWith('/api/notifications/queue'))) + ) { + const queuePath = + /^\/api\/notifications\/queue(?:$|\/(?:retry-all|clear|[A-Za-z0-9_.-]{1,128}(?:\/retry)?))$/u.exec( + url.pathname, + ); + if ( + request.secret !== undefined || + request.body !== undefined || + request.sms !== undefined || + request.smsBatchDelete !== undefined || + request.deviceAction !== undefined || + !queuePath || + url.search || + url.hash || + url.username || + url.password || + (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') || + (request.headers.cookie !== undefined && + (typeof request.headers.cookie !== 'string' || + !/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie))) + ) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if (request.method === 'DELETE') { + if (!new RegExp('^/api/notifications/queue/[A-Za-z0-9_.-]{1,128}$', 'u').test(url.pathname)) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if (!this.options.transport.delete) throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.delete(request.url, request.headers); + } + return this.options.transport.post(request.url, request.headers, ''); + } + if (request.deviceAction !== undefined) return this.deviceAction(request, url, headerKeys); // Auth secrets may travel over HTTPS anywhere, or over HTTP only to literal private hosts. // Public/cleartext auth is still rejected here; SSRF private-only dial remains in transport. + if (request.sms !== undefined || request.smsBatchDelete !== undefined) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); if (!this.allowsAuthProtocol(url)) throw new UpstreamError('UPSTREAM_INSECURE_AUTH'); if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); if (request.url.endsWith('/api/auth/login')) { @@ -189,6 +498,78 @@ export class SafeUpstreamGateway { throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); } + /** + * Device mutations are allowlisted by path and payload shape. The console never learns a path + * the transport has not already agreed to speak, so a compromised device module cannot turn the + * control plane into a generic proxy. + */ + private deviceAction( + request: UpstreamRequest, + url: URL, + headerKeys: string, + ): Promise { + const pathname = url.pathname; + const payload = request.deviceAction?.body; + const cookie = request.headers.cookie; + if ( + request.secret !== undefined || + request.body !== undefined || + request.sms !== undefined || + request.smsBatchDelete !== undefined || + url.hash || + url.username || + url.password || + (url.search && + !DEVICE_ACTION_QUERY_POSTS.some( + (entry) => entry.path.test(pathname) && entry.query.test(url.search.slice(1)), + )) || + (cookie !== undefined && + (typeof cookie !== 'string' || !/^simadmin_session=[^;\s,]+$/.test(cookie))) + ) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if (request.method === 'DELETE') { + if ( + !DEVICE_ACTION_PARAMETRIC_DELETES.some((pattern) => pattern.test(pathname)) || + (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') || + !this.options.transport.delete + ) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.delete(request.url, request.headers); + } + if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + const shape = deviceActionShape(pathname); + if (shape === undefined) throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if (shape === 'empty') { + if (payload !== undefined && !(isRecordLike(payload) && Object.keys(payload).length === 0)) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.post( + request.url, + request.headers, + payload === undefined ? '' : '{}', + ); + } + if (shape === 'empty-json') { + if (payload !== undefined && !(isRecordLike(payload) && Object.keys(payload).length === 0)) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if (headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.post(request.url, request.headers, '{}'); + } + if (payload === undefined) throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + const serialized = canonicalActionBody(payload); + if (serialized === undefined) throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + if ( + headerKeys !== 'accept,content-type' && + headerKeys !== 'accept,content-type,cookie' && + headerKeys !== 'content-type' && + headerKeys !== 'content-type,cookie' + ) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.post(request.url, request.headers, serialized); + } + private allowsAuthProtocol(url: URL): boolean { if (url.protocol === 'https:') return true; if (url.protocol !== 'http:') return false; diff --git a/apps/api/src/interface/http/automation-routes.ts b/apps/api/src/interface/http/automation-routes.ts index 424f2c5..dffdd84 100644 --- a/apps/api/src/interface/http/automation-routes.ts +++ b/apps/api/src/interface/http/automation-routes.ts @@ -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, + ), }; }), ); diff --git a/apps/api/src/interface/http/central-notification-routes.test.ts b/apps/api/src/interface/http/central-notification-routes.test.ts new file mode 100644 index 0000000..1c42463 --- /dev/null +++ b/apps/api/src/interface/http/central-notification-routes.test.ts @@ -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(); + + 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' }); + }); +}); diff --git a/apps/api/src/interface/http/central-notification-routes.ts b/apps/api/src/interface/http/central-notification-routes.ts new file mode 100644 index 0000000..4615d3e --- /dev/null +++ b/apps/api/src/interface/http/central-notification-routes.ts @@ -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( + request: FastifyRequest, + reply: FastifyReply, + operation: () => T | Promise, +): Promise { + 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 | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function logFilter(query: unknown): Record { + 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 => + 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) }; + }), + ); +} diff --git a/apps/api/src/interface/http/device-action-routes.test.ts b/apps/api/src/interface/http/device-action-routes.test.ts new file mode 100644 index 0000000..c341b4c --- /dev/null +++ b/apps/api/src/interface/http/device-action-routes.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/interface/http/device-action-routes.ts b/apps/api/src/interface/http/device-action-routes.ts new file mode 100644 index 0000000..a5ae5e4 --- /dev/null +++ b/apps/api/src/interface/http/device-action-routes.ts @@ -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, + { + 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; + } + }, + ); +} diff --git a/apps/api/src/interface/http/discovery-routes.test.ts b/apps/api/src/interface/http/discovery-routes.test.ts new file mode 100644 index 0000000..ed7793b --- /dev/null +++ b/apps/api/src/interface/http/discovery-routes.test.ts @@ -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 = { + 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> = {}) { + 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(); + }); +}); diff --git a/apps/api/src/interface/http/discovery-routes.ts b/apps/api/src/interface/http/discovery-routes.ts new file mode 100644 index 0000000..14a008c --- /dev/null +++ b/apps/api/src/interface/http/discovery-routes.ts @@ -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 = + (action: (request: FastifyRequest, reply: FastifyReply) => Promise) => + 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; + return { data: await options.discovery.probe(deviceUrl) }; + }), + ); +} diff --git a/apps/api/src/interface/http/event-routes.test.ts b/apps/api/src/interface/http/event-routes.test.ts index 5722e0c..dbe1b13 100644 --- a/apps/api/src/interface/http/event-routes.test.ts +++ b/apps/api/src/interface/http/event-routes.test.ts @@ -10,7 +10,8 @@ import { registerEventRoutes } from './event-routes.js'; const databases: Database.Database[] = []; const apps: ReturnType[] = []; -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', diff --git a/apps/api/src/interface/http/fleet-routes.ts b/apps/api/src/interface/http/fleet-routes.ts new file mode 100644 index 0000000..d3077ca --- /dev/null +++ b/apps/api/src/interface/http/fleet-routes.ts @@ -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 { + 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 | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +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( + items: readonly Item[], + concurrency: number, + mapper: (item: Item, index: number) => Promise, +): Promise { + 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( + handler: (request: FastifyRequest) => Promise, +): (request: FastifyRequest, reply: FastifyReply) => Promise { + 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( + handler: ( + request: FastifyRequest<{ readonly Params: FleetNotificationQueueItemParams }>, + ) => Promise, +): (request: FastifyRequest, reply: FastifyReply) => Promise { + 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 }; + }), + ); +} diff --git a/apps/api/src/interface/http/instance-module-routes.test.ts b/apps/api/src/interface/http/instance-module-routes.test.ts new file mode 100644 index 0000000..5397259 --- /dev/null +++ b/apps/api/src/interface/http/instance-module-routes.test.ts @@ -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' }); + }); +}); diff --git a/apps/api/src/interface/http/instance-module-routes.ts b/apps/api/src/interface/http/instance-module-routes.ts new file mode 100644 index 0000000..261de2b --- /dev/null +++ b/apps/api/src/interface/http/instance-module-routes.ts @@ -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 => { + 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); +} diff --git a/apps/api/src/interface/http/instance-routes.ts b/apps/api/src/interface/http/instance-routes.ts index 9e1cd27..012f38c 100644 --- a/apps/api/src/interface/http/instance-routes.ts +++ b/apps/api/src/interface/http/instance-routes.ts @@ -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 = { diff --git a/apps/api/src/interface/http/job-routes.test.ts b/apps/api/src/interface/http/job-routes.test.ts index d7b3c86..e51d685 100644 --- a/apps/api/src/interface/http/job-routes.test.ts +++ b/apps/api/src/interface/http/job-routes.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/interface/http/job-routes.ts b/apps/api/src/interface/http/job-routes.ts index 0b3d710..407345e 100644 --- a/apps/api/src/interface/http/job-routes.ts +++ b/apps/api/src/interface/http/job-routes.ts @@ -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)); + }); + } } diff --git a/apps/api/src/interface/http/log-center-routes.test.ts b/apps/api/src/interface/http/log-center-routes.test.ts new file mode 100644 index 0000000..1bae12a --- /dev/null +++ b/apps/api/src/interface/http/log-center-routes.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/interface/http/log-center-routes.ts b/apps/api/src/interface/http/log-center-routes.ts new file mode 100644 index 0000000..fc7fd36 --- /dev/null +++ b/apps/api/src/interface/http/log-center-routes.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +/** Rejects unknown keys so a typo in a filter cannot silently widen the result set. */ +function assertKeys(value: Record, 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(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( + request: FastifyRequest, + reply: FastifyReply, + operation: () => T | Promise, +): Promise { + 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(query.source, SOURCES), + level: pick(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), + }), + }; + }), + ); +} diff --git a/apps/api/src/interface/http/organization-routes.test.ts b/apps/api/src/interface/http/organization-routes.test.ts new file mode 100644 index 0000000..a60841f --- /dev/null +++ b/apps/api/src/interface/http/organization-routes.test.ts @@ -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; 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'); + }); +}); diff --git a/apps/api/src/interface/http/organization-routes.ts b/apps/api/src/interface/http/organization-routes.ts new file mode 100644 index 0000000..ea3e28a --- /dev/null +++ b/apps/api/src/interface/http/organization-routes.ts @@ -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( + request: FastifyRequest, + reply: FastifyReply, + operation: () => T | Promise, +): Promise { + 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; + }), + ); +} diff --git a/apps/api/src/interface/http/system-routes.test.ts b/apps/api/src/interface/http/system-routes.test.ts new file mode 100644 index 0000000..70fe535 --- /dev/null +++ b/apps/api/src/interface/http/system-routes.test.ts @@ -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[] = [ + { 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(); + }); +}); diff --git a/apps/api/src/interface/http/system-routes.ts b/apps/api/src/interface/http/system-routes.ts new file mode 100644 index 0000000..335de3d --- /dev/null +++ b/apps/api/src/interface/http/system-routes.ts @@ -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 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Request body must be an object'); + return value as Record; +} + +export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutesOptions): void { + const backups = () => options.componentBackups; + const wrap = + (action: (request: FastifyRequest, reply: FastifyReply) => T | Promise) => + async (request: FastifyRequest, reply: FastifyReply) => { + try { + return await action(request, reply); + } catch (error) { + if (error instanceof ComponentBackupError) { + const status = + error.code === 'NOT_FOUND' + ? 404 + : error.code === 'INCOMPATIBLE' || error.code === 'INTEGRITY_FAILED' + ? 422 + : 400; + return reply + .code(status) + .type('application/problem+json') + .send({ + type: 'about:blank', + title: + status === 404 + ? 'Not Found' + : status === 422 + ? 'Unprocessable Entity' + : 'Bad Request', + status, + code: `COMPONENT_BACKUP_${error.code}`, + detail: 'The component backup request could not be completed.', + requestId: request.id, + }); + } + if (error instanceof TypeError || error instanceof RangeError || error instanceof Error) { + const invalid = + error instanceof TypeError || + error instanceof RangeError || + /invalid|unknown|required/iu.test(error.message); + if (!invalid) throw error; + return reply.code(400).type('application/problem+json').send({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + code: 'MAINTENANCE_VALIDATION_FAILED', + detail: 'The system maintenance request is invalid.', + requestId: request.id, + }); + } + throw error; + } + }; + + app.get( + '/api/v1/system/maintenance', + wrap(async () => options.maintenance.overview()), + ); + app.get( + '/api/v1/system/connection', + wrap(async () => options.connectionSettings.get()), + ); + app.put( + '/api/v1/system/connection', + wrap(async (request) => options.connectionSettings.update(request.body)), + ); + app.post( + '/api/v1/system/connection/refresh', + wrap(async (request, reply) => { + const beat = options.heartbeat; + if (!beat) + return reply.code(501).type('application/problem+json').send({ + type: 'about:blank', + title: 'Not Implemented', + status: 501, + code: 'HEARTBEAT_UNAVAILABLE', + detail: 'The device heartbeat is not enabled.', + requestId: request.id, + }); + return beat.runOnce(); + }), + ); + app.put( + '/api/v1/system/maintenance/retention', + wrap(async (request) => options.maintenance.updateRetention(request.body)), + ); + app.post( + '/api/v1/system/maintenance/cleanup', + wrap(async (request) => { + const value = object(request.body).components; + if (!Array.isArray(value) || value.length === 0) throw new TypeError('components is invalid'); + return options.maintenance.cleanup(value as never); + }), + ); + app.post( + '/api/v1/system/maintenance/optimize', + wrap(async () => options.maintenance.optimize()), + ); + app.get( + '/api/v1/system/maintenance/backups', + wrap(async () => ({ + items: await options.maintenance.listBackups(), + })), + ); + app.post( + '/api/v1/system/maintenance/backups', + wrap(async (_request, reply) => reply.code(201).send(await options.maintenance.createBackup())), + ); + app.get( + '/api/v1/system/maintenance/backups/:filename', + wrap(async (request, reply) => { + const backup = await options.maintenance.backupFile( + (request.params as { filename: string }).filename, + ); + if (!backup) + return reply.code(404).type('application/problem+json').send({ + type: 'about:blank', + title: 'Not Found', + status: 404, + code: 'MAINTENANCE_BACKUP_NOT_FOUND', + detail: 'The requested backup file no longer exists.', + requestId: request.id, + }); + reply + .header('content-disposition', `attachment; filename="${backup.filename}"`) + .header('content-length', String(backup.sizeBytes)) + .type('application/octet-stream'); + return createReadStream(backup.path); + }), + ); + app.delete( + '/api/v1/system/maintenance/backups/:filename', + wrap(async (request) => + options.maintenance.deleteBackup((request.params as { filename: string }).filename), + ), + ); + + app.get( + '/api/v1/system/component-backups/catalog', + wrap(async () => ({ items: backups().catalog() })), + ); + 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)), + ); +} diff --git a/apps/api/src/production-control-plane.test.ts b/apps/api/src/production-control-plane.test.ts index d3219b2..72159f3 100644 --- a/apps/api/src/production-control-plane.test.ts +++ b/apps/api/src/production-control-plane.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -32,6 +32,7 @@ async function fixtureOptions(): Promise { request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), postServiceRestart: async () => ({ status: 200 }), + postBasebandRestart: async () => ({ status: 200 }), postSystemReboot: async () => ({ status: 200 }), }, keychainMetadataCheck: async () => true, @@ -72,4 +73,21 @@ describe('production control-plane authentication composition', () => { expect(response.headers.get('content-type')).toMatch(/^text\/event-stream/); await response.body?.cancel(); }); + + it('creates system backups inside the database data root', async () => { + const options = await fixtureOptions(); + const app = buildProductionControlPlane(options); + cleanup.push(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/system/maintenance/backups', + headers: { [GATEWAY_AUTH_HEADER]: gatewayToken }, + }); + + expect(response.statusCode, response.body).toBe(201); + const backup = response.json(); + expect(backup.path).toContain(dirname(options.databasePath)); + expect(backup.path).not.toBe(options.databasePath); + }); }); diff --git a/apps/api/src/production-control-plane.ts b/apps/api/src/production-control-plane.ts index 5c53e1d..df1791c 100644 --- a/apps/api/src/production-control-plane.ts +++ b/apps/api/src/production-control-plane.ts @@ -1,4 +1,4 @@ -import { isAbsolute } from 'node:path'; +import { dirname, isAbsolute, join } from 'node:path'; import { buildControlPlaneApp, type ControlPlaneApp, @@ -18,8 +18,11 @@ export interface ProductionControlPlaneOptions { readonly gatewayToken?: string; readonly keychainMetadataCheck?: () => boolean | Promise; readonly assetsCheck?: () => boolean | Promise; + readonly runtimeVersion?: string; readonly recoveryMarkersCheck?: () => number | Promise; readonly upstreamGatewayCheck?: () => boolean | Promise; + /** Production keeps device online state fresh on its own; tests and canaries can opt out. */ + readonly heartbeatEnabled?: boolean; } export function buildProductionControlPlane( @@ -40,6 +43,9 @@ export function buildProductionControlPlane( db, store, upstream: options.upstream ?? createProductionUpstream(), + runtimeVersion: options.runtimeVersion ?? '0.1.0', + backupDirectory: join(dirname(options.databasePath), 'backups'), + heartbeatEnabled: options.heartbeatEnabled ?? true, // This route-local decision is safe only in this composition: when a token is // configured, buildApp's global onRequest hook rejects the request first. // Keep all other control-plane compositions fail-closed by default. diff --git a/apps/api/src/runtime-foundation.test.ts b/apps/api/src/runtime-foundation.test.ts index 0d27e8b..b9f9f24 100644 --- a/apps/api/src/runtime-foundation.test.ts +++ b/apps/api/src/runtime-foundation.test.ts @@ -87,7 +87,11 @@ describe('production runtime foundation', () => { CANARY_PORT: '8789', CANARY_UPSTREAM_PORT: '8790', }), - ).toMatchObject({ gatewayToken: token, port: 8789, upstreamPort: 8790 }); + ).toMatchObject({ + gatewayToken: token, + port: 8789, + upstreamPort: 8790, + }); expect(() => readCanaryRuntimeOptions({ CANARY_DIST_DIR: '/tmp/dist' })).toThrow(/token/i); expect(() => readCanaryRuntimeOptions({