- 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.
127 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
}
|