import { readFileSync } from "node:fs"; import { isLogLevel, type LogLevel } from "./logging.js"; import { isValidTimeZone } from "./timezone.js"; export const DEFAULT_CONFIG_PATH = "/etc/mattertimesync/config.json"; export interface Config { /** Directory holding persistent Matter fabric state and service state. Contains secrets. */ storagePath: string; /** IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset. */ timezone: string; logLevel: LogLevel; } export class ConfigError extends Error {} const DEFAULTS = { timezone: "America/Chicago", logLevel: "info" as LogLevel, }; export function loadConfig(path: string): Config { let raw: string; try { raw = readFileSync(path, "utf8"); } catch (cause) { throw new ConfigError( `Cannot read configuration file ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (cause) { throw new ConfigError( `Configuration file ${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, ); } return validateConfig(parsed, path); } export function validateConfig(parsed: unknown, source = "configuration"): Config { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new ConfigError(`${source}: top-level value must be a JSON object`); } const record = parsed as Record; // Device membership lives in controller storage, not the configuration, so // there is deliberately no nodeId field; commands take --node instead. const known = new Set(["storagePath", "timezone", "logLevel"]); for (const key of Object.keys(record)) { if (!known.has(key)) { throw new ConfigError(`${source}: unknown field "${key}"`); } } const storagePath = record["storagePath"]; if (typeof storagePath !== "string" || storagePath.length === 0) { throw new ConfigError(`${source}: "storagePath" is required and must be a non-empty string`); } const timezone = record["timezone"] === undefined ? DEFAULTS.timezone : record["timezone"]; if (typeof timezone !== "string" || !isValidTimeZone(timezone)) { throw new ConfigError( `${source}: "timezone" must be a valid IANA time-zone name (got ${JSON.stringify(timezone)})`, ); } const logLevel = record["logLevel"] === undefined ? DEFAULTS.logLevel : record["logLevel"]; if (!isLogLevel(logLevel)) { throw new ConfigError(`${source}: "logLevel" must be one of debug, info, warn, error`); } return { storagePath, timezone, logLevel }; } /** * Parses a node ID given on the command line (--node). Node IDs are 64-bit * values; they are carried as decimal strings so they never pass through a * lossy JavaScript number. */ export function parseNodeId(value: unknown, source = "--node"): bigint { if (typeof value === "string" && /^\d+$/.test(value)) { return toNodeId(BigInt(value), source); } if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { return toNodeId(BigInt(value), source); } throw new ConfigError(`${source}: node ID must be a decimal string; got ${JSON.stringify(value)}`); } function toNodeId(value: bigint, source: string): bigint { if (value < 0n || value >= 1n << 64n) { throw new ConfigError(`${source}: node ID must fit in an unsigned 64-bit integer`); } return value; }