// applySetPassword — place a new login password hash on the RIGHT account in a config.
//
// THE BUG THIS FIXES. `hpp-onboard --set-password` did `cfg.appPasswordHash = hashPassword(pw)`,
// writing the TOP LEVEL of config.json. That is correct for a legacy single-DID config and wrong for
// every multi-DID one — and all three production hosts are multi-DID. `bootMultiDevice` spreads the
// per-DID entry into the device (`bootSwarmDevice({ storage, ...entry })`, device-node.js:694), so
// `sessions` is built from `entry.appPasswordHash`. A top-level field is never read by anything.
//
// So the command printed "password updated", exited 0, and changed NOTHING: the old password kept
// working and the new one never did. A silent no-op that reports success is the worst shape of bug —
// it is believed, and the next person debugs the login instead of the tool.
//
// Pure: takes the already-computed hash and mutates/returns the config object. No I/O, no scrypt, so
// the behaviour is testable without a keystore or a running daemon.
// Decide WHICH account gets the hash, and refuse rather than guess when it is ambiguous.
// Returns { mode, did, handle, warning? }. Throws with an actionable message on ambiguity.
export function applySetPassword (cfg, { did = null, hash } = {}) {
if (!cfg || typeof cfg !== 'object') throw new Error('applySetPassword: config object required')
if (typeof hash !== 'string' || !hash) throw new Error('applySetPassword: hash required')
const dids = Array.isArray(cfg.dids) ? cfg.dids : null
// MULTI-DID: the per-DID entry is the only thing read at boot.
if (dids && dids.length) {
if (!did && dids.length > 1) {
throw new Error(
'this is a MULTI-DID config with ' + dids.length + ' accounts — name one with --did <did>.\n' +
'hosted: ' + dids.map((d) => (d && d.did) + (d && d.handle ? ' (' + d.handle + ')' : '')).join(', ')
)
}
const target = did ? dids.find((d) => d && d.did === did) : dids[0]
if (!target) {
throw new Error(did + ' is not a hosted DID in this config.\n' +
'hosted: ' + dids.map((d) => d && d.did).join(', '))
}
target.appPasswordHash = hash
const out = { mode: 'multi', did: target.did, handle: target.handle || null }
// A DID with no jwtSecret cannot mint sessions at all, so a password on it does nothing on its own.
// That is the deliberate shape of a co-owned community feed (nobody logs in AS a feed), so this
// warns rather than blocks — a two-step setup that adds the secret next is legitimate.
if (!target.jwtSecret) {
out.warning = 'this DID has no jwtSecret, so it cannot mint sessions — the password alone will not ' +
'enable login. If it is a community feed, it is meant to have neither.'
}
return out
}
// LEGACY single-DID: the top level IS the account.
if (did && cfg.did && did !== cfg.did) {
throw new Error('--did ' + did + ' does not match this single-DID config (' + cfg.did + ')')
}
cfg.appPasswordHash = hash
const out = { mode: 'single', did: cfg.did || null, handle: cfg.handle || null }
if (!cfg.jwtSecret) out.warning = 'this config has no jwtSecret, so it cannot mint sessions'
return out
}