// hpp-onboard — provision a fresh atproto-base SWARM account rooted on a hyperident, and manage
// its registration + login password. This is the PROPER onboarding path (see README "Proper
// setup"): one BIP39 mnemonic (the hyperident root) anchors the DID — did:plc = f(mnemonic +
// handle + pdsUrl). The root recovery/rotation key STAYS with the hyperident; this device holds
// only the SHARED #atproto signing key + its keet device attestation. A single device is a
// 1-member swarm; add devices later by attesting their pubkey — no new PLC op, no migration.
//
// THREE modes:
// (mint) bare bin/hpp-onboard.js --handle <h> --pds <url> [--app-password <pw>] [--submit]
// (submit later) bare bin/hpp-onboard.js --handle <h> --pds <url> --submit # config already minted
// (set password) bare bin/hpp-onboard.js --set-password <pw> [--did <did>] # change the bsky login pw
// --did names WHICH account on a multi-DID config (required when >1 is hosted).
//
// SAFE BY DEFAULT: minting touches NOTHING external. --submit performs the ONE irreversible action
// (POST the genesis to the global PLC directory). The root mnemonic + signing key are used
// in-process and NEVER printed. --submit is idempotent-friendly: it re-derives the deterministic
// genesis from the hyperident and submits it, whether the config was just minted or minted earlier.
import '../lib/bare-globals.js'
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'bare-fs'
import { join } from 'bare-path'
import os from 'bare-os'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import IdentityKey from 'keet-identity-key'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
import { hashPassword } from 'atproto-identity/lib/auth.js'
import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
import { createSwarmIdentity } from 'atproto-identity'
import { provisionDevice } from '../lib/device-node.js'
import { applySetPassword } from '../lib/set-password.js'
const argv = (typeof Bare !== 'undefined' && Bare.argv) ? Bare.argv : process.argv
function arg (name, def = null) {
const i = argv.indexOf('--' + name)
if (i === -1) return def
const v = argv[i + 1]
return (v && !String(v).startsWith('--')) ? v : true // bare flag (--submit) → true
}
const die = (m) => { console.error('hpp-onboard: ' + m); process.exit(1) }
const HOME = os.homedir()
const expand = (p) => String(p).replace(/^~(?=$|\/)/, HOME)
const readJson = (p) => JSON.parse(b4a.toString(readFileSync(p)))
const configDir = expand(arg('config', join(HOME, '.config', 'hyper-proto-peer')))
const configPath = join(configDir, 'config.json')
const setPassword = arg('set-password')
// ── MODE: set-password — change the bsky login password on an existing account (no re-mint, no PLC).
if (setPassword) {
if (setPassword === true) die('--set-password requires a value: --set-password <pw>')
if (!existsSync(configPath)) die('--set-password: no config at ' + configPath + ' (onboard first)')
const cfg = readJson(configPath)
// Route the hash to the account that is actually READ at boot. Writing the top level of a multi-DID
// config is a silent no-op — see lib/set-password.js for the full why. Refuses rather than guesses
// when several accounts are hosted.
let applied
try {
applied = applySetPassword(cfg, { did: arg('did') === true ? null : arg('did'), hash: hashPassword(String(setPassword)) })
} catch (e) {
die('--set-password: ' + e.message)
}
writeFileSync(configPath, b4a.from(JSON.stringify(cfg, null, 2)))
console.log(JSON.stringify({ msg: 'password updated', mode: applied.mode, did: applied.did, handle: applied.handle, warning: applied.warning }))
if (applied.warning) console.log('# WARNING: ' + applied.warning)
console.log('# Restart hpp on this box to apply (pgh app restart hyper-proto-peer). Login: ' + (applied.handle || applied.did) + ' / <the new password>.')
// A DID hosted on several machines verifies sessions on WHICHEVER co-owner the bridge is fronting, so
// a hash changed on one box and not the others breaks login intermittently and unreproducibly.
console.log('# If this DID is CO-OWNED, run the identical command on every co-owner — the hash must match on all of them.')
process.exit(0)
}
const handle = arg('handle')
const pdsUrl = arg('pds')
if (!handle || handle === true || !pdsUrl || pdsUrl === true) die('--handle <h> and --pds <url> are required')
const hyperidentDir = expand(arg('hyperident', join(HOME, '.hyperident')))
const storage = expand(arg('storage', join(HOME, '.config', 'hyper-proto-peer', 'data')))
const plcUrl = arg('plc', 'https://plc.directory')
const writers = arg('writers') && arg('writers') !== true
? String(arg('writers')).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
: null
const submit = arg('submit') === true
// Root mnemonic from the hyperident (the identity anchor). NEVER printed. Plaintext today;
// encrypted-vault unlock is a TODO (ashatron's is plaintext — encrypted:false).
const idFile = join(hyperidentDir, 'identity')
if (!existsSync(idFile)) die('no hyperident identity at ' + idFile + ' (run hyperident init first)')
const idJson = readJson(idFile)
if (idJson.encrypted) die('hyperident is passphrase-encrypted — unlock not wired yet (TODO). This box (ashatron) is plaintext.')
const rootMnemonic = idJson.mnemonic
if (!rootMnemonic) die('hyperident identity has no mnemonic field')
async function registerGenesis (did, signedOp) {
// Bare has no global fetch; PlcDirectory needs one for the POST. Install the WHATWG-standard
// bare-fetch global lazily (only --submit needs network; mint/set-password stay offline).
if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
const plc = new PlcDirectory({ baseUrl: plcUrl })
const data = await plc.submitGenesis({ did, signedOp })
console.log(JSON.stringify({ msg: 'PLC genesis submitted', did, alsoKnownAs: data && data.alsoKnownAs, services: data && data.services }))
console.log('# Registered. Next: start hpp, add ' + did + ' to the bridge frontedDids, then federate.')
}
// ── MODE: submit-later — config already minted; re-derive the DETERMINISTIC genesis + submit it.
if (existsSync(configPath) && submit) {
const cfg = readJson(configPath)
const id = createSwarmIdentity(rootMnemonic, { handle: cfg.handle, pdsUrl: cfg.pdsUrl })
if (id.did !== cfg.did) die('re-derived DID (' + id.did + ') != config DID (' + cfg.did + ') — hyperident/handle/pds mismatch; refusing to submit')
await registerGenesis(id.did, id.signedOp)
process.exit(0)
}
if (existsSync(configPath)) die('config exists at ' + configPath + ' — use --submit to register it, --set-password to change the login pw, or move it aside to re-onboard')
// ── MODE: fresh mint — provision a new device + config from the hyperident root.
const deviceMnemonic = IdentityKey.generateMnemonic()
const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
const bundle = await provisionDevice({ rootMnemonic, devicePublicKey: b4a.toString(deviceKp.publicKey, 'hex'), handle, pdsUrl })
const jwtSecret = b4a.toString(crypto.randomBytes(32), 'hex')
const appPassword = (arg('app-password') && arg('app-password') !== true)
? String(arg('app-password'))
: b4a.toString(crypto.randomBytes(12), 'base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 16)
const appPasswordHash = hashPassword(appPassword)
const config = { swarm: true, handle, pdsUrl, did: bundle.did, deviceMnemonic, signingKey: bundle.signingKey, deviceProof: bundle.deviceProof, rootIdentityKey: bundle.rootIdentityKey, appPasswordHash, jwtSecret, writers }
mkdirSync(configDir, { recursive: true })
mkdirSync(storage, { recursive: true })
writeFileSync(configPath, b4a.from(JSON.stringify(config, null, 2)))
console.log(JSON.stringify({ msg: 'provisioned', did: bundle.did, handle, pdsUrl, configPath, appPassword, submitted: false }))
if (submit) await registerGenesis(bundle.did, bundle.signedOp)
else console.log('# DID minted locally — nothing external touched. Review it, then re-run with --submit to register in the global PLC (IRREVERSIBLE).')