// Deploy entry — the launcher pgh/pear-git invokes as `node index.js`.
//
// A config-driven, self-custody hyper-proto-peer daemon: it holds the user's signing key (in the
// vault, never uploaded), authors + signs its own atproto repo, and serves it + its signed
// firehose over Hyperswarm, discoverable by DID (Option 2). atproto-bridge fronts it KEYLESSLY.
//
// First boot picks ONE onboarding path from config (config.js: file + PEER_*/ADOPT_* env):
// - vault exists -> Peer.load (normal restart; the durable path)
// - adoptDid + key present -> Peer.adopt + import (migrate an existing published DID)
// - rootMnemonic present -> Peer.init (fresh self-custody onboarding)
// The vault (<storage>/peer-identity.json) survives updates because PEER_STORAGE defaults OUTSIDE
// the install tree. readiness: pidfile — this is a swarm-only daemon (no HTTP/UDS surface).
import './lib/bare-globals.js' // web-global polyfills for Bare (TextEncoder/…) — MUST load first
import Hyperswarm from 'hyperswarm'
import { existsSync, readFileSync } from 'bare-fs'
import { join } from 'bare-path'
import { Peer } from './lib/peer.js'
import { loadConfig } from './lib/config.js'
import { loadOrCreateSwarmKeyPair } from './lib/swarm-keypair.js'
import { readCar, listRecords } from 'atproto-repo/lib/repo.js'
const cfg = loadConfig()
// CLI subcommand dispatch on the hyper-proto-peer binary. With no subcommand this is the daemon (below);
// `hyper-proto-peer rebuild-did <did>` / `resync-did <did>` run an operational heal command and exit.
const argv = (typeof globalThis.Bare !== 'undefined' && globalThis.Bare.argv) ? globalThis.Bare.argv.slice(2) : []
const sub = (argv[0] && !argv[0].startsWith('-') && !argv[0].startsWith('did:')) ? argv[0] : null
// diag — READ-ONLY: get-state + members-diag + pointer-diag + creator role for every hosted DID as ONE
// JSON blob. `ssh host 'hyper-proto-peer diag'` replaces the heredoc + throwaway .mjs with absolute-path
// imports that inspecting a running daemon used to require. `--remote <did>` asks over the swarm instead.
if (sub === 'diag') {
const { diagCommand } = await import('./lib/diag.js')
globalThis.Bare.exit(await diagCommand(argv.slice(1)))
}
// cores — READ-ONLY: map the discovery key from a `[hypercore] conflict detected in <dkey>` log line back
// to an actual core (store dir, public key, alias name, and whether THIS machine can sign it). That log is
// upstream's only trace of a conflict and prints a one-way hash, so the core was previously unnameable.
// Opens every corestore read-only, so it is safe to run against the daemon while it is up.
// rotation — READ-ONLY by default: can we still CHANGE each hosted DID? Every other field in `diag`
// answers "is this node advancing?"; none can answer "could we still fix this if we had to?", and that
// question had exactly one wrong answer for six weeks (#47). `rotation gen` mints an offline recovery
// key; `rotation add` registers one (DRY RUN unless --publish — a PLC append is permanent).
if (sub === 'rotation') {
const { rotationCommand } = await import('./lib/rotation-cli.js')
globalThis.Bare.exit(await rotationCommand(argv.slice(1)))
}
if (sub === 'cores') {
const { coresCommand } = await import('./lib/cores.js')
globalThis.Bare.exit(await coresCommand(argv.slice(1)))
}
// notifications — verify the authenticated AppView read chain (createSession -> listNotifications)
// through the keyless bridge, as a real client would. `diag` answers "what is this node doing?" from the
// inside; this answers "does the user-scoped path actually work?" from the outside, which is the only
// question the two service-auth gates can be checked against. Doing it by hand meant a hand-built JWT
// and a secret on an argv line — see lib/notifications.js.
if (sub === 'notifications') {
const { notificationsCommand } = await import('./lib/notifications.js')
globalThis.Bare.exit(await notificationsCommand(argv.slice(1)))
}
if (sub === 'reindex-overlay') {
// Operator repair against the RUNNING daemon over its UDS (no restart): re-register every record +
// blob file so peers can fetch by oid, and announce local blobs that have no shared pointer.
const { reindexOverlayCommand } = await import('./lib/reindex-overlay.js')
globalThis.Bare.exit(await reindexOverlayCommand(argv.slice(1)))
}
if (sub === 'repair-pointers') {
// Operator repair for a converged pointer whose BYTES exist nowhere — the state reindex-overlay
// cannot fix, because there is nothing left to re-register. Attempts a real fetch FIRST and
// tombstones only what no peer can produce. DRY RUN unless --write.
const { repairPointersCommand } = await import('./lib/repair-pointers.js')
globalThis.Bare.exit(await repairPointersCommand(argv.slice(1)))
}
// hosting-show / hosting-attach / hosting-detach — accept a bridge's invite, or leave.
//
// Genesis is P2P-only, so an identity exists without anyone's permission; being FRONTED by a membrane is
// what an invite grants. Accepting one is a PLC update adding a handle and a PDS endpoint, and it can
// only be signed by a current rotation key — so the operator cannot do it for you, and you cannot front
// yourself. Preview by default; --yes appends.
if (sub === 'hosting-show' || sub === 'hosting-attach' || sub === 'hosting-detach') {
const { hostingCommand } = await import('./lib/hosting-cli.js')
globalThis.Bare.exit(await hostingCommand(argv))
}
// coowner-invite / coowner-join — admit another machine as a co-owner of a DID. A COMMAND, because doing
// it by hand is four hex-string edits across two boxes with a shared secret in the middle, and it is what
// anyone adding a second machine needs. No root mnemonic involved: the invite is chain-attested by the
// inviting device itself (atproto-identity attestDeviceViaDevice). See lib/coowner.js for the ceremony.
// `--pair` mints the invite over a BLIND-PAIRING TUNNEL instead of a bundle file. Same ceremony, same
// crypto — only the hand-off changes. The file route asks an operator to move a secret between two boxes
// by hand, which is fine between servers you have shells on and impossible for a phone. Any node holding
// the DID can run this: keet's addDevice is available to every active member and confers nothing on the
// adder, and coownerInvite no longer claims the genesis.
if (sub === 'coowner-invite' && argv.includes('--pair')) {
const { mintAdoptInvite } = await import('./lib/adopt.js')
const did = argv.find((a) => a.startsWith('did:'))
if (!did) { console.error('usage: hyper-proto-peer coowner-invite <did> --pair [--config <file>]'); globalThis.Bare.exit(1) }
const ci = argv.indexOf('--config')
const configFile = ci >= 0 ? argv[ci + 1] : null
try {
// APPROVAL IS THE INVOCATION. coownerInvite MUTATES the config and the bundle carries the shared
// signing key, so mintAdoptInvite refuses to fire without an approve() — running this command, on a
// machine that holds the DID, IS that approval. Same posture as favela-web's `pair`.
const src = await mintAdoptInvite({ did, approve: () => true, configFile })
console.log('\n=== invite for ' + did + ' ===\n')
console.log(' ' + src.inviteHex + '\n')
console.error('Paste that ONE string into the joining device. It carries the shared signing key, so\ntreat it like a password: single-use, and anyone who reads it first can take the identity.\n')
console.error('Waiting for the device to join (Ctrl-C to cancel)…')
const r = await src.waitForPeer()
if (r) console.error('\n✓ joined — device ' + String(r.devicePublicKey).slice(0, 16) + '… roster now ' + (r.members ? r.members.length : '?') + ' device(s)')
else console.error('\n✗ refused — nothing was written')
try { await src.close() } catch {}
console.error('\nRestart this daemon and the joiner so both pick up the new roster.')
globalThis.Bare.exit(r ? 0 : 1)
} catch (e) {
console.error(String(e.message || e))
globalThis.Bare.exit(1)
}
}
if (sub === 'coowner-invite' || sub === 'coowner-join') {
const { coownerCli } = await import('./lib/coowner.js')
try {
const r = await coownerCli(sub, argv.slice(1))
console.log(JSON.stringify(r, null, 2))
if (sub === 'coowner-join' && !r.joined) {
console.error('\nNext: on a machine that ALREADY co-owns this DID, run\n hyper-proto-peer coowner-invite ' + (argv.find((a) => a.startsWith('did:')) || '<did>') + ' --device ' + r.devicePublicKey + '\nthen bring its `bundle` back here as a file and re-run with --bundle <file>.')
}
if (sub === 'coowner-invite') {
console.error('\nThe bundle above carries the SHARED SIGNING KEY — treat it as a secret: private path, trusted channel, delete after joining.\nRestart BOTH daemons once the joiner has run coowner-join --bundle.')
}
globalThis.Bare.exit(0)
} catch (e) {
console.error(String(e.message || e))
globalThis.Bare.exit(1)
}
}
if (sub === 'rebuild-did' || sub === 'resync-did') {
const rest = argv.slice(1)
if (sub === 'rebuild-did') {
const { rebuildDidCommand } = await import('./lib/rebuild-did.js')
globalThis.Bare.exit(await rebuildDidCommand(rest))
} else {
const { resyncDid, readResyncIndex } = await import('./lib/device-node.js')
const did = rest.find((a) => a.startsWith('did:'))
const sIdx = rest.indexOf('--storage')
const storage = sIdx >= 0 ? rest[sIdx + 1] : cfg.storage
if (!did) { console.error('usage: hyper-proto-peer resync-did <did> [--storage <path>]'); console.error('flagged:', JSON.stringify(readResyncIndex(storage))); globalThis.Bare.exit(1) }
const { gen, sidePath } = resyncDid(storage, did)
console.log(JSON.stringify({ msg: 'resync-did: DID flagged for fresh-corestore re-join on next boot', did, gen, sidePath, storage }))
console.log('RESTART the daemon to apply: pgh app restart hyper-proto-peer')
globalThis.Bare.exit(0)
}
}
if (cfg.swarm) {
// Multi-device swarm mode (atproto-base): N attested devices of ONE DID share the repo via a
// StateBaseV3 intent-log; the lease-holder materializes the canonical signed repo. See
// lib/device-node.js. Needs rootMnemonic (identity/shared-key root) + deviceMnemonic.
const { bootSwarmDevice, bootMultiDevice, readAdoptRepo } = await import('./lib/device-node.js')
// adopt-into-swarm: if adoptCar is set, continue-migrate that existing repo (the origin imports it
// so the swarm continues the chain instead of re-authoring). See atproto-base adopt path.
let adopt = null
if (cfg.adoptCar && existsSync(cfg.adoptCar)) {
adopt = await readAdoptRepo(readFileSync(cfg.adoptCar))
console.log(JSON.stringify({ msg: 'adopt repo loaded', car: cfg.adoptCar, records: adopt.records.length, rev: adopt.head.rev }))
}
if (cfg.dids && cfg.dids.length) {
// MULTI-DID (multi-tenant) daemon: ONE process serves N self-custody DIDs over ONE shared
// Corestore/Hyperswarm/ProtomuxWakeup (each DID a namespace, one storage lock). A user's own DID
// + the communities they co-own live as entries in cfg.dids. See lib/device-node.js bootMultiDevice.
// OTA respawn, threaded EXPLICITLY into the boot rather than left on a module-global (see
// device-node.js). `daemon` is assigned on the next line and the handler cannot fire before a
// release-ready arrives, so the forward reference is safe.
let daemon = null
const onRelease = (ver) => {
console.log(JSON.stringify({ msg: 'ota: closing for respawn (exit 75)', version: ver || null }))
Promise.resolve(daemon ? daemon.close() : null).catch(() => {}).then(() => process.exit(75)).catch(() => process.exit(75))
}
// cfg.deviceMnemonic is the DEVICE-scoped key `coowner-join` mints at config root for entries that
// carry none of their own (see bootMultiDevice). Config.js has always read it; nothing forwarded it.
daemon = await bootMultiDevice({ storage: cfg.storage, dids: cfg.dids, deviceMnemonic: cfg.deviceMnemonic, onRelease })
console.log(JSON.stringify({ msg: 'multi-did daemon serving', dids: daemon.nodes.length, storage: cfg.storage }))
const shutdownM = async () => { try { await daemon.close() } catch {} ; process.exit(0) }
process.on('SIGINT', shutdownM)
process.on('SIGTERM', shutdownM)
// OTA: close cleanly, then exit 75 so the supervisor respawns onto the tree the sidecar installed.
// Registered HERE because this scope owns `daemon` — the UDS layer only signals.
try {
const { setOtaHandler } = await import('./lib/device-node.js')
setOtaHandler(() => {
console.log(JSON.stringify({ msg: 'ota: closing for respawn (exit 75)' }))
Promise.resolve(daemon.close()).catch(() => {}).then(() => process.exit(75)).catch(() => process.exit(75))
})
} catch (e) {
console.error(JSON.stringify({ msg: 'ota handler registration failed (non-fatal)', error: e.message }))
}
// Sidecar registration — THIS is the path the fleet runs (multi-DID). Without it the sidecar has no
// udsPath for hpp and cannot push `release-ready`, so autoupdate installs a tree the daemon never
// adopts. The primary DID's control.sock is the one to register: `pgh app status` and the OTA push
// both resolve through it. Best-effort; a management failure must not stop the daemon serving.
try {
const { maintainSidecarRegistration } = await import('./lib/sidecar.js')
const ctl = join(cfg.storage, 'control.sock')
maintainSidecarRegistration({ udsPath: ctl, name: process.env.PGH_APP_NAME || 'hyper-proto-peer', runtime: 'bare', version: null })
console.log(JSON.stringify({ msg: 'sidecar registration armed', uds: ctl }))
} catch (e) {
console.error(JSON.stringify({ msg: 'sidecar registration unavailable (non-fatal)', error: e.message }))
}
} else {
const node = await bootSwarmDevice({
storage: cfg.storage,
deviceMnemonic: cfg.deviceMnemonic, // this device's OWN identity secret (NOT the root)
did: cfg.did, // provisioned bundle (from provisionDevice on the root; never the root mnemonic)
signingKey: cfg.signingKey,
deviceProof: cfg.deviceProof,
rootIdentityKey: cfg.rootIdentityKey,
handle: cfg.handle,
pdsUrl: cfg.pdsUrl,
members: cfg.members, // device roster (co-equal multi-writer); null = self-only
writers: cfg.writers, // control-surface apply-write allowlist (null = read-only, secure default)
adopt, // { records, head } from adoptCar — origin continue-migrates the existing DID's repo
resetFirehose: cfg.resetFirehose, // one-time: clear a malformed __commit__ log + re-emit clean (marker-guarded)
appPasswordHash: cfg.appPasswordHash, // bsky-login: peer verifies the app-password (create-session)
jwtSecret: cfg.jwtSecret // bsky-login: peer mints/verifies session tokens
})
console.log(JSON.stringify({
msg: 'swarm-device serving',
mode: 'swarm',
did: node.did,
self: node.self.slice(0, 16),
handle: cfg.handle,
controlKey: node.control ? node.control.serverKey : null, // address this device's control RPC
writeEnabled: !!cfg.writers
}))
const shutdown = async () => { try { await node.close() } catch {} ; process.exit(0) }
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
// Register the control socket with pgh-sidecar. Without this the sidecar has no udsPath for us, so it
// cannot push `release-ready` at all — hpp was installed by autoupdate and then just kept running the
// old code until someone restarted it by hand. Registration also makes `pgh app status` resolve us.
// Best-effort and non-fatal: the daemon's job is serving the DID, not being manageable.
try {
const { maintainSidecarRegistration } = await import('./lib/sidecar.js')
const ctl = join(cfg.storage, 'control.sock')
maintainSidecarRegistration({ udsPath: ctl, name: process.env.PGH_APP_NAME || 'hyper-proto-peer', runtime: 'bare', version: null }) // the sidecar treats version as informational; reading package.json here is not worth a failure path
console.log(JSON.stringify({ msg: 'sidecar registration armed', uds: ctl }))
} catch (e) {
console.error(JSON.stringify({ msg: 'sidecar registration unavailable (non-fatal)', error: e.message }))
}
}
} else {
const vaultPath = join(cfg.storage, 'peer-identity.json')
let peer
let mode
if (existsSync(vaultPath)) {
mode = 'load'
peer = await Peer.load({ storage: cfg.storage })
} else if (cfg.adoptDid && cfg.adoptDidKey && cfg.adoptDevicePrivHex) {
mode = 'adopt'
peer = await Peer.adopt({
storage: cfg.storage,
did: cfg.adoptDid,
handle: cfg.handle,
privateKey: cfg.adoptDevicePrivHex,
didKey: cfg.adoptDidKey,
pdsUrl: cfg.pdsUrl,
appPassword: cfg.appPassword
})
if (cfg.adoptCar) {
const car = await readCar(new Uint8Array(readFileSync(cfg.adoptCar)))
const recs = await listRecords(car)
const parsed = recs.map((r) => {
if (r.collection && r.rkey != null) return { collection: r.collection, rkey: r.rkey, record: r.value }
const [collection, ...rest] = String(r.key).split('/')
return { collection, rkey: rest.join('/'), record: r.value }
})
await peer.importRecords(parsed)
console.log(JSON.stringify({ msg: 'imported repo', count: parsed.length, collections: [...new Set(parsed.map((p) => p.collection))] }))
}
} else if (cfg.rootMnemonic) {
mode = 'init'
peer = await Peer.init({
storage: cfg.storage,
rootMnemonic: cfg.rootMnemonic,
handle: cfg.handle,
pdsUrl: cfg.pdsUrl,
appPassword: cfg.appPassword
})
} else {
console.error('hyper-proto-peer: no vault and no onboarding config — set adoptDid/adoptDidKey/adoptDevicePrivHex (migrate) or rootMnemonic (fresh) in ' + cfg.configPath)
process.exit(1)
}
// Ensure at least one signed commit so the peer is frontable + verifiable on connect (a bridge
// authenticates by replaying our signed commit against the DID key). An adopted repo with
// imported records already has commits, so this only fires for an empty fresh init.
if (peer.seq.length === 0) {
await peer.write({
collection: 'app.bsky.feed.post',
rkey: 'hello',
record: { $type: 'app.bsky.feed.post', text: `hello from ${peer.handle}`, createdAt: new Date().toISOString() }
})
}
// Persistent swarm keypair — the peer's serverKey must be STABLE across restarts so the bridge
// reconnects promptly (a random keypair per boot makes every restart a new DHT identity). Lives
// beside the identity vault in cfg.storage (outside the wiped corestore dir).
const swarm = new Hyperswarm({ keyPair: loadOrCreateSwarmKeyPair(cfg.storage) })
// Write path (Phase 2): if a writers allowlist is configured, register the apply-write RPC
// gated to those client pubkeys — the peer still holds the key and signs; the caller only
// requests the write. Absent an allowlist, apply-write is not offered (secure default).
const writers = cfg.writers ? new Set(cfg.writers) : null
const serving = peer.serve(swarm, writers ? { canWrite: (hex) => writers.has(String(hex).toLowerCase()) } : {})
await serving.ready()
console.log(JSON.stringify({
msg: 'peer serving',
mode,
writeEnabled: !!writers,
did: peer.did,
handle: peer.handle,
didKey: peer.didKey,
serverKey: serving.serverKey,
discoveryTopic: serving.discoveryTopic,
repoKey: peer.repoKeyHex,
firehoseKey: peer.firehoseKeyHex,
firehoseLen: peer.seq.length
}))
const shutdown = async () => {
try { serving.close(); await swarm.destroy(); await peer.close() } catch {}
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
}