OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / config.js

122 lines · javascript ref:
// Config — a file (${HOME}/.config/hyper-proto-peer/config.json) overlaid by PEER_* env, with
// defaults. One place decides storage/handle/pds/adopt so the daemon is config-driven (a normal
// pgh app), not purely env-driven. Env wins over file wins over default, so the pgh manifest's
// defaults.env and an operator's config.json both work.
//
// SECRETS STAY OUT OF GIT: the adopt device signing key (adoptDevicePrivHex) and the root
// mnemonic live in the operator's config.json on the box (or env at provision time) — NEVER in
// the committed manifest. The self-custody key is written to the vault (<storage>/peer-identity.json)
// on first boot and reloaded thereafter; config.json is only consulted to MINT the vault.

import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'

const DEFAULT_DIR = path.join(os.homedir(), '.config', 'hyper-proto-peer')

function expand (v) {
  if (typeof v !== 'string') return v
  return v.replace(/\$\{HOME\}/g, os.homedir()).replace(/^~(?=\/|$)/, os.homedir())
}

// Normalize a WRITERS allowlist (Phase-2 write path): the hex Noise pubkeys of clients
// authorized to call the peer's `apply-write` RPC. Accepts an env comma-string
// (PEER_WRITERS) or a file array/comma-string (`writers`) → lowercased hex list, or null.
function normalizeWriters (raw) {
  if (!raw) return null
  const list = Array.isArray(raw) ? raw : String(raw).split(',')
  const set = list.map((s) => String(s).trim().toLowerCase()).filter(Boolean)
  return set.length ? set : null
}

// Normalize the device roster to [{ key: <hex>, role: 1 }] (all devices role-equal). Accepts a
// comma-string of hex pubkeys or an array of hex strings / {key,role} objects.
export function normalizeMembers (raw) {
  if (!raw) return null
  const list = Array.isArray(raw) ? raw : String(raw).split(',')
  const out = []
  for (const m of list) {
    const key = (typeof m === 'object' && m ? m.key : m)
    const hex = String(key || '').trim().toLowerCase()
    if (hex) out.push({ key: hex, role: 1 })
  }
  return out.length ? out : null
}

export function configPath () {
  return process.env.PEER_CONFIG || path.join(DEFAULT_DIR, 'config.json')
}

