// The atproto verbs — and the reason they live in a THIRD package rather than either of the two
// they sit between.
//
// ⛔ NEITHER SIDE MAY HOLD THESE. `favela-peer/test/boundary.test.js` forbids the daemon naming
// `atproto-` at all, from hyper-social #61 §10: "the moment the daemon imports the projection the
// dependency has been inverted and the recentring has failed". `favela-surface` is a renderer and
// its own boundary test forbids it reaching for storage, a swarm or a projection. So the composition
// belongs to whoever holds both halves — which is this package.
//
// ⚠ `favela-identity/spec/identity.md` §7.5 says the same thing from the other direction: "the peer
// performs the op and is the only thing that appends", and the surface "renders the form". This is
// the seam that lets both stay true while a DID can still be minted from a settings page.
// ⛔ FIRST, before anything that reaches @noble via the atproto stack. Those modules use TextEncoder
// at MODULE scope and Bare has no such global, so `hyper-proto-peer/lib/mint.js` fails to load with
// "TextEncoder is not defined" — measured, not assumed. The mint is imported lazily below, so this
// line runs first; a shim loaded after the fact is loaded too late. hpp's own bin/ files do the same.
import 'hyper-proto-peer/lib/bare-globals.js'
import fs from 'bare-fs'
import { join, dirname } from 'bare-path'
/**
* Where a node's atproto config lives.
*
* ⛔ UNDER THE NODE'S STORAGE, never `$HOME`. `mintIdentity` defaults to
* `os.homedir() + '/.config/hyper-proto-peer/config.json'` and `writeConfigFile` is a bare
* `writeFileSync` with no `mkdirSync` in front of it — so on a handset, which has no home directory,
* the mint derived every key, built the genesis, and threw ENOENT on its LAST line, losing the
* recovery root it had just generated with no way to ask for it again.
*
* Same class as the OTA homedir chain (hyper-social #69 §1), where the message named the path and
* not the caller and it cost four sideload cycles. favela-peer already made this fix on its own half
* and wrote down the rule: "THE VAULT LIVES UNDER THE NODE'S STORAGE, not ~/.hyperident. A phone has
* no home directory... the daemon already knows where its own state goes, so it says."
*/
//
// ⛔ UNLESS THE EMBEDDER NAMES hpp's OWN CONFIG (`node.atprotoConfigFile`). On a machine that already runs
// hyper-proto-peer the identities live in hpp's config and data, and a second copy under the node's
// storage would be a second writer for the same devices — a fork (hyper-social #77 §5, change 1).
export const atprotoConfigPath = (node) => node.atprotoConfigFile || join(node.storage, 'atproto', 'config.json')
/** Where a minted identity's sealed recovery root goes. */
export const recoveryVaultPath = (node, did) =>
join(node.storage, 'atproto', 'recovery', String(did).split(':').pop() + '.vault.json')
/**
* Where the genesis operation is kept between minting and submitting.
*
* ⛔ IT HAS TO BE KEPT SOMEWHERE. `submitMintedGenesis` requires `{ did, signedOp }` and throws
* without it, hpp's config entry does not record it, and `mintIdentity` returns it once. So a mint
* whose caller dropped it could never be published — which is exactly what happened: `did-submit`
* called `submitMintedGenesis({ did })` and could not have worked on any input.
*
* ⚠ NOT SECRET. This is the operation that gets posted to a public global directory; the secret that
* signed it is the root, which lives sealed in the vault. Keeping it in its own file rather than
* inside hpp's config keeps this package out of a shape it does not own.
*/
export const genesisPath = (node, did) =>
join(node.storage, 'atproto', 'genesis', String(did).split(':').pop() + '.json')
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
const writeJson = (p, v) => {
fs.mkdirSync(dirname(p), { recursive: true })
// Written via a temp file and renamed: a record half-written by a process that died at the wrong
// moment is indistinguishable from a corrupt one.
const tmp = p + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(v, null, 2) + '\n')
fs.renameSync(tmp, p)
}
/**
* Which identity an op acts on.
*
* ⛔ NOT `dids[0]`. On a server the desktop app boots hpp's own config, which holds several identities
* (wj62 and loser-club), and did-carry / did-adopt / did-submit are permanent appends to a global log —
* "whichever is first" is not a choice anyone made (hyper-social #77 §5, change 5). pickNode's rule
* (lib/project.js): an explicit `did`, then the designated credential (identity.md §5.1), then the only
* one. With several and none chosen a WRITE refuses; a read answers for the first and lists them all.
*
* Returns { cfg, entry, dids } — `cfg` because did-adopt writes the entry back into it.
*/
export function atprotoEntry (node, a = {}, { write = false, designated = null } = {}) {
const cfg = readJson(atprotoConfigPath(node))
const all = cfg && Array.isArray(cfg.dids) ? cfg.dids.filter((d) => d && d.did) : []
const dids = all.map((d) => d.did)
if (a && a.did) {
const entry = all.find((d) => d.did === a.did)
if (!entry) throw new Error(a.did + ' is not an identity this node holds')
return { cfg, entry, dids }
}
if (designated) {
const entry = all.find((d) => d.did === designated)
if (entry) return { cfg, entry, dids }
}
if (all.length > 1 && write) {
throw new Error('this node holds several identities (' + dids.join(', ') + ') and none is designated — say which one')
}
return { cfg, entry: all[0] || null, dids }
}
// The designated credential, if this node has one — reach's, the same one the projector honours.
async function designatedDid (node) {
try {
const { dispatch } = await import('favela-peer/lib/rpc.js')
const r = await dispatch(node, 'reach', {})
return (r && r.credential) || null
} catch { return null }
}
export const ATPROTO_OPS = {
/**
* Create a new atproto identity, or restore one from a recovery phrase.
*
* ⛔ MINTING IS NOT SUBMITTING. This never touches the network: submitting the genesis to the PLC
* directory is "a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log" and is a separate verb
* for that reason. A mint that cannot reach the directory still yields a working P2P identity.
*
* ⛔ AND IT IS P2P-ONLY: no handle, no PDS. hyper-proto-peer's mint records why — "creating an
* identity and joining someone's infrastructure are different decisions, and requiring both at
* once is what made minting undecidable". Both arrive later as a PLC update.
*/
'did-mint': async (node, a) => {
// ⛔ REFUSE BEFORE MINTING, NOT AFTER. The passphrase is the only thing protecting the recovery
// vault, and favela-android's mint records why: the sealed blob is designed to be replicated, so
// it will land on disks we do not control and is safe there only because it is useless without
// something in a person's head. Checking afterwards would leave an identity that exists and
// cannot be protected — and minting is impossible to undo.
const passphrase = typeof a.passphrase === 'string' ? a.passphrase : ''
if (passphrase.length < 8) {
throw new Error('did-mint needs a passphrase of at least 8 characters — it is the only thing protecting your recovery vault')
}
const { mintIdentity } = await import('hyper-proto-peer/lib/mint.js')
const configFile = atprotoConfigPath(node)
// ⚠ Created HERE. `writeConfigFile` opens the path directly, so an absent directory is an ENOENT
// at the end of an expensive, unrepeatable operation rather than a cheap failure at the start.
fs.mkdirSync(dirname(configFile), { recursive: true })
const r = await mintIdentity({ rootMnemonic: a.mnemonic || null, configFile, onStep: () => {} })
// ── SEAL IMMEDIATELY ───────────────────────────────────────────────────────────────────────
// The root mnemonic exists in memory exactly once, here — hpp's mint returns it and persists it
// nowhere. Sealing happens before this function returns, so nothing between the mint and the page
// that shows the words can cost the user their recovery secret.
//
// ⚠ LOUD, NOT FATAL. The identity already exists and the phrase is about to be shown; a caller
// who writes it down has lost nothing. Throwing here would destroy a usable identity over a
// failure to back it up, so the outcome is REPORTED and the page decides what to say.
let sealed = false
let sealError = null
try {
const { sealVault } = await import('atproto-identity/lib/vault.js')
const envelope = sealVault({ secret: r.rootMnemonic, passphrase, did: r.did })
const vaultPath = recoveryVaultPath(node, r.did)
fs.mkdirSync(dirname(vaultPath), { recursive: true })
fs.writeFileSync(vaultPath, JSON.stringify(envelope, null, 2) + '\n')
sealed = true
} catch (e) {
sealError = (e && e.message) || String(e)
}
// ⚠ The root mnemonic comes back and is NOT stored in the clear. Minting on the device is the one
// moment it exists there, so it is RETURNED for the caller to show once, and what lands on disk
// is the sealed vault above.
//
// ⛔ `signedOp` is carried too. Submitting the genesis needs it — `submitMintedGenesis` requires
// { did, signedOp } and throws without it — and the config entry does not keep it, so dropping it
// here is what makes a later submit impossible.
// ⛔ KEPT, or this identity could never be published. See `genesisPath`.
let kept = false
try { writeJson(genesisPath(node, r.did), { did: r.did, signedOp: r.signedOp, submittedAt: null }); kept = true } catch { /* reported below */ }
return { did: r.did, mnemonic: r.rootMnemonic, device: r.devicePublicKey, signedOp: r.signedOp, sealed, sealError, kept }
},
/**
* What this node's atproto identity is, if it has one.
*
* ⛔ REPORTS STATE, NEVER A VERDICT. hyper-social #69 §3 earned this rule four times over: "a page
* that prints a conclusion cannot be used to discover the conclusion is wrong". So this says what
* is on disk — a DID, a sealed vault, a submission — and the page decides what that means.
*
* ⚠ `submitted` is what WE recorded, not what the directory says. Asking the PLC directory would
* be a network call on every settings load, and a node that is offline would report "not
* published" about an identity that is. The distinction is the page's to explain.
*/
/**
* Author this identity's favela records into its atproto repo.
*
* ⛔ THE ONE THING THAT WAS NEVER RUNNING. ap-bridge HOLDS a registered name's feed and projects the
* fediverse from it server-side, so a phone federates with nothing running on it. atproto is the
* opposite: the membrane is keyless and re-serves only what the device SIGNED, so an atproto record
* exists solely because something beside the signing key authored it. On a handset that is the
* handset. Measured: a phone with a correct PLC record, a resolving handle, an active relay and nine
* fediverse posts had an atproto repo containing one bootstrap record and no profile at all.
*
* ⛔ DRY BY DEFAULT, like favela-bridge's CLI and for the same reason: projection is publication onto
* a network that does not forget. `send` must be asked for.
*
* ⚠ Consent and the reach lattice are enforced inside favela-bridge, not here — a room that may not
* leave the device is withheld even with bsky switched on.
*/
'bsky-project': async (node, a = {}) => {
const { projectBsky } = await import('./project.js')
const lines = []
const r = await projectBsky(node, node.atproto, {
send: !!a.send,
limit: Number.isFinite(Number(a.limit)) ? Number(a.limit) : Infinity,
reproject: !!a.reproject,
log: (line) => lines.push(String(line))
})
return { ...r, log: lines }
},
'did-status': async (node, a = {}) => {
const { entry, dids } = atprotoEntry(node, a, { designated: await designatedDid(node) })
if (!entry || !entry.did) return { did: null, dids, sealed: false, submitted: false, publishable: false }
const genesis = readJson(genesisPath(node, entry.did))
return {
did: entry.did,
// Every identity this node holds, so a page can offer the choice rather than show only the first.
dids,
// ⛔ The handle and endpoint a bridge granted, if one has. Without them a published DID has
// `alsoKnownAs: []` and `services: {}` — nothing for Bluesky to fetch a repo from — so the page
// must be able to tell "published" from "reachable", which are not the same state at all.
handle: entry.handle || null,
pdsUrl: entry.pdsUrl || null,
sealed: fs.existsSync(recoveryVaultPath(node, entry.did)),
submitted: !!(genesis && genesis.submittedAt),
// Whether a submit is even possible — without the genesis operation it is not, and saying so
// is better than a button that throws.
publishable: !!(genesis && genesis.signedOp)
}
},
/**
* The whole thing, in one act: ask a membrane to carry this identity, then put what it granted
* on-chain.
*
* ⛔ ONE VERB BECAUSE IT IS ONE DECISION. Fronting without adopting leaves a membrane holding a DID
* that resolves nowhere; adopting without fronting puts a handle and an endpoint on-chain,
* permanently, pointing at a server that has never heard of you. Neither half is useful alone and
* the failure of either is expensive, so they are not offered separately to a person.
*
* ⚠ ORDER MATTERS AND IS NOT ARBITRARY. Fronting is reversible — a membrane can drop a DID. The PLC
* update is not. So the reversible step goes first: if the membrane refuses, nothing permanent has
* happened, and the name is still free.
*
* ⚠ ONE PASSPHRASE PROMPT, and there is no version without it. The update is signed by a ROTATION
* key, which derives from the recovery root; the signing key this node boots with deliberately
* cannot do it (identity.md §6.1.3 — a device can rotate without ever signing). Keeping the
* mnemonic unsealed to avoid the prompt would make "a stolen device yields a locked vault" false.
*/
'did-carry': async (node, a) => {
const { entry } = atprotoEntry(node, a, { write: true, designated: await designatedDid(node) })
if (!entry || !entry.did) throw new Error('this node has no identity for a bridge to carry')
if (!a.passphrase) throw new Error('this needs your recovery passphrase — it unlocks the rotation key, the only thing allowed to change this identity')
const { connectBridge, buildFront } = await import('favela-peer/lib/bridge.js')
const { rpc } = await connectBridge(node.swarm, a.bridge)
const offer = await rpc.request('policy', {})
if (!offer || !offer.pdsUrl) throw new Error('that bridge does not say where it serves from, so there is nothing safe to publish')
const { nonce } = await rpc.request('challenge', {})
const req = buildFront({ nonce, id: entry.did, root: node.rootHex(), cred: node.cred })
const r = await rpc.request('front', { ...req, name: a.name })
if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('the bridge refused: ' + r.status))
const { handle, pdsUrl } = r.body
if (!handle) throw new Error('the bridge carried this identity but granted no handle, so there is nothing to publish')
// ⛔ THE IRREVERSIBLE HALF, and only now. Everything above can be undone by asking the bridge to
// drop us; from here the append is permanent and the previous state stays readable for ever.
const adopted = await ATPROTO_OPS['did-adopt'](node, { did: entry.did, passphrase: a.passphrase, handle, pdsUrl, plcUrl: a.plcUrl })
return { ...adopted, carriedBy: a.bridge, pending: r.status === 202 ? (r.body.reason || true) : false }
},
/**
* Accept a bridge's offer: put its handle and its PDS endpoint onto this DID, on-chain.
*
* ⛔ WHY THIS IS THE MISSING HALF. A mint is P2P-ONLY by design — `buildGenesisOp` writes
* `alsoKnownAs: []` and `services: {}` when no handle or PDS is given, and hpp's mint says the two
* "arrive LATER, as a PLC update op, when an invite from atproto-bridge or ap-bridge is accepted".
* Nothing did that, so a published DID had no server for anyone to fetch a repo from and could not
* be resolved or followed no matter what else was true.
*
* ⛔ SIGNED BY A ROTATION KEY, WHICH IS NOT THE SIGNING KEY. That separation is the whole
* survivability story (identity.md §6.1.3): a device can rotate without ever signing, and the
* signing key in config.json deliberately cannot do this. The rotation key derives from the
* recovery root, which lives sealed — so this needs the passphrase, once, and there is no version
* of it that does not. Keeping the mnemonic unsealed would make "a stolen device yields a locked
* vault" false.
*
* ⛔ AND IT IS A SECOND PERMANENT APPEND. The PLC log is append-only: this is not editable and not
* removable, and the old state stays readable for ever. The caller confirms before reaching here.
*
* ⚠ CARRIES THE HEAD FORWARD. A PLC op replaces the WHOLE document state — it is not a patch — so
* every field not being changed is copied from the live head. Dropping one would silently delete
* it: `rotationKeys` in particular, which would be an unrecoverable loss of rotation authority.
*/
'did-adopt': async (node, a) => {
const passphrase = typeof a.passphrase === 'string' ? a.passphrase : ''
const handle = String(a.handle || '').trim().toLowerCase()
const pdsUrl = String(a.pdsUrl || '').trim()
if (!passphrase) throw new Error('adopting a handle needs your recovery passphrase — it unlocks the rotation key, which is the only thing allowed to change this identity')
if (!/^[a-z0-9][a-z0-9.-]{0,252}[a-z0-9]$/.test(handle)) throw new Error('that is not a usable handle')
if (!/^https:\/\/[^\s]+$/.test(pdsUrl)) throw new Error('a PDS endpoint must be an https URL')
const { cfg, entry } = atprotoEntry(node, a, { write: true, designated: await designatedDid(node) })
if (!entry || !entry.did) throw new Error('this node has no identity to adopt a handle onto')
const did = entry.did
// ⛔ The vault, not the config. The config holds the SIGNING key; this needs the ROTATION key,
// and only the sealed root can produce it.
const envelope = readJson(recoveryVaultPath(node, did))
if (!envelope) throw new Error('no recovery vault on this device, so the rotation key cannot be derived here')
const { openVault } = await import('atproto-identity/lib/vault.js')
let mnemonic
try {
mnemonic = openVault({ envelope, passphrase, did })
} catch {
// ⚠ Undistinguished on purpose, exactly as the vault is: telling a caller which half they got
// right tells an attacker the same thing.
throw new Error('could not open the recovery vault — check the passphrase')
}
const { deriveAtprotoKey, ATPROTO_RECOVERY_PATH, buildUpdateOp, signOp } = await import('atproto-identity/lib/atproto-identity.js')
const { PlcDirectory } = await import('atproto-identity/lib/plc-directory.js')
// ⛔ THE EMBEDDER SUPPLIES fetch, this package must not import it. `bare-fetch` drags in bare-tls
// and bare-zlib — NATIVE addons — and this package resolves a different copy of them than the
// Android app does. Importing it here demanded libbare-tls.3.1.10 while the APK ships 3.1.7, and
// the build guard refused the release: staged, that bundle would have failed at dlopen, never
// reached ready, and had its tree condemned for ever on a device with no cable.
//
// ⚠ So the rule is the same one favela-surface follows for shell state — the thing that owns the
// platform provides the platform. The worklet and the desktop entry each install their own.
// ⛔ THROUGH hpp, so the native versions match the APK. `bare-fetch` drags in bare-tls and
// bare-zlib; importing it from HERE resolved bare-tls 3.1.10 while the APK carries 3.1.7, and the
// build guard refused the release — correctly, because staged it would have died at dlopen and
// had its tree condemned for ever on a device with no cable. hpp's copy is the one the APK was
// built against, and every other network path in this stack already uses it.
const { ensureFetch } = await import('hyper-proto-peer/lib/mint.js')
if (!(await ensureFetch())) throw new Error('this node has no fetch — the PLC directory cannot be reached from here')
const rotation = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
const plc = new PlcDirectory(a.plcUrl ? { baseUrl: a.plcUrl } : {})
const head = await plc.getHead(did)
const unsigned = buildUpdateOp({
prevSignedOp: head,
// Carried forward, every one. A PLC op is the whole document state.
rotationDidKeys: head.rotationKeys,
verificationMethods: head.verificationMethods,
alsoKnownAs: ['at://' + handle],
services: { ...(head.services || {}), atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl } }
})
const signedOp = { ...unsigned, sig: signOp(unsigned, rotation.privateKey) }
await plc.submitUpdate({ did, signedOp })
// ⛔ RECORDED ONLY AFTER THE CHAIN ACCEPTED IT. The booted node reads handle/pdsUrl from here, and
// a config claiming a handle the directory does not carry is worse than one claiming none.
entry.handle = handle
entry.pdsUrl = pdsUrl
writeJson(atprotoConfigPath(node), cfg)
return { did, handle, pdsUrl, adopted: true }
},
/**
* ⛔ THE IRREVERSIBLE ONE. Appends the genesis to the global PLC log — no delete, no edit, and a
* test identity is forever. Its own separateness is the safeguard: nothing that mints can submit
* by accident.
*/
'did-submit': async (node, a = {}) => {
const { entry } = atprotoEntry(node, a, { write: true, designated: await designatedDid(node) })
if (!entry || !entry.did) throw new Error('did-submit: this node has no identity to publish')
const p = genesisPath(node, entry.did)
const genesis = readJson(p)
if (!genesis || !genesis.signedOp) {
throw new Error('did-submit: the genesis operation for ' + entry.did + ' is not on disk, so it ' +
'cannot be published from here — it is re-derivable from the recovery phrase')
}
// ⚠ Idempotent by refusal rather than by re-posting. The directory is append-only; a second
// submission of the same genesis is at best wasted and at worst confusing to read later.
if (genesis.submittedAt) return { did: entry.did, submitted: true, already: true }
const { submitMintedGenesis } = await import('hyper-proto-peer/lib/mint.js')
const r = await submitMintedGenesis({ did: entry.did, signedOp: genesis.signedOp })
// ⛔ RECORDED ONLY AFTER IT SUCCEEDED. Marking first and posting second would leave a node that
// believes it published something it did not, and the repair for that is a human reading a
// global log.
writeJson(p, { ...genesis, submittedAt: Date.now() })
return { did: entry.did, submitted: true, already: false, alsoKnownAs: r.alsoKnownAs || [] }
}
}
export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath }
/**
* Boot this node's atproto identities — the thing that makes the device a WRITER rather than a reader.
*
* ⛔ THIS IS WHY hpp IS HERE AT ALL. Until now the worklet reached exactly two hpp entry points, mint
* and submit, so a phone could create an identity and publish it to the global directory and then
* author nothing under it. The swarm replicates a DID's repo to every device that holds it; a device
* that never boots the DID holds nothing.
*
* ⛔ `loadConfig()`, NEVER `JSON.parse`. favela-android learned this the hard way: the `members`
* roster has three on-disk shapes — [{key,role}], an array of hex strings, and a "hex,hex" string —
* and only the first is understood downstream. Hand-parsing made it the one reader that skipped the
* rule, the roster arrived EMPTY, and the room refused to project while the config plainly listed
* three members. `loadConfig` normalises all three, and it reads PEER_CONFIG, which is set just above
* it for that reason.
*
* ⚠ Returns null rather than throwing when there is no identity. A node with no DID is the normal
* state, not a failure, and a read-only node that boots beats a node that does not.
*/
export async function bootAtproto (node, { log = () => {}, configFile = null, storage = null, project = null } = {}) {
// `configFile` + `storage`: hpp's EXISTING config and data, when this machine already has them (the
// desktop app on a server). Absent — the handset, a fresh desktop — both live under node storage.
if (configFile) node.atprotoConfigFile = configFile
configFile = atprotoConfigPath(node)
if (!fs.existsSync(configFile)) return null
const home = storage || join(node.storage, 'atproto')
// ⛔ Set BEFORE loadConfig — it is what loadConfig reads. Our mint writes the config under the
// node's storage precisely because a handset has no home directory, so hpp must be pointed at it.
process.env.PEER_CONFIG = configFile
if (!storage) process.env.ATPROTO_OVERLAY_ROOT = process.env.ATPROTO_OVERLAY_ROOT || join(home, 'overlay')
const { loadConfig } = await import('hyper-proto-peer/lib/config.js')
const cfg = loadConfig()
const dids = Array.isArray(cfg.dids) ? cfg.dids : []
if (dids.length === 0) return null
// ⛔ SAY WHAT THIS DEVICE IS. The device public key is the one value another machine needs in order
// to admit this phone to a DID's roster, and on a release-signed app it is unreadable from adb. A
// phone that holds a DID but is absent from its roster could not otherwise even name itself to the
// machine able to fix that — favela-android lost an evening to exactly this.
try {
const { deviceKeetKeyPair } = await import('atproto-identity/lib/device-chain.js')
const b4a = (await import('b4a')).default
for (const d of dids) {
const mn = d.deviceMnemonic || cfg.deviceMnemonic
if (!mn) continue
const kp = await deviceKeetKeyPair(mn)
log('atproto device key', { did: d.did, devicePublicKey: b4a.toString(kp.publicKey, 'hex') })
}
} catch (e) { log('could not derive the device public key', { error: (e && e.message) || String(e) }) }
const { bootMultiDevice } = await import('hyper-proto-peer/lib/device-node.js')
const hpp = await bootMultiDevice({ storage: home, dids, deviceMnemonic: cfg.deviceMnemonic || null })
// ⛔ REPORT WHAT BOOTED, NOT WHAT WAS CONFIGURED. favela-android printed the config's DID count, so
// it said "signing enabled" while zero nodes had come up, and an hour went into diagnosing an empty
// appview when there was no node at all. A count that cannot express the failure is worse than none.
const up = (hpp && hpp.nodes && hpp.nodes.length) || 0
if (up === 0) log('atproto booted NO DIDs — this device signs nothing', { configured: dids.length })
else log('atproto up', { dids: up, configured: dids.length })
// ⛔ HUNG OFF THE NODE, because an op only ever receives `(node, args)`. The bsky projection has to
// run next to the signing key — the membrane is keyless and re-serves only what this device signed
// — so `bsky-project` needs the booted handle, and there is no other way for it to reach one.
// Attaching it here rather than in each caller's boot means the desktop host and the Android
// worklet both get it without either knowing it happened.
node.atproto = hpp
// ⛔ AND THE PROJECTION WATCH, for the same reason. It was started by the desktop entry alone, so the
// Android worklet — which boots through here too — never projected a post on its own: a post reached
// bsky only when someone pressed publish in Settings (2026-10-14). Started here, neither shell can be
// without it, and there is exactly one per node (two would race to post the same record).
// `project` is injectable for tests; the watch itself logs every refusal and failure.
if (up > 0) {
const { watchProjection } = await import('./project.js')
node.projection = await watchProjection(node, hpp, { log: (line) => log('bsky', { line }), ...(project ? { project } : {}) })
log(node.projection.watching ? 'bsky projection watching' : 'bsky projection not watching')
}
return hpp
}