src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/config.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/config.ts')
-rw-r--r--src/config.ts99
1 files changed, 99 insertions, 0 deletions
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..14dbd4f
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,99 @@
+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<string, unknown>;
+
+ // 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;
+}