Files
multi-simadmin/apps/api/src/application/connections/connection-settings-service.ts
T
chick 635527dd26 feat(api): integrate identity, notification scope and connection settings into control plane
- Wire identity, metrics and update services into the assembled API.

- Add connection authorization modes and scoped console authorization.

- Preserve notification group IDs for filtered delivery.

- Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
2026-09-07 00:45:18 +08:00

127 lines
4.4 KiB
TypeScript

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<ConnectionSettings> = 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<string, unknown>)
: 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;
}
}