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; readonly authorizationMode: 'auto' | 'manual'; } 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, authorizationMode: 'auto', }); const SETTING_KEY = 'connection.settings'; const FIELDS: readonly (keyof ConnectionSettings)[] = [ 'heartbeatSeconds', 'offlineSeconds', 'authorizationMode', ]; 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 authorizationMode = source.authorizationMode === undefined ? DEFAULT_CONNECTION_SETTINGS.authorizationMode : source.authorizationMode; if (authorizationMode !== 'auto' && authorizationMode !== 'manual') throw new TypeError('authorizationMode must be auto or manual'); 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, authorizationMode }; } 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; } }