From f9186bd851d0fc36af1c5621adcdbdc34899dcd5 Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 5 Sep 2026 18:53:04 +0800 Subject: [PATCH] feat(api): absorb the Hub control plane into the local instance model Add central notification channels, rules, queue and delivery logs, fleet organization groups and tags, device discovery, the device action catalog, instance module reads, the log centre, connection settings and system maintenance as native /api/v1 routes backed by the existing secret store, audit trail and pinned upstream transport. --- .../application/audit/audit-query-service.ts | 78 + .../automation/schedule-time.test.ts | 73 +- .../application/automation/schedule-time.ts | 116 +- .../scheduled-operation-dispatcher.test.ts | 47 + .../scheduled-operation-dispatcher.ts | 67 +- .../automation/scheduled-task-repository.ts | 21 +- .../automation/scheduled-task-service.ts | 33 +- .../automation/scheduler-coordinator.ts | 11 +- .../automation/target-resolver.test.ts | 40 + .../application/automation/target-resolver.ts | 32 +- .../connections/connection-probe.test.ts | 118 +- .../connections/connection-probe.ts | 105 +- .../connection-settings-service.test.ts | 127 ++ .../connection-settings-service.ts | 113 ++ .../connections/fleet-heartbeat.test.ts | 155 ++ .../connections/fleet-heartbeat.ts | 120 ++ .../connections/upstream-session-client.ts | 9 +- .../application/events/event-journal.test.ts | 3 +- .../instances/device-action-catalog.test.ts | 107 ++ .../instances/device-action-catalog.ts | 946 ++++++++++ .../instances/device-action-service.test.ts | 200 +++ .../instances/device-action-service.ts | 449 +++++ .../device-discovery-service.test.ts | 242 +++ .../instances/device-discovery-service.ts | 392 ++++ .../instances/instance-module-catalog.ts | 133 ++ .../instances/instance-module-service.test.ts | 171 ++ .../instances/instance-module-service.ts | 321 ++++ .../instances/instance-service.test.ts | 1 + .../application/instances/instance-service.ts | 26 +- .../src/application/jobs/job-query-service.ts | 65 + .../jobs/job-reconcile-service.test.ts | 108 ++ .../application/jobs/job-reconcile-service.ts | 178 ++ .../activate-pending-secret.test.ts | 17 +- .../legacy-import/legacy-import.test.ts | 26 +- .../messages/hub-message-service.test.ts | 258 +++ .../messages/hub-message-service.ts | 505 ++++++ .../messages/instance-message-service.test.ts | 83 + .../messages/instance-message-service.ts | 121 ++ .../messages/sms-outbox-service.test.ts | 190 ++ .../messages/sms-outbox-service.ts | 483 +++++ .../central-notification-service.test.ts | 458 +++++ .../central-notification-service.ts | 1578 +++++++++++++++++ .../notifications/channel-delivery.test.ts | 305 ++++ .../notifications/channel-delivery.ts | 556 ++++++ .../instance-notification-service.ts | 713 ++++++++ .../notifications/smtp-sender.test.ts | 215 +++ .../application/notifications/smtp-sender.ts | 283 +++ .../operations/secure-operation-execution.ts | 17 +- .../device-organization-service.test.ts | 118 ++ .../device-organization-service.ts | 239 +++ .../instance-resource-service.test.ts | 134 +- .../resources/instance-resource-service.ts | 77 +- .../status/status-snapshot-service.ts | 44 + .../system/component-backup-service.test.ts | 228 +++ .../system/component-backup-service.ts | 604 +++++++ .../system/connection-log-service.test.ts | 179 ++ .../system/connection-log-service.ts | 305 ++++ .../system/log-center-service.test.ts | 189 ++ .../application/system/log-center-service.ts | 445 +++++ .../system/system-maintenance-service.test.ts | 243 +++ .../system/system-maintenance-service.ts | 384 ++++ apps/api/src/canary-gateway.test.ts | 6 +- apps/api/src/cli-runtime.test.ts | 37 +- apps/api/src/control-plane.operations.test.ts | 4 + apps/api/src/control-plane.test.ts | 806 +++++++++ apps/api/src/control-plane.ts | 263 ++- .../infrastructure/database/database.test.ts | 9 + .../database/migrations-upgrade.test.ts | 103 ++ .../src/infrastructure/database/migrations.ts | 282 +++ .../transport/pinned-http-requester.ts | 2 +- .../transport/safe-control-plane-upstream.ts | 2 + .../transport/safe-instance-transport.ts | 8 +- .../transport/safe-upstream-gateway.test.ts | 336 ++++ .../transport/safe-upstream-gateway.ts | 391 +++- .../src/interface/http/automation-routes.ts | 17 +- .../http/central-notification-routes.test.ts | 174 ++ .../http/central-notification-routes.ts | 291 +++ .../http/device-action-routes.test.ts | 173 ++ .../interface/http/device-action-routes.ts | 107 ++ .../interface/http/discovery-routes.test.ts | 157 ++ .../src/interface/http/discovery-routes.ts | 101 ++ .../src/interface/http/event-routes.test.ts | 3 +- apps/api/src/interface/http/fleet-routes.ts | 564 ++++++ .../http/instance-module-routes.test.ts | 67 + .../interface/http/instance-module-routes.ts | 62 + .../api/src/interface/http/instance-routes.ts | 8 + .../api/src/interface/http/job-routes.test.ts | 91 + apps/api/src/interface/http/job-routes.ts | 41 + .../interface/http/log-center-routes.test.ts | 102 ++ .../src/interface/http/log-center-routes.ts | 153 ++ .../http/organization-routes.test.ts | 104 ++ .../src/interface/http/organization-routes.ts | 119 ++ .../src/interface/http/system-routes.test.ts | 316 ++++ apps/api/src/interface/http/system-routes.ts | 213 +++ apps/api/src/production-control-plane.test.ts | 20 +- apps/api/src/production-control-plane.ts | 8 +- apps/api/src/runtime-foundation.test.ts | 6 +- 97 files changed, 18646 insertions(+), 104 deletions(-) create mode 100644 apps/api/src/application/connections/connection-settings-service.test.ts create mode 100644 apps/api/src/application/connections/connection-settings-service.ts create mode 100644 apps/api/src/application/connections/fleet-heartbeat.test.ts create mode 100644 apps/api/src/application/connections/fleet-heartbeat.ts create mode 100644 apps/api/src/application/instances/device-action-catalog.test.ts create mode 100644 apps/api/src/application/instances/device-action-catalog.ts create mode 100644 apps/api/src/application/instances/device-action-service.test.ts create mode 100644 apps/api/src/application/instances/device-action-service.ts create mode 100644 apps/api/src/application/instances/device-discovery-service.test.ts create mode 100644 apps/api/src/application/instances/device-discovery-service.ts create mode 100644 apps/api/src/application/instances/instance-module-catalog.ts create mode 100644 apps/api/src/application/instances/instance-module-service.test.ts create mode 100644 apps/api/src/application/instances/instance-module-service.ts create mode 100644 apps/api/src/application/jobs/job-reconcile-service.test.ts create mode 100644 apps/api/src/application/jobs/job-reconcile-service.ts create mode 100644 apps/api/src/application/messages/hub-message-service.test.ts create mode 100644 apps/api/src/application/messages/hub-message-service.ts create mode 100644 apps/api/src/application/messages/sms-outbox-service.test.ts create mode 100644 apps/api/src/application/messages/sms-outbox-service.ts create mode 100644 apps/api/src/application/notifications/central-notification-service.test.ts create mode 100644 apps/api/src/application/notifications/central-notification-service.ts create mode 100644 apps/api/src/application/notifications/channel-delivery.test.ts create mode 100644 apps/api/src/application/notifications/channel-delivery.ts create mode 100644 apps/api/src/application/notifications/instance-notification-service.ts create mode 100644 apps/api/src/application/notifications/smtp-sender.test.ts create mode 100644 apps/api/src/application/notifications/smtp-sender.ts create mode 100644 apps/api/src/application/organization/device-organization-service.test.ts create mode 100644 apps/api/src/application/organization/device-organization-service.ts create mode 100644 apps/api/src/application/system/component-backup-service.test.ts create mode 100644 apps/api/src/application/system/component-backup-service.ts create mode 100644 apps/api/src/application/system/connection-log-service.test.ts create mode 100644 apps/api/src/application/system/connection-log-service.ts create mode 100644 apps/api/src/application/system/log-center-service.test.ts create mode 100644 apps/api/src/application/system/log-center-service.ts create mode 100644 apps/api/src/application/system/system-maintenance-service.test.ts create mode 100644 apps/api/src/application/system/system-maintenance-service.ts create mode 100644 apps/api/src/infrastructure/database/migrations-upgrade.test.ts create mode 100644 apps/api/src/interface/http/central-notification-routes.test.ts create mode 100644 apps/api/src/interface/http/central-notification-routes.ts create mode 100644 apps/api/src/interface/http/device-action-routes.test.ts create mode 100644 apps/api/src/interface/http/device-action-routes.ts create mode 100644 apps/api/src/interface/http/discovery-routes.test.ts create mode 100644 apps/api/src/interface/http/discovery-routes.ts create mode 100644 apps/api/src/interface/http/fleet-routes.ts create mode 100644 apps/api/src/interface/http/instance-module-routes.test.ts create mode 100644 apps/api/src/interface/http/instance-module-routes.ts create mode 100644 apps/api/src/interface/http/log-center-routes.test.ts create mode 100644 apps/api/src/interface/http/log-center-routes.ts create mode 100644 apps/api/src/interface/http/organization-routes.test.ts create mode 100644 apps/api/src/interface/http/organization-routes.ts create mode 100644 apps/api/src/interface/http/system-routes.test.ts create mode 100644 apps/api/src/interface/http/system-routes.ts 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({