// 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
}