OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / config / loader.js

190 lines · javascript ref:
/**
 * Configuration Loader
 *
 * Priority (highest first):
 *   1. CLI flags (passed as overrides)
 *   2. ~/.hiverelay/config.json (user config file)
 *   3. config/default.js (built-in defaults)
 */

import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import { createHmac } from 'crypto'
import defaults from './default.js'
import {
  getStorageCapProvenance,
  markStorageCapDefault,
  markStorageCapExplicit
} from './storage-cap.js'

const HIVERELAY_DIR = join(homedir(), '.hiverelay')
const CONFIG_PATH = join(HIVERELAY_DIR, 'config.json')
const STORAGE_DIR = join(HIVERELAY_DIR, 'storage')

export { HIVERELAY_DIR, CONFIG_PATH, STORAGE_DIR }
export {
  copyStorageCapProvenance,
  getStorageCapProvenance,
  markStorageCapDefault,
  markStorageCapExplicit,
  resolveStorageCap
} from './storage-cap.js'

/**
 * Derive a stable management token from a host-provided seed (e.g. the
 * `$APP_SEED` env var that self-hosting platforms like Umbrel inject —
 * deterministic per app-id, so it survives reinstalls). Domain-separated
 * from the wizard's identity/encryption key (`hiverelay/wizard/v1`) so the
 * two derivations can never collide. Returns a 64-char hex string, or null
 * if the seed is missing/too short to be useful.
 */
export function deriveTokenFromSeed (seed) {
  if (!seed || typeof seed !== 'string' || seed.length < 32) return null
  return createHmac('sha256', 'hiverelay/ui-token/v1')
    .update(Buffer.from(seed, 'utf8'))
    .digest('hex')
}

/**
 * Apply the HIVERELAY_OUTBOXLOG_NAMESPACE env var to CLI overrides.
 *
 * The app-neutral outboxlog rejects records signed under an unregistered
 * namespace (`unknown namespace`, 400). An ENV-driven operator (fleet/bern box
 * with HIVERELAY_OUTBOXLOG=1) needs a way to register the namespace their app
 * signs under (e.g. Peerit signs 'peerit'); this maps the env value to
 * config.outboxlog.namespace, which OutboxLogApp.start() feeds to the engine's
 * namespace registry.
 *
 * Precedence — env is a DEFAULT, not a permanent override, mirroring
 * HIVERELAY_ACCEPT_MODE / HIVERELAY_MAX_STORAGE. Because loadConfig()'s deep
 * merge is `defaults < config.json < cliOverrides`, applying the env
 * unconditionally would clobber a persisted config.json outboxlog.namespace.
 * So the env is only applied when the operator has NOT persisted an explicit
 * outboxlog.namespace (hasPersistedNamespace === false) and has not already set
 * one on cliOverrides. Mutates and returns cliOverrides.
 *
 * @param {object} cliOverrides - the mutable CLI-override object
 * @param {string|undefined} rawEnvValue - process.env.HIVERELAY_OUTBOXLOG_NAMESPACE
 * @param {boolean} hasPersistedNamespace - true if config.json already has outboxlog.namespace
 */
export function applyOutboxlogNamespaceEnv (cliOverrides = {}, rawEnvValue, hasPersistedNamespace = false) {
  if (!rawEnvValue || hasPersistedNamespace) return cliOverrides
  const namespace = String(rawEnvValue).trim()
  if (!namespace) return cliOverrides
  if (!cliOverrides.outboxlog || typeof cliOverrides.outboxlog !== 'object') {
    cliOverrides.outboxlog = {}
  }
  if (typeof cliOverrides.outboxlog.namespace !== 'string') {
    cliOverrides.outboxlog.namespace = namespace
  }
  return cliOverrides
}

/**
 * Load config: defaults < config.json < CLI overrides
 */
export function loadConfig (cliOverrides = {}) {
  let fileConfig = {}

  if (existsSync(CONFIG_PATH)) {
    try {
      fileConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
    } catch {
      // Ignore malformed config file
    }
  }

  // Deep merge: defaults < file < CLI (preserves nested object keys)
  const config = deepMerge(deepMerge(defaults, fileConfig), cliOverrides)

  // Presence, not numeric equality, is provenance. In particular, an
  // explicitly configured 50 GiB cap must not be mistaken for the built-in
  // 50 GiB default and rewritten by disk-relative resolution.
  if (Object.prototype.hasOwnProperty.call(cliOverrides, 'maxStorageBytes')) {
    const overrideProvenance = getStorageCapProvenance(cliOverrides)
    markStorageCapExplicit(config, overrideProvenance?.source || 'cli')
  } else if (Object.prototype.hasOwnProperty.call(fileConfig, 'maxStorageBytes')) {
    markStorageCapExplicit(config, 'persisted')
  } else {
    markStorageCapDefault(config)
  }

  // Always resolve storage to absolute path
  if (config.storage === defaults.storage && fileConfig.storage == null && cliOverrides.storage == null) {
    config.storage = STORAGE_DIR
  }

  return config
}

/**
 * Write config to ~/.hiverelay/config.json
 */
export function saveConfig (config) {
  mkdirSync(HIVERELAY_DIR, { recursive: true })
  mkdirSync(STORAGE_DIR, { recursive: true })

  // Only persist non-default values
  const toSave = {}
  const storageCapProvenance = getStorageCapProvenance(config)
  for (const [key, val] of Object.entries(config)) {
    if (key === 'maxStorageBytes') {
      // A resolved default may be below 50 GiB. Persisting that derived value
      // would turn it into an operator designation on restart and ratchet the
      // cap down again. Explicit values, including exactly 50 GiB, must always
      // be written.
      const explicit = storageCapProvenance
        ? storageCapProvenance.explicit === true
        : Object.prototype.hasOwnProperty.call(config, 'maxStorageBytes')
      if (explicit) toSave[key] = val
      continue
    }
    if (JSON.stringify(val) !== JSON.stringify(defaults[key])) {
      toSave[key] = val
    }
  }

  writeFileSync(CONFIG_PATH, JSON.stringify(toSave, null, 2) + '\n')
  return CONFIG_PATH
}

/**
 * Ensure ~/.hiverelay directory structure exists
 */
export function ensureDirs () {
  mkdirSync(HIVERELAY_DIR, { recursive: true })
  mkdirSync(STORAGE_DIR, { recursive: true })
}

/**
 * Recursively merge source into target, preserving sibling keys in nested objects.
 */
function deepMerge (target, source) {
  const result = cloneConfigValue(target)
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
    if (
      source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) &&
      result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])
    ) {
      result[key] = deepMerge(result[key], source[key])
    } else {
      result[key] = cloneConfigValue(source[key])
    }
  }
  return result
}

function cloneConfigValue (value) {
  if (Array.isArray(value)) return value.map(cloneConfigValue)
  if (!value || typeof value !== 'object') return value

  const out = {}
  for (const key of Object.keys(value)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
    out[key] = cloneConfigValue(value[key])
  }
  return out
}