// atproto-base device boot — the ROOT provisions devices; a DEVICE boots from its bundle, never the
// root mnemonic (atproto-base-REVOCATION.md §4). provisionDevice (root-side) creates the swarm
// identity + attests a device; bootSwarmDevice (device-side) brings the device up from the received
// { did, signingKey, deviceProof, rootIdentityKey } + its own deviceMnemonic.
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import process from 'bare-process' // for the OTA respawn exit — Bare has no global process
// Set by the daemon entrypoint, which owns close() and therefore the exit. Kept module-level rather than
// threaded through bootMultiDevice -> bootSwarmDevice because the UDS surface is created deep inside the
// per-device boot and the only thing it needs is "tell whoever owns the process".
let otaHandler = null
export function setOtaHandler (fn) { otaHandler = typeof fn === 'function' ? fn : null }
import Hyperswarm from 'hyperswarm'
import ProtomuxWakeup from 'protomux-wakeup'
import Protomux from 'protomux'
import { CID } from 'multiformats/cid'
import { join } from 'bare-path'
import { createSwarmIdentity } from 'atproto-identity'
import { StateBaseV3 } from 'atproto-room/lib/state-base.js'
import { SwarmDevice } from './swarm-device.js'
import { nextRetry } from './reconcile-retry.js'
import { serveDevice } from './serve-device.js'
import { serveDeviceUds } from './serve-device-uds.js'
import { LabelRoom } from 'atproto-room/lib/label-room.js'
import { serveMembrane } from 'atproto-room/lib/serve-membrane.js'
import { BlobStore } from 'atproto-store/lib/blob-store.js'
import { OverlayBlobStore } from 'atproto-store/lib/overlay-blob-store.js'
import { BlobPointerStore } from 'atproto-store/lib/blob-pointer-store.js'
import { DualBlobStore } from './dual-blob-store.js'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
import { HyperOverlay } from 'hyper-overlay'
import { openCorestore } from 'atproto-store/lib/corestore.js'
// Every corestore is wrapped so a hypercore conflict is RECORDED, not just console.logged by upstream.
// See lib/conflict-watch.js — nothing in this stack listened for the `conflict` event before this.
import { watchStore } from './conflict-watch.js'
import { AccountRegistry } from 'atproto-store/lib/account-registry.js'
import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js'
import { normalizeMembers } from './config.js'
import { makeSessions } from './sessions.js'
import { readCar, loadCommit, listRecords } from 'atproto-repo/lib/repo.js'
// Read an existing repo CAR (e.g. wj62's, exported via the bridge) into the { records, head } shape
// bootSwarmDevice's `adopt` option consumes — so a config `adoptCar` path continue-migrates a live
// account into the swarm.
export async function readAdoptRepo (carBytes) {
const car = await readCar(carBytes)
const lc = loadCommit(car)
const sc = lc.value
const records = listRecords(car).map((r) => { const i = r.key.indexOf('/'); return { collection: r.key.slice(0, i), rkey: r.key.slice(i + 1), record: r.value } })
return { records, head: { rev: sc.rev, commitCid: lc.cid, dataCid: sc.data, signedCommit: sc } }
}
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
const NS = (s) => b4a.from('atproto-base/v1/' + s)
// provisionDevice — ROOT-side: create the swarm identity + attest ONE device, producing the bundle
// the device boots from. The root holds the mnemonic (recovery + signing keys); the device receives
// { did, signingKey, deviceProof, rootIdentityKey } — NEVER the root, so it can't derive the recovery
// key or self-recover. signingKey is the sensitive part and should be delivered encrypted (the
// broadcast-encryption slice); this just assembles the bundle. Also returns the signed genesis op
// (for PLC registration) + recoveryKey stays with the root (NOT in the bundle).
export async function provisionDevice ({ rootMnemonic, devicePublicKey, handle, pdsUrl } = {}) {
if (!rootMnemonic || !devicePublicKey) throw new Error('provisionDevice: rootMnemonic + devicePublicKey required')
const devPub = typeof devicePublicKey === 'string' ? b4a.from(devicePublicKey, 'hex') : devicePublicKey
const id = createSwarmIdentity(rootMnemonic, { handle, pdsUrl })
const deviceProof = await attestDeviceChain({ rootMnemonic, devicePublicKey: devPub })
const rootPub = await rootIdentityPublicKey(rootMnemonic)
return {
did: id.did,
signingKey: b4a.toString(id.signingKey.privateKey, 'hex'),
deviceProof: b4a.toString(b4a.from(deviceProof), 'hex'),
rootIdentityKey: b4a.toString(rootPub, 'hex'),
handle,
pdsUrl,
signedOp: id.signedOp // for PLC registration (root-side; NOT needed to boot a device)
}
}
// provisionAdoptedDevice — ROOT-side, ADOPT-INTO-SWARM: provision a device for an EXISTING DID with
// a GIVEN #atproto signing key (e.g. wj62's raw adopted key from its vault) + a SEPARATE keet
// membership root that governs device attestation. The atproto identity (did + signing key + its PLC
// registration) is UNCHANGED — no PLC op; the keet root is new and only decides which devices may
// sign. Returns the { did, signingKey, deviceProof, rootIdentityKey } bundle bootSwarmDevice consumes.
export async function provisionAdoptedDevice ({ did, signingKey, keetRootMnemonic, devicePublicKey } = {}) {
if (!did || !signingKey || !keetRootMnemonic || !devicePublicKey) throw new Error('provisionAdoptedDevice: did, signingKey, keetRootMnemonic, devicePublicKey required')
const devPub = typeof devicePublicKey === 'string' ? b4a.from(devicePublicKey, 'hex') : devicePublicKey
const deviceProof = await attestDeviceChain({ rootMnemonic: keetRootMnemonic, devicePublicKey: devPub })
const rootPub = await rootIdentityPublicKey(keetRootMnemonic)
return {
did,
signingKey: typeof signingKey === 'string' ? signingKey : b4a.toString(signingKey, 'hex'),
deviceProof: b4a.toString(b4a.from(deviceProof), 'hex'),
rootIdentityKey: b4a.toString(rootPub, 'hex')
}
}
// `joiner` — this device is JOINING an existing room, not bootstrapping one. It must never claim the
// deterministic genesis writer and must write NOTHING at boot; it converges by replication and the
// forward-only sign() picks up from the tip.
//
// This exists because the creator election is `lowest-keyed member == self` (state-base.js _amCreator),
// so admitting a device whose key sorts BELOW the existing creator's silently flips the role: the joiner
// elects itself creator, opens the roomSeed-derived gsKeyPair EMPTY-and-writable in its fresh store and
// authors a rival genesis, while the incumbent — no longer lowest — opens keyPair=null and ABANDONS the
// genesis writer it had been using. Both halves are wrong and neither errors. Observed in production
// admitting a second machine to loser-club: the joiner came up amCreator=true with its own 1-record head
// while the incumbent logged "stays a PLAIN WRITER". Membership is cryptographic and worked perfectly;
// the ROLE is what broke.
//
// So a room's creator is an explicit property, not an emergent consequence of key ordering: the joiner
// sets `joiner` and the incumbent sets `forceCreator`. Same shape as the resync path, which already had
// to solve exactly this for a creator rejoining from a fresh store.
export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, swarm = true, swarmOpts = null, signIntervalMs = 2000, members = null, control = null, writers = null, adopt = null, resetFirehose = false, appPasswordHash = null, jwtSecret = null, shared = null, records = null, federationHeartbeatMs = 0, primary = false, storeBackend = null, resyncStore = null, forceCreator = false, joiner = false, projectRepoCore = true, onRelease = null, labeler = false } = {}) {
if (typeof storage !== 'string') throw new Error('bootSwarmDevice: storage path required')
if (!deviceMnemonic) throw new Error("bootSwarmDevice: deviceMnemonic (this device's own identity) required")
if (!did || !signingKey || !deviceProof || !rootIdentityKey) throw new Error('bootSwarmDevice: provisioned bundle (did, signingKey, deviceProof, rootIdentityKey) required — run provisionDevice on the root')
// RECEIVED material — the shared signing key, the root identity pubkey, and this device's
// attestation. NONE of it is the root mnemonic (so this device cannot derive the recovery key).
const sharedKey = typeof signingKey === 'string' ? b4a.from(signingKey, 'hex') : signingKey
const rootPub = typeof rootIdentityKey === 'string' ? b4a.from(rootIdentityKey, 'hex') : rootIdentityKey
const proof = typeof deviceProof === 'string' ? b4a.from(deviceProof, 'hex') : deviceProof
// members-only room secrets — derivable only by devices that hold the DID + shared signing key
const roomKey = crypto.hash([NS('room-key'), b4a.from(did)])
const roomSeed = crypto.hash([NS('room-seed'), b4a.from(sharedKey)])
const encryptionKey = crypto.hash([NS('enc'), b4a.from(sharedKey)])
// this device's OWN keet identity + the root→device attestation it was provisioned with
const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
const self = b4a.toString(deviceKp.publicKey, 'hex')
// NORMALISE AT THE BOUNDARY, do not trust the caller to have done it. `members` reaches here in three
// shapes — [{key,role}], an array of hex strings, or a "hex,hex" comma-string — and projectMembers
// understands only the first (state-base.js reads `m.key`, so a plain string yields undefined and is
// skipped, producing an EMPTY map). loadConfig normalises, so every node booting through it is fine;
// a caller that parses config.json itself is not. favela-android did exactly that, and its roster
// silently evaporated: the room refused to project, logging "empty roster would wipe 3 existing
// member(s)" every 30s while its config plainly listed three. Third instance of this trap in this
// codebase (see coowner.js) — hence fixing it where the value is CONSUMED, not in each reader.
// Idempotent for already-normalised input, which is what every fleet node passes today.
const roster = normalizeMembers(members) || [{ key: self, role: 1 }]
// ── PRE-CUTOVER DID: keep the StateBase WHERE IT ALREADY IS ──────────────────────────────────────
// `primary` already means "this DID predates the multi-DID cutover on this box, keep its paths
// stable" — it preserves the flat control.sock, swarm-keypair and membrane. It did NOT cover the
// StateBase, which relocated from its own corestore at <storage>/statebase into a namespace of the
// shared store. That relocation is not cosmetic: StateBaseV3 derives its local writer from the
// corestore's primaryKey (own store) vs the shared store's primaryKey + roomKey namespace, so moving
// it gives the DID a DIFFERENT writer on an EMPTY base. The creator's genesis writer is deterministic,
// so it re-opens empty-and-writable and the boot-write block authors a fresh genesis — i.e. the DID's
// history is left behind in the old directory.
//
// wj62 survived that relocation only because it had a CO-OWNER to backfill from. A single-homed DID has
// no such peer, which is the entire reason ashatron could not move onto the unified path. Keeping the
// StateBase in place removes the need for a peer: the writer identity and history are untouched, and
// only the boot path changes. The daemon replicates this store explicitly (see _statebaseStore on the
// returned node), exactly as it already does for a resync side store. Nothing is moved, copied or
// deleted — the fix is to STOP relocating.
const legacyStatebasePath = join(storage, 'statebase')
const keepLocalStatebase = !!shared && !!primary && !resyncStore && fs.existsSync(legacyStatebasePath)
if (keepLocalStatebase) console.log(JSON.stringify({ msg: 'pre-cutover DID: keeping its StateBase in place (own corestore) instead of re-homing to the shared store', did, path: legacyStatebasePath }))
const sb = new StateBaseV3((keepLocalStatebase || !shared) ? legacyStatebasePath : null, roomKey, {
roomSeed,
encryptionKey,
// Multi-DID: open on the daemon's SHARED Corestore (a per-DID namespace) + SHARED wakeup, and
// create no swarm of our own — the daemon owns store+swarm+wakeup and drives replication
// (bootMultiDevice). Single-DID keeps its own per-room store + swarm exactly as before.
network: shared ? false : swarm,
// RESYNC (fresh-corestore re-join): a poisoned autobase local writer lives in the SHARED store,
// keyed by that store's primaryKey — a fresh namespace re-derives the SAME poisoned writer (pgv3 #4).
// resyncStore is a DEDICATED per-DID Corestore (its own primaryKey → a CLEAN local writer) that joins
// the SAME base (roomKey-derived key) as a fresh replica of the group's healthy state. Only the
// StateBase base re-homes here; the overlay/membrane/control keypair stay on shared.store (below), so
// the bridge-facing serverKey/coords are unchanged — the resync is transparent to the bridge. The old
// poisoned cores stay on disk in shared.store (nothing deleted). Read-only until re-admitted (the tick
// loop's ensureAdmitted rings the doorbell; a current indexer addWriters the fresh local key).
// keepLocalStatebase: pass NO store so StateBaseV3 opens its own Corestore at the legacy path,
// preserving the writer identity derived from that store's primaryKey.
store: resyncStore || ((shared && !keepLocalStatebase) ? shared.store : undefined),
// RESYNC heal: force this fresh-corestore re-join to open as a NON-creator (fresh local writer), so
// the deterministic genesis writer becomes a REMOTE writer that linearizes — instead of an
// un-sheddable local writer autobase won't linearize (diagnostic 1). Mirrors keet's fresh-writer rejoin.
// A JOINER takes the same posture as a resync: never elect as creator, so the roomSeed-derived
// gsKeyPair stays with the incumbent and becomes a REMOTE writer here that replicates + linearizes.
// Without this, a joiner whose key sorts below the incumbent's wins the election and authors a rival
// genesis (see the `joiner` note on bootSwarmDevice).
forceNonCreator: !!resyncStore || !!joiner,
// assume-genesis heal: a healthy co-owner re-assumes the deterministic genesis writer/indexer after
// the original creator abandoned it via a resync. Mutually exclusive with resync (resync wins).
forceCreator: forceCreator && !resyncStore,
wakeup: shared ? shared.wakeup : undefined,
swarmOpts,
selfKey: deviceKp.publicKey,
attest: makeAttest(deviceKp, proof),
verifyAttest: makeVerifyAttest(rootPub),
getMembers: async () => roster
})
await sb.ready()
// Project the roster into __members__ so admission is attestation-gated. The CREATOR (lowest-key
// member — StateBaseV3._amCreator) persists it; peers replicate it. ONE call: StateBaseV3 retains
// the roster and re-projects EVENT-DRIVEN + self-terminating (on its promote-timer tick + every new
// swarm connection) until __members__ lands in its view. This REPLACES the old bounded 12-try boot
// loop, which gave up before writability settled whenever the creator became writable AFTER the
// ~13s window (re-adopt + swarm flap) → __members__ never landed → admission verifiedRole=null →
// members held their own lines (the wj62 fork; hyper-social #9, pear-git #4 lineage).
//
// ⚠ Keeps the BOOT-HANG fix: NO `sb.get('__members__')` base.update() poll here (that could hang a
// freshly-admitted plain writer) — this call is non-blocking, boot proceeds, projection self-drives
// in the background. A non-creator's call no-ops (peers replicate __members__).
//
// RESYNC anti-fork (part 2): on a fresh-corestore re-join the CREATOR's projectMembers would WRITE
// __members__ into its EMPTY genesis core (base.local == base.key for the creator) BEFORE replication
// backfills it — the same fork the boot-write gate below prevents (diagnosed via localLen=2 on the
// supposedly converge-only side store, test/resync-creator-convergence.test.js). __members__ already
// exists in the network (the base was admitting writers before), so it REPLICATES in; the creator must
// NOT re-project it on a resync. Skip entirely — write nothing until the genesis core has backfilled.
if (!resyncStore) await sb.projectMembers(roster).catch(() => {})
// firehose:true — sign() persists each signed #commit into the shared __commit__ log, which the
// membrane surface projects + serves to the bridge (Phase D). Without it there is nothing to front.
// Optional file-based record store (records-as-files, O(current) — off the append-only StateBase).
// Flag-gated (storeBackend:'overlay'); reserved "__" keys stay on the StateBase. Records are LOCAL
// deduped files. hyper-overlay is pure content-addressed STORAGE/distribution here (dedup + serve
// bytes by hash); it does NOT converge trees. Cross-device convergence is the atproto firehose:
// the non-signer ingests the signer's firehose and writes record bytes to these files (task B).
let recordStore = null
let recordOverlay = null
let repoRoot = null
if (storeBackend === 'overlay') {
repoRoot = join(storage, 'atproto-repo', b4a.toString(roomKey, 'hex').slice(0, 16))
// HyperOverlay = content-addressed store for record bytes (+ blobs). Keyed by roomKey so N DIDs'
// overlays coexist on the daemon's ONE shared connection for content SERVING (per-DID channel id).
// Uses the shared corestore (namespaced) when multi-DID, else a dedicated one.
const ovStore = shared ? shared.store : watchStore(openCorestore(join(storage, 'atproto-repo-overlay')), 'atproto-repo-overlay')
recordOverlay = new HyperOverlay(ovStore, { namespace: 'atproto-repo-overlay:' + did, key: roomKey, destDir: join(repoRoot, '.fetch') })
await recordOverlay.ready()
recordStore = await new OverlayRepoStore(repoRoot, { overlay: recordOverlay }).ready()
console.log(JSON.stringify({ msg: 'record store: overlay (content-addressed files)', did, path: repoRoot }))
}
const device = new SwarmDevice(sb, { did, privateKey: sharedKey, self, firehose: true, recordStore })
// LABELER DID (`labeler: true` on its config entry; member labels, hyper-social #80). Moderators' label
// intents and the signed label log live in this DID's own StateBase, signed with the DID key — so the
// co-owners that sign its commits are exactly the ones that can sign its labels, with no second
// election: `holds` is the repo signer's own gate (device.isSigner). Intents sit under a RESERVED name
// because this base also holds the repo, and RepoBase reads any "/"-bearing non-"__" key as a record.
const labelRoom = labeler ? new LabelRoom(sb, { src: did, privateKey: sharedKey, holds: () => device.isSigner(), ns: '__labelintent__' }) : null
// NO unconditional boot claim: that let a booting device STEAL a still-valid lease before it had
// replicated the current holder's lease (the split-brain root cause). The sign loop claims lazily
// + safely via renewOrClaim (claim ONLY if the lease is free/TTL-lapsed), after replication has had
// ticks to converge. The creator's fresh-account bootstrap force-signs its initial commit below.
// adopt-into-swarm continuity: the writable (creator/origin) device imports the existing repo's
// records + head so the swarm CONTINUES the chain (next sign: prev = adopted commit, prevData =
// adopted data root) instead of re-authoring. Records replicate to peers via StateBaseV3. See
// atproto-pds-ref §2/§7. adopt = { records:[{collection,rkey,record}], head:{rev, commitCid,
// dataCid} } (CIDs may be strings — parsed here).
// RESYNC (creator-aware anti-fork gate) — CONVERGE-ONLY boot, write NOTHING. A fresh-corestore re-join
// is a REPLICA of an EXISTING base whose authoritative pointers + #commits already live in the network;
// this device's local record FILES may be STALE. The block below appends to base.local (migratePointers
// backfills file-pointers; restore/adopt/fresh seed head + commits). For the CREATOR that append is fatal:
// its genesis writer key is DETERMINISTIC (_gsKeyPair), so a fresh corestore re-opens the SAME writer
// EMPTY and immediately `writable` (NOT shed — test/resync-creator-fork.repro.mjs 4/4). Appending stale
// file-pointers into that empty genesis core BEFORE replication backfills it FORKS the writer (the
// df0c052a split-brain, docs/POSTMORTEM-2026-07-21-stortron-wj62-poison-heal.md). The heal: write nothing
// at boot → base.local backfills from the healthy peer (proven test/creator-writer-backfill.probe.mjs 7/7)
// → the base converges by replication → the tick loop's FORWARD-ONLY-gated sign() continues the ONE chain
// at the tip. The network is authoritative for a resynced replica; nothing is deleted (files stay on disk),
// and a converged migratePointers would be a no-op anyway (idempotent — pointers already replicated in).
// A JOINER boots converge-only for the same reason a resync does: the authoritative pointers and
// #commits already exist in the network, so anything written here before replication backfills is a
// fork. Critically this also suppresses the FRESH-ACCOUNT bootstrap below — on a joiner the log reads
// empty at boot, so that branch would author an initial commit and a rival head. That is exactly what
// happened admitting a second machine to loser-club: it came up holding one self-authored record.
if (resyncStore || joiner) {
console.log(JSON.stringify({ msg: (resyncStore ? 'resync' : 'joiner') + ': converge-only boot — skipping all local writes (migratePointers/restore/adopt/fresh-account) until replication converges; forward-only sign() continues the chain', did }))
} else if (sb.base.writable) {
const cid = (v) => (typeof v === 'string' ? CID.parse(v) : v)
// STEP 2a MIGRATION (files-only → pointers) — MUST run before any listRecords() below. A repo
// written by the previous files-only backend (OverlayRepoSubstrate) has its record BYTES on disk
// but NO pointers in the shared autobase; under PointerSubstrate listRecords() would read ZERO,
// so the adopt/restore/fresh branch below would mis-classify a populated repo as empty AND the
// first sign() would wipe it (the wj62 hazard). Backfill a {$oid,size} pointer per un-pointed
// record file first (idempotent, writable-only, never clobbers a converged pointer/tombstone).
// The sign() anti-wipe guard is the belt-and-suspenders backstop if this hasn't completed yet.
// STEP 2a-0 MIGRATION (INLINE records → pointers) — MUST run before migratePointers and before any
// listRecords() below, because it covers the one shape that would otherwise WIPE the repo.
//
// A DID written through the legacy substrate holds full RECORDS in the autobase where {$oid,size}
// pointers belong, and no record FILES at all. Under PointerSubstrate that reads as an EMPTY record
// set — _resolve passes ptr.$oid (undefined) to ensureLocal, which falls back to the local file, and
// with no file it returns null so the key is dropped. The anti-wipe guard in sign() does NOT catch
// this: it only aborts when record FILES exist, and here there are none. So the first sign() after
// flipping such a DID to storeBackend:'overlay' would materialize an empty MST root and publish a
// delete-every-record commit. That is e2jev's exact state (hyper-social#16 P1).
//
// migrateInline is the fix and its fromInline branch handles precisely this case — it writes the file
// FROM the inline copy, then publishes the pointer. Content is preserved rather than chosen: the
// pointer addresses the bytes that already resolve, asserted by hash, and it REFUSES any key whose
// oid would change. Writable-only; peers converge the pointers by replication.
//
// MARKER-GUARDED (__inline_migrated__) because unlike backfill this REWRITES autobase entries, which
// must not happen implicitly on every start. Idempotent regardless (a converted entry has $oid and is
// skipped), so the marker is belt-and-braces, not the only protection.
try {
const inlineDone = await sb.get('__inline_migrated__').catch(() => null)
if (!inlineDone) {
const mi = await device.migrateInlinePointers()
if (mi && !mi.skipped) {
await sb.set('__inline_migrated__', { ts: Date.now(), converted: mi.converted, fromInline: mi.fromInline, total: mi.total })
if (mi.converted || mi.mismatched || mi.failed) {
console.log(JSON.stringify({ msg: 'step-2a-0: inline records converted to fetchable pointers', did, ...mi }))
}
}
}
} catch (e) { console.error(JSON.stringify({ msg: 'step-2a-0: inline migration failed — NOT flipping this DID is safer than signing an empty set', did, error: e.message })) }
try {
const mig = await device.migratePointers()
if (mig && mig.written) console.log(JSON.stringify({ msg: 'step-2a: backfilled files-only records into pointers', did, ...mig }))
} catch (e) { console.error(JSON.stringify({ msg: 'step-2a: pointer backfill failed (anti-wipe guard still protects)', did, error: e.message })) }
// ONE-TIME firehose repair (resetFirehose flag): if the shared __commit__ log accumulated malformed
// frames (full-set re-authors from before the restore fix, which a relay rejects on prevData), clear
// it + re-emit a single clean continuation from the adoptCar baseline. Guarded by the durable
// __firehose_reset__ marker so it fires EXACTLY once across the swarm (the origin does it; peers see
// the marker + skip). NB: also move each device's membrane corestore aside before this boot so the
// derived firehose Hypercore is rebuilt from the now-clean log (operational step in the runbook).
const resetDone = resetFirehose ? await sb.get('__firehose_reset__').catch(() => null) : true
if (resetFirehose && !resetDone && adopt && adopt.head) {
const head = { rev: adopt.head.rev, commitCid: cid(adopt.head.commitCid), dataCid: cid(adopt.head.dataCid), signedCommit: adopt.head.signedCommit || null }
const h = await device.resetFirehose(adopt.records || [], head)
await sb.set('__firehose_reset__', { ts: Date.now(), rev: h ? h.rev : null })
console.log(JSON.stringify({ msg: 'firehose reset: cleared malformed frames + re-emitted clean continuation', did, from: adopt.head.rev, rev: h ? h.rev : null }))
} else {
const existing = await device.repo.listRecords()
if (existing.length === 0 && adopt && adopt.head) {
// FIRST cutover only (empty repo): import the existing DID's repo so the swarm CONTINUES its
// chain instead of re-authoring. On a RESTART the records are already in the shared state, so we
// must NOT re-adopt (that resets head to the ORIGINAL adopted rev → the next sign re-emits the
// posts) — we restore instead.
const head = { rev: adopt.head.rev, commitCid: cid(adopt.head.commitCid), dataCid: cid(adopt.head.dataCid), signedCommit: adopt.head.signedCommit || null }
try {
await device.adopt(adopt.records || [], head)
console.log(JSON.stringify({ msg: 'adopted repo into swarm', did, rev: head.rev, records: (adopt.records || []).length }))
// Emit ONE baseline #commit frame so the membrane projects immediately — without this an
// adopted repo with no subsequent write has an empty firehose log and the bridge can't front
// it (getRepo/getLatestCommit fail) until the first post. See device.emitBaseline().
const b = await device.emitBaseline()
if (b) console.log(JSON.stringify({ msg: 'baseline firehose frame emitted (adopted repo is membrane-frontable)', did, rev: b.rev }))
} catch (e) { console.log(JSON.stringify({ msg: 'adopt FAILED', error: e.message })) }
} else if (existing.length > 0) {
// RESTART with existing signed state: re-seed head + the diff baseline from the shared __commit__
// log so the firehose stays INCREMENTAL (no full-set re-author) and the chain continues.
const h = await device.restore()
console.log(JSON.stringify({ msg: 'restored signed head', did, rev: h ? h.rev : null, records: existing.length }))
} else {
// FRESH empty account (no adopt, no existing state): author + sign an INITIAL commit so the
// repo is non-empty and verifiable. A bridge authenticates a peer by replaying its signed
// commit against the DID key and refuses to front an empty repo (PeerFront.discover), so
// without this a brand-new swarm account is never frontable — and it can't post its way out
// (the composer posts THROUGH the bridge). Mirrors the single-tenant path (index.js:112). Only
// the writable creator authors it; peers receive the commit via __commit__ replication.
await device.putRecord('app.bsky.feed.post', 'self.hello', {
$type: 'app.bsky.feed.post',
text: `hello from ${handle || did}`,
createdAt: new Date().toISOString()
})
const h = await device.sign({ force: true }) // creator bootstrap: sole writable device, no other signer → force-sign is safe (boot claim removed)
console.log(JSON.stringify({ msg: 'authored initial signed commit (fresh swarm account)', did, rev: h ? h.rev : null }))
}
}
}
// Ensure per-DID bootstrap records (a community's app.bsky.feed.generator / labeler.service etc.),
// then sign — folds serve-loser-club.mjs's record-authoring into the supervised daemon. Writable
// devices only (creator/lease-holder; a plain writer re-authors after admission). Idempotent.
if (sb.base.writable && !resyncStore && Array.isArray(records) && records.length) {
let wrote = 0
for (const r of records) {
try { const ex = await device.repo.getRecord(r.collection, r.rkey); if (!ex) { await device.putRecord(r.collection, r.rkey, r.record); wrote++ } } catch (e) { console.error(JSON.stringify({ msg: 'bootstrap record failed', did, collection: r.collection, rkey: r.rkey, error: e.message })) }
}
if (wrote) { try { await device.sign({ force: true }) } catch {} ; console.log(JSON.stringify({ msg: 'bootstrap records authored', did, wrote })) }
}
// sign loop: while we hold the lease, renew + materialize the linearized view into the chain, then
// re-project the new commit + records into the membrane-served cores so the bridge sees it.
let membraneServing = null
// EVENT-DRIVEN sign/project — ZERO polling timer (was setInterval(signIntervalMs), the append-frequency
// anti-pattern). One sb.watch(tick) fires on every StateBase advance — a local write OR a replicated
// #commit (view apply) — AND on a signer-presence change (co-owner connect/disconnect →
// SignerPresence.onChange → sb._fireWatchers). So the lease-holder re-materializes and EVERY device's
// membrane re-projects exactly when there is something to do, never on an idle clock.
//
// The tick body is unchanged in effect but guarded + NON-BLOCKING (sign() reads the autoUpdate
// snapshot; returns null on an unchanged data root; membrane.sync is incremental from seq.length), so
// an idle event appends NOTHING. FAILOVER is opportunistic (the zero-timer design): a departed signer's
// grace expiry is noticed on the next write/replication/presence event — acceptable latency, no data
// loss (the write is durable in the log; it just materializes into a signed commit a beat later).
// ensureAdmitted (re-admission doorbell) runs when not writable; requestWriter re-rings internally.
// BYTE convergence for EVERY device, signer or not. sign() resolves record bytes as a side effect of
// listRecords({now:true}) — but it returns at the ELECTION GATE first, so a non-signing co-owner
// never pulled a single record byte and drifted toward "only what I authored" while its pointer set
// stayed correct (see SwarmDevice.reconcileBytes). Runs on its OWN coalescing guard and is never
// awaited by the tick, so a large backlog drains without delaying signing or stacking passes.
// Bounded passes, and it stops as soon as a pass makes no progress — the next event retries.
// Hoisted so the reconcile runner can reach the blob store: `blobs` below is block-scoped inside
// `if (controlEnabled)`, and reconcile() is defined before it. Null until the blob stores exist.
let blobStore = null
let reconciling = false
// ⛔ A FAILED FETCH IS NOT "NOTHING TO DO", AND THAT COST A POST. The pass loop below used to break on
// `!r.fetched`, which collapses two opposite outcomes: "everything resolved" and "I tried and could not
// reach the bytes". The comment said the next event would retry — and events are the ONLY driver here
// (zero polling, by design: sb.watch on a StateBase advance or a presence change, plus reconcile() on
// overlay attach when a peer connects). For the MOST RECENTLY converged pointer the advance that would
// retry it has already happened, so a single transient miss leaves that record unresolved until some
// unrelated write or a new peer connection shakes it loose.
//
// Measured on the handset 2026-08-18: a post converged at 21:26, its one fetch failed, and nothing
// retried for 13 minutes. At the next boot the same fetch failed once more and SUCCEEDED 3 seconds
// later — the miss was transient all along (the peer had not registered the oid yet: hyper-overlay's
// oid→disk map is in-memory and rebuilt per boot, atproto-store invariant #5). Downstream, favela-web's
// appview correctly refuses to author bytes hpp owns, so the record indexed but could not render: every
// layer behaving as designed, and the post invisible.
//
// So retry on FAILURE specifically, bounded and self-disarming:
// - one pending retry at a time, never a queue (a burst of events must not stack timers)
// - exponential backoff, because a peer that is genuinely absent must not be hammered
// - a clean pass disarms and resets the budget, so steady state is still zero timers
// - a real EVENT resets the budget too: the world changed, so the previous exhaustion is stale
// - unref'd, because a convergence retry must never be the reason a process stays alive
// The arithmetic and the budget live in lib/reconcile-retry.js (pure, tested in
// test/reconcile-retry.test.mjs). This owns only the timer — and unrefs it, because a convergence
// retry must never be the reason a process stays alive.
let retryTimer = null
let retryDelay = 0
let retryAttempts = 0
const reconcile = async ({ fromRetry = false } = {}) => {
if (reconciling) return
reconciling = true
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
let failedRecords = 0
let failedBlobs = 0
try {
for (let pass = 0; pass < 64; pass++) {
const r = await device.reconcileBytes()
if (!r || r.skipped) break
failedRecords = r.failed || 0
if (!r.more || !r.fetched) break // resolved everything reachable this pass
}
// BLOBS converge on the same events. reconcileBytes walks the RECORD pointer set and skips
// `__`-prefixed keys, so `__blob__/<cid>` could never be covered by it — without this, a blob's
// pointer replicates but its bytes only ever move when something happens to call get-blob.
if (blobStore && typeof blobStore.reconcileBlobs === 'function') {
for (let pass = 0; pass < 64; pass++) {
const b = await blobStore.reconcileBlobs()
if (!b || b.skipped) break
failedBlobs = b.failed || 0
if (!b.more || !b.fetched) break
}
}
} catch {} finally { reconciling = false }
const d = nextRetry({ failed: failedRecords, failedBlobs, fromRetry, attempts: retryAttempts, delay: retryDelay })
retryAttempts = d.attempts
retryDelay = d.delay
if (d.giveUp) {
console.log(JSON.stringify({ msg: 'reconcile: giving up until the next event', did: device.did, records: failedRecords, blobs: failedBlobs, attempts: d.attempts }))
return
}
if (!d.arm) return
console.log(JSON.stringify({ msg: 'reconcile: bytes unresolved — retry armed', did: device.did, records: failedRecords, blobs: failedBlobs, attempt: d.attempts, inMs: d.delay }))
retryTimer = setTimeout(() => { retryTimer = null; reconcile({ fromRetry: true }).catch(() => {}) }, d.delay)
if (retryTimer.unref) retryTimer.unref()
}
let ticking = false
let tickAgain = false
// ⛔ A SWALLOWED TICK IS INVISIBLE. 2026-10-14: loser-club's elected signer (stortron, holdsSigner true,
// running the fixed atproto-room) never re-signed its two ver-less member labels, and nothing anywhere said
// why — this catch ate whatever sign() or the label tick threw. Log it (once per distinct message, so an
// event storm cannot flood the log), and log when the label room signs, so the next restart is evidence.
let lastTickError = null
const tick = async () => {
if (ticking) { tickAgain = true; return } // coalesce bursts; re-run once if one arrived mid-flight
ticking = true
let step = 'admit'
try {
if (sb.base && !sb.base.writable) { try { await sb.ensureAdmitted() } catch {} }
reconcile() // deliberately NOT awaited — byte convergence must not gate signing (or vice versa)
step = 'sign'
await device.sign()
if (labelRoom) {
step = 'labels'
const signed = await labelRoom.tick()
if (signed > 0) console.log(JSON.stringify({ msg: 'labels: signed', did: device.did, count: signed }))
}
step = 'project'
if (membraneServing) { await membraneServing.sync(); await membraneServing.syncLabels() }
lastTickError = null
} catch (err) {
const m = step + ': ' + ((err && err.message) || String(err))
if (m !== lastTickError) console.error(JSON.stringify({ msg: 'tick failed', did: device.did, step, error: (err && err.message) || String(err) }))
lastTickError = m
} finally { ticking = false; if (tickAgain) { tickAgain = false; tick() } }
}
let unwatch = null
if (signIntervalMs > 0) { // >0 = enable the (now event-driven) sign loop; 0 = disabled (tests drive sign() manually)
unwatch = (typeof sb.watch === 'function') ? sb.watch(() => { tick() }) : null
tick() // initial pass at boot (materialize any pending intents + project the membrane)
}
// Federation heartbeat — folds serve-loser-club.mjs's keep-alive into the daemon. A non-archival
// relay only indexes LIVE #commits (atproto-bridge POSTMORTEM), so a low-activity account goes stale
// once it stops emitting. Periodically write a fresh marker (net.favela.federation/self, a non-bsky
// collection that never pollutes the public feed); the sign loop above materializes it into a #commit.
let hbTimer = null
if (federationHeartbeatMs > 0) {
let hn = 0
const heartbeat = async () => { try { hn++; await device.putRecord('net.favela.federation', 'self', { $type: 'net.favela.federation', seq: hn }) } catch {} }
await heartbeat() // emit one now
hbTimer = setInterval(heartbeat, federationHeartbeatMs)
if (hbTimer && typeof hbTimer.unref === 'function') hbTimer.unref()
}
// control surface: a SEPARATE Hyperswarm so a client/app can submit writes to + observe this
// device (StateBaseV3's own swarm only replicates the intent-log between devices). Reuses
// swarmOpts (testnet bootstrap in tests). writers = allowlist of client Noise pubkeys; null =
// read-only (secure default, matching lib/peer.js). Enabled by default when networked; pass
// control:false to suppress. Returns { serverKey, discoveryTopic }.
const controlEnabled = control == null ? swarm : control
let serving = null
let controlSwarm = null
let membraneStore = null
let udsServing = null
if (controlEnabled) {
// PERSISTENT control/serving keypair (same rationale as lib/swarm-keypair.js for the single-tenant
// peer): the control+membrane surface is what the keyless bridge fronts. A RANDOM keypair per boot
// (`new Hyperswarm()`) makes every restart a NEW serverKey — the bridge's old connection dies and
// Hyperswarm can't retry the same DHT identity, so fronts go stale until a slow topic re-query. A
// STABLE serverKey (persisted in <storage>/swarm-keypair.json) lets the bridge reconnect promptly on
// restart → the swarm is restart-safe with no bridge bounce. repoKey/firehoseKey are already stable
// (DID-named cores on a persisted corestore), so this closes the last rotating coordinate.
// Per-DID sub-paths when hosted by the multi-DID daemon (bootMultiDevice): the control keypair
// (this DID's bridge-facing serverKey) + the local UDS are per-DID so N DIDs don't collide; the
// membrane cores share the daemon's ONE Corestore (a per-DID namespace). EXCEPT the `primary` DID
// (the machine's own user DID, e.g. wj62): it keeps the FLAT control.sock + swarm-keypair + membrane
// so its bridge-facing serverKey/coords and its co-located client's UDS stay STABLE across the
// single-DID→multi-DID cutover (only its StateBase re-homes into the shared store). So a migrated
// primary DID's clients (favela-web Writer) and the bridge see no change.
const perDid = shared && !primary
const ctlDir = perDid ? join(storage, 'dids', b4a.toString(roomKey, 'hex').slice(0, 16)) : storage
if (perDid) fs.mkdirSync(ctlDir, { recursive: true })
controlSwarm = new Hyperswarm({ ...(swarmOpts || {}), keyPair: loadOrCreateSwarmKeyPair(ctlDir) })
// membrane surface (Phase D): project __commit__ + records into cores + serve get-coords on the
// SAME peer-facing swarm, so the keyless membrane (atproto-bridge) fronts this device by DID.
membraneStore = perDid ? shared.store.namespace('membrane:' + did) : watchStore(openCorestore(join(storage, 'membrane')), 'membrane')
// Blobs (avatars/images), SHARED by both surfaces — blobs live in the PEER; the keyless bridge only
// relays. get-blob is public (bsky reads avatars); upload-blob is authed. On an overlay-backed DID, write
// NEW blobs to a content-addressed OverlayBlobStore (files under the shared repoRoot/blobs — ONE copy,
// shared with a co-located consumer like favela-web) and DUAL-READ: fall back to the legacy Hyperbee
// BlobStore for pre-migration blobs so existing avatars don't 404 (no bulk rewrite; old blobs age out).
// Legacy-only for non-overlay DIDs. Same drop-in put/get/has API either way — serveDevice/Membrane unchanged.
const legacyBlobs = await new BlobStore(membraneStore, { name: 'membrane-blobs:' + did }).ready()
let blobs = legacyBlobs
if (storeBackend === 'overlay' && repoRoot && recordOverlay) {
const overlayBlobs = await new OverlayBlobStore(join(repoRoot, 'blobs'), { overlay: recordOverlay, indexStore: membraneStore, name: 'overlay-blobs:' + did }).ready()
// The overlay bee ({mimeType,size,oid}) is NODE-LOCAL — it lives on membraneStore and never
// replicates — and OverlayBlobStore.get() reads it FIRST, returning null before it looks at the
// file or the overlay. So without this wrapper an upload here leaves the FILE fetchable by oid
// from anywhere while the cid->oid mapping stays on this node forever, and a co-owner cannot
// derive it (the atproto blob CID and the overlay oid are different hashes). The blob then
// serves or 404s depending on which co-owner the bridge fronts. BlobPointerStore publishes
// `__blob__/<cid>` = {$oid,size,mimeType} into the SHARED state — the same pointers-in-the-
// autobase/bytes-in-the-overlay design PointerSubstrate already uses for records. Reserved "__"
// key: splitKey rejects it, so it can never reach a signed commit.
blobs = new DualBlobStore(new BlobPointerStore(sb, overlayBlobs, { did }), legacyBlobs)
console.log(JSON.stringify({ msg: 'blob store: overlay content-addressed files + shared pointers + legacy dual-read', did, path: join(repoRoot, 'blobs') }))
}
// ⛔ ALWAYS, NOT ONLY ON THE OVERLAY PATH. This assignment lived inside the `if` above, so a DID
// with no `storeBackend: 'overlay'` — which is every DID minted by favela-host, since it sets none
// — returned `blobs: null` from bootMultiDevice while `legacyBlobs` was right here, working, and
// already handed to serveDevice and serveMembrane. The two stores share a drop-in put/get/has API
// by design, so there was never a reason for the handle to report one and not the other.
//
// ⚠ IT ONLY BITES AN IN-PROCESS CONSUMER, which is why it survived: every socket surface takes
// `blobs` directly from this scope. The returned field exists precisely FOR the in-process case
// (see the note where this node is returned), and that is the one case it got wrong.
//
// Measured: a handset published 11 posts to its atproto repo and NONE of them carried an image.
// The same posts reached the fediverse with their jpegs attached, because that projection never
// asks this object for a blob store. upload-blob threw 'this node has no blob store', BskySink
// caught it and returned null, and each post went as text with a loss line nobody reads.
blobStore = blobs
// LOCAL UDS control surface — bring it up EARLY (before serveDevice/serveMembrane, whose sync/ready can
// stall on an un-converged base's base.update), so a co-located client (favela-web) can ALWAYS reach the
// device over local IPC even while convergence lags. Owner-gated by the filesystem → no writer-allowlist.
try {
// OTA respawn. This used to call `this.close()` — but this is inside bootSwarmDevice, a plain
// FUNCTION, so in an ES module `this` is undefined and it threw a TypeError that serve-device-uds's
// try/catch swallowed: the handler logged "respawning" and then silently did nothing, which is
// exactly how it shipped in 0.3.100-0.3.103. The process lifecycle belongs to index.js (it owns
// `daemon` and its close), so it registers the handler and this only signals.
udsServing = await serveDeviceUds(device, {
uds: join(ctlDir, 'control.sock'),
blobs,
labels: labelRoom,
// EXPLICIT handler first. The module-global `otaHandler` below is a fallback kept for callers
// that still use setOtaHandler(), but it is NOT the primary path any more: a module-level
// variable is only shared if index.js and this file resolve to the SAME module instance, and
// under pgh's content-addressed node_modules a specifier can resolve to two realpaths — Bare
// caches by realpath, so each copy gets its OWN `otaHandler`, one set and the other read. That
// is consistent with what shipped: 0.3.105 logged "release-ready", called through, and found
// null. Threading the function through the call cannot be defeated that way.
onRelease: (ver) => {
if (typeof onRelease === 'function') return onRelease(ver)
if (otaHandler) return otaHandler(ver)
console.error(JSON.stringify({ msg: 'ota: release-ready arrived but NO respawn handler is registered — this daemon will not self-update' }))
}
})
console.log(JSON.stringify({ msg: 'device UDS control surface', uds: join(ctlDir, 'control.sock') }))
} catch (e) { console.error(JSON.stringify({ msg: 'UDS control surface failed (swarm surface still up)', error: e.message })) }
const allow = Array.isArray(writers) ? writers.map((w) => String(w).toLowerCase()).filter(Boolean) : null
const canWrite = allow && allow.length ? (hex) => allow.includes(String(hex).toLowerCase()) : null
// bsky-login: the peer is the auth authority — build the session minter/verifier from the carried
// appPasswordHash + jwtSecret so the membrane serve can answer the bridge's create/refresh-session.
// Constructed BEFORE serveDevice because the device surface's get-service-auth gates on
// sessions.verifyAccess — the bridge forwards the end user's bearer for exactly that. Pure factory,
// no I/O, so the ordering is free.
const sessions = makeSessions({ did, handle, appPasswordHash, jwtSecret })
serving = serveDevice(device, controlSwarm, { canWrite, blobs, sessions })
// ⛔ TIME-BOXED, because `ready()` on BOTH surfaces is `Promise.all([topic.flushed(), …])` — the DHT
// ANNOUNCE completing, which is not the same thing as the surface working. `swarm.join()` has already
// registered the topics and connections are served meanwhile: on the phone five `device control
// connection`s were logged while boot was still sitting inside this await. On a workstation the flush
// returns in milliseconds; behind carrier-grade NAT it can take minutes or never resolve, and awaiting
// it wedged bootSwarmDevice → bootMultiDevice never returned → favela-android never sent `ready` and
// the app sat on its splash screen with a node that was, in fact, up (hyper-social #40).
//
// So boot proceeds and the announce finishes in the background. Nothing is skipped and nothing is
// swallowed silently: a timeout says so, naming the surface. The membrane projection is time-boxed on
// the same reasoning — the event-driven tick re-syncs on the next StateBase advance anyway, so a slow
// first pass costs a beat of freshness, never correctness.
const awaitOrContinue = async (what, promise, ms = 15000) => {
let timer = null
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('__timeout__'), ms); if (timer && timer.unref) timer.unref() })
const outcome = await Promise.race([promise.then(() => '__ok__', (e) => e), timeout])
clearTimeout(timer)
if (outcome === '__timeout__') console.error(JSON.stringify({ msg: 'boot continued WITHOUT waiting for ' + what + ' — it is still in progress in the background; the surface already accepts connections', did, waitedMs: ms }))
else if (outcome !== '__ok__') console.error(JSON.stringify({ msg: what + ' failed (boot continues)', did, error: (outcome && outcome.message) || String(outcome) }))
return outcome === '__ok__'
}
await awaitOrContinue('the control-surface DHT announce', serving.ready())
// projectRepoCore comes straight off the DID's config entry (bootMultiDevice spreads `...entry`),
// exactly like storeBackend. FALSE = the stateless virtual device: no membrane-repo Hyperbee is
// opened, record bytes exist only as the content-addressed files, and a consumer derives the repo
// via the get-repo-car RPC (proven byte-identical in test/derive-vs-project). Defaults TRUE so
// nothing changes until a DID is explicitly flipped — rolled out one DID and one node at a time.
// Flipping back is instant: the existing core on disk is untouched, just not opened.
membraneServing = await serveMembrane(device, { corestore: membraneStore, swarm: controlSwarm, did, handle, sessions, blobs, projectRepoCore, labels: labelRoom })
await awaitOrContinue('the membrane DHT announce', membraneServing.ready())
// sync() reads the device's __commit__ log through the StateBase. On a device that is not yet admitted
// (roster projected by another machine — #40 §6) that base may never advance, so this too is bounded.
await awaitOrContinue('the initial membrane projection', membraneServing.sync(), 30000)
// ⛔ AND THE LABELS. The boot tick above ran before the membrane existed, so it signed but could not project,
// and a quiet DID has no later event to tick on. Measured 2026-10-14: loser-club's re-signed (ver:1) member
// labels sat in both co-owners' signed logs for hours (label-list projection:null) while the gateway served
// the old ones and bsky showed no badge. Sign whatever is pending (election-gated) and project, bounded.
if (labelRoom) {
await awaitOrContinue('the initial label projection', (async () => {
await labelRoom.tick()
await membraneServing.syncLabels()
})(), 30000)
}
}
const close = async () => {
if (unwatch) { try { unwatch() } catch {} } // stop the event-driven sign/project subscription
if (hbTimer) clearInterval(hbTimer)
if (serving) { try { serving.close() } catch {} }
if (membraneServing) { try { membraneServing.close() } catch {} }
if (udsServing) { try { await udsServing.close() } catch {} }
if (controlSwarm) { try { await controlSwarm.destroy() } catch {} }
if (membraneStore) { try { await membraneStore.close() } catch {} }
if (recordStore) { try { await recordStore.close() } catch {} }
if (recordOverlay) { try { await recordOverlay.close() } catch {} }
try { await sb.close() } catch {}
}
// `reconcile` is exposed so the daemon can also trigger byte convergence on OVERLAY PEER ATTACH —
// the moment bytes become reachable is not a StateBase event, so sb.watch alone would leave a
// just-connected peer sitting on fetchable bytes until something else happened to fire.
// _statebaseStore: a pre-cutover DID's OWN corestore, kept in place. It sits OUTSIDE the daemon's
// shared store.replicate(conn), so — exactly like a resync side store — the daemon must replicate it
// explicitly or the DID would never sync with anyone. Reported separately from _sideStore because
// StateBaseV3 owns this one and closes it itself (sb.close); double-closing a resync store is what
// _sideStore's own close path is for.
// `blobs` is exposed because an IN-PROCESS consumer needs it: favela-android runs this daemon inside
// the app, so its UI reaches blobs through the object rather than the `upload-blob`/`get-blob` RPCs the
// co-located (favela-web over UDS) and remote (bridge over swarm) consumers use. Same store, no surface.
// `uds` is the LOCAL control socket path (or null if that surface failed to come up). Exposed because
// the co-located consumer needs to NAME it: favela-web reaches this device over that socket, and the
// only alternative is for the caller to re-derive `<storage>/dids/<roomId>/control.sock` from hpp's
// internal directory layout — a private path rebuilt by hand in another repo, which breaks the day this
// moves and surfaces as "writes are disabled" nowhere near the cause. hpp knows the path; hpp says it.
return { device, sb, did, self, blobs: blobStore, control: serving, membrane: membraneServing, controlSwarm, overlay: recordOverlay, repoRoot, reconcile, close, uds: udsServing ? udsServing.uds : null, _sideStore: resyncStore, _statebaseStore: keepLocalStatebase ? sb.store : null }
}
// ── RESYNC (fresh-corestore re-join) index ────────────────────────────────────────────────────────
// A per-DID directive persisted at <storage>/statebase-resync/index.json ({ "<did>": gen }). When a DID
// carries a gen, bootMultiDevice opens its StateBase from a FRESH Corestore at
// <storage>/statebase-resync/<did16>-r<gen> (its own primaryKey → a CLEAN local writer) instead of the
// poisoned shared-store base — the durable, restart-surviving heal for a poisoned autobase local writer
// (the SIGABRT in ApplyState._appendLocalNodes). Generic: any hosted DID, keyed by DID (mirrors pgv3's
// per-repo forceResync). resyncDid() BUMPS the gen; the daemon reopens on next boot — a poisoned base
// can't be live-closed (it aborts on apply), so boot-reopen (never opening the poisoned base) is the
// correct shape. Nothing is deleted: the old poisoned cores stay on disk in the shared store.
//
// ⛔ DO NOT "SIMPLIFY" THIS INTO autobase's setLocal(key,{keyPair}) WRITER ROTATION. That undocumented
// API (autobase/index.js:489) looks like it should replace the whole second-Corestore apparatus below —
// rotate onto a fresh keypair in place, drop the side store, drop the _sideStore.replicate ordering in
// bootMultiDevice. Characterized against 7.28.1 (2026-08-12) and it does NOT work here, for three
// independent reasons:
// 1. NOT A CLEAN SLATE. _rotateLocalWriter calls LocalState.moveTo(old,new), which copies the
// linearizer updates AND the autobase/boot record onto the new writer, and the base stays in the
// same store with its stale view cores. The fresh primaryKey is the whole point of a side store.
// 2. IT ASSERTS ON AN INDEXER. _rotateLocalWriter nulls localWriter before _updateLinearizer calls
// _clearLocalIndexer, which asserts localWriter !== null — a bare AssertionError with an EMPTY
// message, leaving a half-rotated base. A poisoned node is usually the creator, i.e. the sole
// indexer. Even with that fixed, rotating the sole indexer leaves the room with ZERO indexers.
// 3. IT DE-ADMITS ANYWAY. The rotated key is not in the writer set, so an indexer still has to
// addWriter it — the same online-indexer dependency the fresh-corestore re-join already has.
// So the second Corestore + the side-store replication ordering are load-bearing, not incidental.
// Evidence + probes: memory/reference_autobase-setlocal-writer-rotation, pear-git#35.
const RESYNC_DIR = 'statebase-resync'
const did16For = (did) => b4a.toString(crypto.hash([NS('room-key'), b4a.from(did)]), 'hex').slice(0, 16)
// Attaching a DID's overlay content protocol to a connection was wrapped in a bare `try {} catch {}` at
// BOTH attach sites. If it ever throws, that DID silently loses byte transfer on that connection — the
// pointers still converge via the autobase, so the repo looks fine while the BYTES never move, and
// `fetchFile` later reports only a generic null. Byte resolution gates signing, so this is a silent
// failure on a load-bearing path (hyper-social #13, lesson 3). Deduped per did+reason so a flapping
// swarm cannot spam the log.
// Record the OUTCOME of an attach, not just its exceptions. attachProtocol returns silently on every
// internal guard, so "no exception" never meant "attached" — that misreading cost a day. Logged once
// per did+peerCount, so it is quiet in steady state but re-reports the moment connectivity changes.
const _attachSeen = new Set()
function logOverlayAttachResult (did, overlay) {
let peers = null
try { peers = overlay.peerCount } catch {}
const k = did + ':' + peers
if (_attachSeen.has(k)) return
_attachSeen.add(k)
try {
console.log(JSON.stringify({
msg: peers > 0
? 'overlay attached — byte transfer is live for this DID on this connection'
: 'overlay attachProtocol returned but peerCount is 0 — NO byte transfer on this connection',
did, overlayPeers: peers
}))
} catch {}
}
const _attachFails = new Set()
function logOverlayAttachFail (did, err) {
const reason = (err && err.message) || String(err)
const k = did + ':' + reason
if (_attachFails.has(k)) return
_attachFails.add(k)
try { console.error(JSON.stringify({ msg: 'overlay attachProtocol FAILED — this DID will not move record BYTES on this connection (pointers still converge, so the repo will look healthy)', did, error: reason })) } catch {}
}
const resyncSidePath = (storage, did, gen) => join(storage, RESYNC_DIR, did16For(did) + '-r' + gen)
export function readResyncIndex (storage) {
try { return JSON.parse(fs.readFileSync(join(storage, RESYNC_DIR, 'index.json'), 'utf8')) } catch { return {} }
}
// Mark a DID for a fresh-corestore re-join on the next daemon boot (bumps its gen). Returns the new gen
// + the side-store path. The daemon must be restarted to apply (the poisoned base is not live-closable).
export function resyncDid (storage, did) {
if (!storage || !did) throw new Error('resyncDid: storage + did required')
const idx = readResyncIndex(storage)
const gen = (idx[did] || 0) + 1
idx[did] = gen
fs.mkdirSync(join(storage, RESYNC_DIR), { recursive: true })
fs.writeFileSync(join(storage, RESYNC_DIR, 'index.json'), JSON.stringify(idx, null, 2) + '\n')
return { gen, sidePath: resyncSidePath(storage, did, gen) }
}
// bootMultiDevice — the MULTI-DID (multi-tenant) hpp daemon. Serves N self-custody DIDs from ONE
// process over ONE shared Corestore (one dir, one RocksDB lock — each DID is a namespace), ONE
// Hyperswarm + ONE ProtomuxWakeup for StateBase replication (the keet multi-room pattern), plus a
// per-DID control/membrane surface (each DID's own bridge-facing serverKey + local UDS). This is a
// user's own DID + the communities they co-own, all in one supervised daemon — retiring the bespoke
// per-community scripts. Foundation proven in atproto-room/test/multi-did.test.js.
// dids = [{ deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, members,
// writers, adopt?, resetFirehose?, appPasswordHash?, jwtSecret? }].
//
// `deviceMnemonic` (the top-level one) is the DEVICE-SCOPED fallback — this machine's own keet identity,
// used by any entry that does not carry its own. A device key is derived from a mnemonic and NOTHING
// else (`deviceKeetKeyPair` takes no DID), so `coownerJoin` mints it at config ROOT and writes a `dids`
// entry WITHOUT one — that is what lets a phone mint its key before it knows which DID it is joining.
// Boot must read it the same way or a genuinely-joined DID dies on "deviceMnemonic required" and the
// daemon serves ZERO DIDs while its config, roster and attestation are all perfect (hyper-social #40 §3).
// Precedence is coownerJoin's, exactly: per-DID first (it may already be attested, and replacing it
// orphans the attestation), else device-scoped. Proof: test/coowner-join-boots.test.js.
export async function bootMultiDevice ({ storage, dids = [], deviceMnemonic = null, swarmOpts = null, signIntervalMs = 2000, onRelease = null } = {}) {
if (typeof storage !== 'string') throw new Error('bootMultiDevice: storage path required')
if (!Array.isArray(dids) || dids.length === 0) throw new Error('bootMultiDevice: at least one DID required')
// ONE shared substrate for every DID's StateBase (+ per-DID membrane namespaces of the same store).
const store = watchStore(openCorestore(join(storage, 'store')), 'store')
await store.ready()
// RESYNC directives: DIDs whose StateBase must open from a fresh side corestore (healed poisoned base).
const resyncIdx = readResyncIndex(storage)
const wakeup = new ProtomuxWakeup()
const swarm = new Hyperswarm({ ...(swarmOpts || {}), keyPair: loadOrCreateSwarmKeyPair(join(storage, 'statebase-swarm')) })
// `nodes` is declared BEFORE the connection handler so the handler's closure can attach each hosted
// DID's signer-presence on every connection (S1 multi-DID). It is only ever pushed to (never reassigned),
// so the closure always sees the current roster.
const nodes = []
// keet muxing: ONE store.replicate + ONE wakeup.addStream per connection carries EVERY DID's cores
// AND every DID's optimistic/pending-writer announcements — so admission works for all DIDs over one
// connection (proven: atproto-room/test/multi-did.test.js). No per-base replicate/wakeup.
swarm.on('connection', (conn) => {
conn.on('error', () => {})
try { wakeup.addStream(conn) } catch {}
// SIDE STORES REPLICATE FIRST — before the shared store below. Corestore keys hypercore channels by
// DISCOVERY KEY on a Protomux that is SHARED per stream, so when two corestores on one connection both
// hold the same core, whichever replicates FIRST claims (hypercore/alpha, discoveryKey) and the other's
// attach is a duplicate that Protomux answers with null. The loser then syncs with NOBODY while looking
// perfectly healthy locally: length 0, no error, no conflict — the hardest possible shape to diagnose.
// Reproduced both ways in test/side-store-channel-priority.test.js.
//
// For a DID whose base lives in a side store, that side store is the AUTHORITATIVE copy and anything
// left in the shared store is the abandoned pre-resync one, so the side store must win. Ordering is the
// whole fix. (A stale core merely sitting ON DISK in the shared store does not collide — corestore does
// not auto-open cores on remote interest — so this bites only when something opens it in-process too.)
for (const n of nodes) { if (n._sideStore) { try { n._sideStore.replicate(conn) } catch {} } }
// A PRE-CUTOVER DID's StateBase lives in its OWN corestore (kept in place rather than re-homed), so
// like a resync side store it is outside store.replicate below and must be replicated explicitly —
// otherwise that DID silently syncs with nobody while looking perfectly healthy locally.
for (const n of nodes) { if (n._statebaseStore) { try { n._statebaseStore.replicate(conn) } catch {} } }
try { store.replicate(conn) } catch {}
// S1 multi-DID signer election: the StateBases run network:false (no swarm of their own) so their
// internal per-connection presence handshake never fires here — the daemon must drive it. Attach EACH
// hosted DID's ephemeral presence over this one shared connection; per-DID channel ids (roomKey) keep
// them from colliding (attachSignerPresence passes id=this._roomKey). Without this, every co-owner of a
// shared DID sees only itself present → holdsSigner()==true for all → they all sign → FORK. Double-attach
// (a node booting while this connection exists) is a safe no-op (protomux returns null on the duplicate).
for (const n of nodes) { try { n.sb.attachSignerPresence(conn) } catch {} }
// Record CONVERGENCE is the StateBase autobase: a tiny {$oid,size} POINTER per record replicates
// symmetrically like any control key (PointerSubstrate). hyper-overlay is the DISTRIBUTION layer —
// it serves the record BYTES by content-hash (oid). Attach each overlay-backed DID's content
// protocol here so a follower resolving the converged pointer set (store.ensureLocal) can pull the
// bytes from a peer by oid. Per-DID channel ids (roomKey) let N overlays coexist on this one mux.
const mux = Protomux.from(conn)
for (const n of nodes) { if (n.overlay) { try { n.overlay.attachProtocol(mux); logOverlayAttachResult(n.did, n.overlay); if (n.reconcile) n.reconcile() } catch (err) { logOverlayAttachFail(n.did, err) } } }
})
const shared = { store, swarm, wakeup }
// DID-manager (atproto account-manager analog): the durable roster of hosted DIDs — one Hyperbee on
// the shared store, so a restart re-opens exactly the DIDs this node hosts.
const registry = await new AccountRegistry(store, { name: 'hpp-dids' }).ready()
for (const d of dids) {
try {
// RESYNC: if this DID is flagged, open its StateBase from a FRESH per-DID Corestore (clean local
// writer) instead of the poisoned shared-store base. The daemon owns + closes this side store.
let resyncStore = null
if (resyncIdx[d.did]) {
resyncStore = watchStore(openCorestore(resyncSidePath(storage, d.did, resyncIdx[d.did])), 'statebase-resync')
await resyncStore.ready()
console.log(JSON.stringify({ msg: 'multi-did: RESYNC — opening DID from a fresh side corestore', did: d.did, gen: resyncIdx[d.did], sidePath: resyncSidePath(storage, d.did, resyncIdx[d.did]) }))
}
// Per-DID adoptCar (notary-revival rebuild): a rebuilt DID's config entry may carry adoptCar (the
// path to its authoritative CAR exported by `rebuild-did`) + forceCreator. Load it into `adopt`
// {records, head} so the healthy node AUTHORS the genesis from the snapshot. COEXISTENCE RULE
// (rebuild-2node-transition.test.js): the OLD base must be OFF the swarm first — a rebuild belongs
// in FRESH storage (or a daemon that no longer hosts the old copy), never alongside the old cores.
let entry = d
if (d.adoptCar && !d.adopt) {
try { entry = { ...d, adopt: await readAdoptRepo(fs.readFileSync(d.adoptCar)) }; console.log(JSON.stringify({ msg: 'multi-did: adoptCar loaded for rebuild', did: d.did, car: d.adoptCar, records: entry.adopt.records.length, rev: entry.adopt.head.rev })) } catch (e) { console.error(JSON.stringify({ msg: 'multi-did: adoptCar load FAILED', did: d.did, car: d.adoptCar, error: e.message })) }
}
// deviceMnemonic AFTER the spread, and explicit rather than relying on spread order: an entry may
// carry the key with an undefined value, which would silently win and re-throw the original error.
const node = await bootSwarmDevice({ storage, ...entry, deviceMnemonic: entry.deviceMnemonic || deviceMnemonic, swarm: true, swarmOpts, signIntervalMs, shared, resyncStore, onRelease })
swarm.join(node.sb.base.discoveryKey, { server: true, client: true }) // daemon joins each DID's StateBase topic
// resync side store: replicate on connections already open (future ones via the connection handler).
if (node._sideStore) for (const conn of swarm.connections) { try { node._sideStore.replicate(conn) } catch {} }
// Same catch-up for a kept-in-place StateBase on a DID that booted AFTER connections were open.
if (node._statebaseStore) for (const conn of swarm.connections) { try { node._statebaseStore.replicate(conn) } catch {} }
try { await registry.put(node.did, { did: node.did, handle: d.handle || null, active: true, addedAt: Date.now() }) } catch {}
nodes.push(node)
// A DID booting AFTER connections are already open: the connection handler ran before this node was in
// `nodes`, so attach it to every existing connection now (the reverse of the handler's forward-attach).
for (const conn of swarm.connections) { try { node.sb.attachSignerPresence(conn) } catch {} }
// A DID booting AFTER connections exist: attach its overlay content protocol to each open conn too.
if (node.overlay) for (const conn of swarm.connections) { try { node.overlay.attachProtocol(Protomux.from(conn)); logOverlayAttachResult(node.did, node.overlay); if (node.reconcile) node.reconcile() } catch (err) { logOverlayAttachFail(node.did, err) } }
console.log(JSON.stringify({ msg: 'multi-did: serving DID', did: node.did, handle: d.handle || null, controlKey: node.control ? node.control.serverKey : null }))
} catch (e) {
// WITH THE STACK. A per-DID boot failure is reported by MESSAGE alone on every node in the fleet,
// including a phone with no filesystem access and no debugger — so `Cannot read properties of
// undefined (reading 'length')` named a JS idiom and nothing else, and localizing it meant guessing
// at shapes and rebuilding an APK per guess. The message is the one thing that cannot identify the
// site; the stack is free and is the whole diagnosis.
console.error(JSON.stringify({ msg: 'multi-did: failed to boot DID', did: d.did, error: e.message, stack: e.stack }))
}
}
await swarm.flush().catch(() => {})
console.log(JSON.stringify({ msg: 'multi-did hpp serving', dids: nodes.length, storage }))
const byDid = new Map(nodes.map((n) => [n.did, n]))
const close = async () => {
for (const n of nodes) { try { await n.close() } catch {} }
for (const n of nodes) { if (n._sideStore) { try { await n._sideStore.close() } catch {} } } // daemon owns resync side stores
try { await swarm.destroy() } catch {}
try { await registry.close() } catch {}
try { await store.close() } catch {}
}
return { nodes, byDid, shared, registry, close }
}