export function loadConfig () {
  let file = {}
  const p = configPath()
  try { file = JSON.parse(fs.readFileSync(p, 'utf8')) } catch { /* absent/invalid → defaults */ }

  const env = process.env
  const pick = (fileKey, envKey, def) => {
    if (env[envKey] != null && env[envKey] !== '') return env[envKey]
    if (file[fileKey] != null) return file[fileKey]
    return def
  }

  const storage = expand(pick('storage', 'PEER_STORAGE', path.join(DEFAULT_DIR, 'data')))
  const cfg = {
    storage,
    handle: pick('handle', 'PEER_HANDLE', 'peer.local'),
    pdsUrl: pick('pdsUrl', 'PEER_PDS_URL', 'https://pds.local'),
    appPassword: pick('appPassword', 'PEER_APP_PASSWORD', null),
    // WRITERS allowlist: hex Noise pubkeys allowed to call apply-write. Null (default) =
    // no remote writers → apply-write is NOT registered (secure default; the peer still
    // authors its own commits). See index.js (canWrite wiring) + lib/peer.js apply-write.
    writers: normalizeWriters(pick('writers', 'PEER_WRITERS', null)),
    // ADOPT an existing published DID (migration): presence of adoptDid + adoptDidKey +
    // adoptDevicePrivHex triggers Peer.adopt on first boot (only if no vault yet). adoptCar is
    // an optional path to a repo CAR whose records are imported (re-authored under the peer key).
    adoptDid: pick('adoptDid', 'ADOPT_DID', null),
    adoptDidKey: pick('adoptDidKey', 'ADOPT_DID_KEY', null),
    adoptDevicePrivHex: pick('adoptDevicePrivHex', 'ADOPT_DEVICE_PRIV_HEX', null),
    adoptCar: expand(pick('adoptCar', 'ADOPT_CAR', null)),
    // INIT a fresh self-custody identity (onboarding): the hyperident root recovery seed. Only
    // used when there is no vault AND no adopt block. Never stored in the vault.
    rootMnemonic: pick('rootMnemonic', 'PEER_ROOT_MNEMONIC', null),
    // MULTI-DEVICE swarm mode (atproto-base): boot a SwarmDevice (N attested devices of one DID
    // share the repo) instead of the single-tenant peer. Needs rootMnemonic (identity/shared-key
    // root) + deviceMnemonic (this device's keet identity, attested to the root).
    swarm: /^(1|true|yes)$/i.test(String(pick('swarm', 'PEER_SWARM', ''))),
    // ONE-TIME firehose repair: clear a malformed __commit__ log + re-emit a clean continuation from
    // the adoptCar baseline (guarded by a durable marker, so it's safe to leave set). See
    // bootSwarmDevice/SwarmDevice.resetFirehose. Set PEER_RESET_FIREHOSE=1 (or config resetFirehose).
    resetFirehose: /^(1|true|yes)$/i.test(String(pick('resetFirehose', 'PEER_RESET_FIREHOSE', ''))),
    deviceMnemonic: pick('deviceMnemonic', 'PEER_DEVICE_MNEMONIC', null),
    // The provisioned device BUNDLE (atproto-base-REVOCATION.md §4): a swarm device boots from these
    // — the received DID + shared signing key + root->device attestation + root identity pubkey —
    // NOT the root mnemonic (which it must never hold). Produced by provisionDevice() on the root.
    did: pick('did', 'PEER_DID', null),
    signingKey: pick('signingKey', 'PEER_SIGNING_KEY', null),
    deviceProof: pick('deviceProof', 'PEER_DEVICE_PROOF', null),
    rootIdentityKey: pick('rootIdentityKey', 'PEER_ROOT_IDENTITY_KEY', null),
    // bsky-login secrets (the peer is the sole auth authority): scrypt hash of the app-password +
    // the HMAC secret for session JWTs. Carried in the swarm config because the swarm boots from the
    // bundle, not the single-tenant vault. Enables com.atproto.server.createSession via the keyless
    // bridge. See lib/sessions.js + serve-membrane.js.
    appPasswordHash: pick('appPasswordHash', 'PEER_APP_PASSWORD_HASH', null),
    jwtSecret: pick('jwtSecret', 'PEER_JWT_SECRET', null),
    // The device ROSTER (co-equal multi-writer): the hex keet-identity pubkeys of ALL attested
    // devices of this DID. Each device is admitted iff it proves attestation AND is in this set.
    // Absent → self-only (this device signs; others can read/replicate but not co-sign). Accepts a
    // PEER_MEMBERS comma-string or a `members` file array of hex strings / {key,role} objects.
    members: normalizeMembers(pick('members', 'PEER_MEMBERS', null)),
    // MULTI-DID (multi-tenant) mode: a `dids` array in config.json — each entry a full per-DID bundle
    // { did, signingKey, deviceMnemonic, deviceProof, rootIdentityKey, handle, pdsUrl?, members?,
    // writers?, adopt? }. Present + swarm → index.js boots bootMultiDevice (ONE daemon, N DIDs, one
    // shared Corestore/swarm/wakeup). Absent → the single-DID path (this config's own did fields). A
    // user's own DID + the communities they co-own live here as separate entries.
    // Normalize each per-DID roster the same way the single-DID path does — bootMultiDevice passes
    // dids[] verbatim to bootSwarmDevice, whose projectMembers needs [{key,role}], NOT a raw
    // "hex,hex" comma-string. Without this a fresh multi-DID StateBase crashes on the roster encode.
    dids: Array.isArray(file.dids) ? file.dids.map((d) => ({ ...d, members: normalizeMembers(d && d.members) })) : null,
    configPath: p
  }
  return cfg
}