src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/config.ts
blob: 14dbd4f7a71b1f4d913d26b024b0f9a24c569af7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
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;
}