// favela-android worklet — a FULL favela node, headless, speaking IPC to a React Native UI.
//
// ⛔ THIS IS NOT favela-web WITH THE HTML REMOVED. favela-web binds an HTTP port because a browser needs
// one. React Native does not: bare-kit already provides an IPC channel between the worklet thread and
// the app. Keeping an HTTP server here would mean binding a socket, serialising objects to HTML, and
// parsing them back — to avoid using the channel that is already open. So: no port, no render.js, no
// static assets. Objects cross the boundary.
//
// What IS reused from favela-web, unchanged: the appview (index + every query), repo-sync, communities,
// hosted-dids, and the whole atproto/holepunch stack. The appview was always render-agnostic —
// getTimeline/_hydrate return plain objects and nothing in the index knows HTML exists — which is why
// this is a re-render rather than a rewrite. See spec/architecture.md §1.
//
// ⛔ THE WORKLET OWNS ALL STATE. RN holds no atproto knowledge: no CAR parsing, no MST, no signing. If
// the UI ever needs atproto-repo, the boundary has been drawn in the wrong place. Every mutation goes
// through here, so the signing key never leaves the thread that owns it.
import 'favela-web/lib/bare-globals.js'
import process from 'bare-process'
import fs from 'bare-fs'
import path from 'bare-path'
import b4a from 'b4a'
import { createUtf8Decoder } from './utf8.js'
import { rosterEnv } from './roster.js'
import { parseShell, shellState, SHELL_FILE, STATE_FILE } from './shell-handoff.js'
const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null
// Line-delimited JSON over the bare-kit pipe. Deliberately dumb: a request carries an `id`, the reply
// echoes it. Anything richer (streaming, backpressure) can be added when a screen actually needs it —
// inventing it now would be designing against imagined requirements.
function send (obj) {
if (!IPC) { console.log(JSON.stringify(obj)); return }
try { IPC.write(Buffer.from(JSON.stringify(obj) + '\n')) } catch { /* UI gone; node keeps running */ }
}
// ⛔ ALSO TO THE SYSTEM LOG. These lines are the only account of what the node did at boot — whether hpp
// came up, why it didn't, which DIDs loaded. Sending them ONLY over IPC meant they existed for the two
// seconds they were on the startup screen and nowhere else: unreadable by adb, ungrepable after the
// fact, and gone entirely if the UI was not watching yet. A boot log that cannot be read after boot is
// not a log. bare-kit routes console.* to logcat under the `bare` identifier.
const log = (msg, extra = {}) => {
const line = Object.keys(extra).length ? msg + ' ' + JSON.stringify(extra) : msg
try { console.log('[favela] ' + line) } catch {}
send({ t: 'log', msg, ...extra })
}
// Android hands the app-private dir in as argv (there is no $HOME and no env on a phone — this replaces
// favela's FAVELA_STORE_ROOT convention entirely).
const storage = Bare.argv[Bare.argv.length - 1]
const dirs = {
root: storage,
hpp: path.join(storage, 'hpp'),
favela: path.join(storage, 'favela'),
overlay: path.join(storage, 'hpp', 'atproto-repo')
}
// What the native shell handed us for THIS boot: which bundle it started, whether it can service the
// settings page's update buttons, and the OS's DNS servers. Read once, here, because prepareEnv turns
// two of the three into env that favela-web freezes at import (see below).
//
// Absent on an old shell — every field then reads as "unknown", which is the honest answer and disables
// exactly the features that depend on it. See lib/shell-handoff.js for why this is a file, not argv.
const shell = (() => {
try { return parseShell(fs.readFileSync(path.join(storage, SHELL_FILE), 'utf8')) } catch { return parseShell(null) }
})()
// ⛔ ENV BEFORE ANY IMPORT THAT READS IT. favela-web's libs capture process.env at module top level, so
// their config freezes on first import. Everything below is therefore assigned here and every favela
// import in boot() is DYNAMIC.
function prepareEnv () {
for (const d of Object.values(dirs)) fs.mkdirSync(d, { recursive: true })
process.env.HOME = storage
process.env.FAVELA_STORE_ROOT = dirs.favela
process.env.ATPROTO_OVERLAY_ROOT = dirs.overlay
process.env.PEER_CONFIG = path.join(dirs.hpp, 'config.json')
// A phone is storage-constrained and killed constantly; bound the inbound lens by default.
process.env.FAVELA_RETENTION_BUDGET = process.env.FAVELA_RETENTION_BUDGET || String(64 * 1024 * 1024)
// ⛔ ANDROID HAS NO /etc/resolv.conf. favela-web finds no nameserver there and turns DNS TXT handle
// verification OFF — which on a handset means every custom-domain handle renders handle.invalid,
// measured 2026-08-24. netd owns resolution on Android and `getprop net.dns1` has been empty since
// Android 8, so the only source is ConnectivityManager, which only the native side can ask. It does,
// and passes the answer through shell.json. This is the OS's own resolver for the active network —
// not a third-party one, which favela-web deliberately refuses to default to.
if (shell.dns.length) process.env.FAVELA_NAMESERVERS = shell.dns.join(',')
// Where the settings page reads the update state from. Set unconditionally so the file's ABSENCE (a
// node that never wrote one) and an `ota:false` inside it are distinguishable.
process.env.FAVELA_SHELL_STATE = path.join(storage, STATE_FILE)
}
// Env-overridable so the suite can bind an ephemeral port. Hardcoding it meant test/ipc.test.mjs —
// which boots this file for real — died with EADDRINUSE whenever a favela-web was already running on
// this machine, i.e. on any developer box that had actually deployed one. The phone always gets 4041.
const FAVELA_PORT = Number(process.env.FAVELA_PORT) || 4041
// No `appview` here any more — favela-web owns the one index on this node. See the note where the
// worklet's second one used to be built.
const state = { hpp: null, ready: false, favelaUrl: null, favelaSession: null }
// The OTA runtime handle and the last time a check completed. Module-scoped because the IPC surface
// needs both long after boot() has returned.
let ota = null
let _lastCheck = null
// BOOT TIMING. "The app takes a very long time to start" has been a standing complaint with no number
// attached, and every guess about the cause (bundle size? swarm join? replication?) is unfalsifiable
// without one. Each stage logs its own duration and the cumulative total, so the next boot on a real
// handset says where the time actually goes instead of inviting another guess.
//
// Deliberately measured with Date.now() deltas around the stages that ALREADY log — no new checkpoints
// invented, so the timings line up with the existing narrative in the log.
let _bootT0 = 0
let _stageT = 0
function stage (name, extra = {}) {
const now = Date.now()
const ms = _stageT ? now - _stageT : 0
_stageT = now
log('boot stage: ' + name, { ms, totalMs: now - _bootT0, ...extra })
}
async function boot () {
_bootT0 = _stageT = Date.now()
prepareEnv()
log('booting', { storage })
// ---- the node's own identity + repo. This is what makes it a FULL node rather than a reader. ----
const cfgPath = process.env.PEER_CONFIG
if (fs.existsSync(cfgPath)) {
try {
const { bootMultiDevice } = await import('hyper-proto-peer/lib/device-node.js')
// ⛔ hpp's OWN LOADER, not JSON.parse. The config's `members` roster has three on-disk shapes —
// [{key,role}], an array of hex strings, or a "hex,hex" comma-string — and only the first is
// understood downstream: projectMembers reads `m.key`, so plain strings yield undefined, are
// skipped, and the roster arrives EMPTY. loadConfig normalises all three (config.js
// normalizeMembers), which is why every node that boots through it works and this app did not:
// hand-parsing made it the one reader that skipped the rule. The room then refused to project,
// logging "empty roster would wipe 3 existing member(s)" every 30s while the config listed three.
//
// loadConfig reads PEER_CONFIG, which is set above, and also applies the PEER_* env overrides —
// none of which are set on a handset, so this is the same file, correctly shaped.
// ── REPAIR BEFORE LOAD: an identity minted by 0.1.6 has no storeBackend ──────────────────
// Those DIDs boot on the legacy inline substrate and cannot post — the appview refuses to
// materialise their record bytes ("refusing to CREATE bytes for a roster DID"). 0.3.121 fixed the
// mint; it does nothing for an identity that already exists, and that identity is a permanently
// registered did:plc with a sealed recovery vault, so wiping storage to change one field is not an
// option. lib/repair-store-backend.js decides which entries may move (minted HERE, never joined);
// hyper-proto-peer's marker-guarded migrateInline does the actual, tested conversion at boot.
//
// Written back to the RAW file, not through loadConfig: loadConfig normalises and overlays env, so
// persisting its output would write derived values back as if they were the operator's.
try {
const { repairStoreBackend } = await import('./repair-store-backend.js')
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
const { changed } = repairStoreBackend(raw)
if (changed.length) {
// Keep the previous file. This is the one write that touches an identity's config, and the old
// copy is the fastest way back if the migration below turns out to be wrong for this repo.
try { fs.copyFileSync(cfgPath, cfgPath + '.bak.' + Math.floor(Date.now() / 1000)) } catch {}
fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + '\n')
log('repaired storeBackend — these identities were minted before 0.3.121 and could not post', { dids: changed })
}
} catch (e) {
// Loud, not fatal: a node that boots read-only is better than a node that does not boot.
log('storeBackend repair skipped', { error: (e && e.message) || String(e) })
}
const { loadConfig } = await import('hyper-proto-peer/lib/config.js')
const cfg = loadConfig()
// deviceMnemonic is DEVICE-scoped: `coowner-join` mints it at config ROOT so a phone can generate
// its key before it knows which DID it is joining, and the `dids` entry it writes carries none.
// Not passing it is why a phone that had genuinely completed the ceremony served ZERO DIDs
// (hyper-social #40 §3). hyper-proto-peer 0.3.110+ applies the same precedence coowner-join uses.
state.cfgDids = cfg.dids || [] // handles live in the CONFIG, not on the booted node — keep them
// SAY WHAT THIS DEVICE IS. The device public key is the ONE value another machine needs in order to
// admit this phone (`coowner-invite --device <hex>` puts it in the DID's roster, which is what
// StateBaseV3 checks before it will admit a writer). It was written nowhere: not logged, not in any
// read RPC, and unreadable from adb on a release-signed app — so a phone that holds a DID but is
// absent from its roster could not even NAME itself to the machine able to fix that. Whole evening,
// 2026-08-11.
//
// Same mnemonic precedence coowner-join uses (per-DID key first, config root second): printing the
// key from the wrong source would send the next person round the same loop with a hex string that
// admits nothing. Best-effort — a phone that cannot derive this still boots and reads.
try {
const { deviceKeetKeyPair } = await import('atproto-identity/lib/device-chain.js')
for (const d of (cfg.dids || [])) {
const mn = d.deviceMnemonic || cfg.deviceMnemonic
if (!mn) continue
const kp = await deviceKeetKeyPair(mn)
log('device public key for ' + d.did, { devicePublicKey: b4a.toString(kp.publicKey, 'hex') })
}
} catch (e) { log('could not derive the device public key', { error: (e && e.message) || String(e) }) }
state.hpp = await bootMultiDevice({ storage: dirs.hpp, dids: cfg.dids || [], deviceMnemonic: cfg.deviceMnemonic || null })
// (A roster "repair" lived here briefly — it read the config's members, found them non-empty, and
// correctly did nothing, because the empty roster was never the config's: it was the SHAPE being
// dropped on the way in, which loadConfig above now prevents. Removed rather than left dormant.)
// ⛔ REPORT WHAT BOOTED, NOT WHAT WAS CONFIGURED. This line used to print the CONFIG's DID count,
// so it said "signing enabled {dids:1}" while zero nodes had come up — the compose button renders
// on `signing`, so the app looked paired 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 no line.
const up = (state.hpp && state.hpp.nodes.length) || 0
const configured = (cfg.dids || []).length
if (up === 0 && configured > 0) log('hpp booted NO DIDs — read-only despite ' + configured + ' configured', { dids: 0, configured })
else log('hpp up — signing enabled', { dids: up, configured })
stage('hpp up')
} catch (e) {
// A node that cannot sign still reads; a node that cannot boot is useless. Degrade, loudly.
try { console.error('[favela] hpp boot FAILED — read-only:', (e && e.stack) || String(e)) } catch {}
log('hpp boot FAILED — read-only', { error: (e && e.message) || String(e) })
}
} else {
// Deliberately NOT minting a DID here, and that has not changed now that `mint` exists — it is a
// REQUEST handler, driven by someone tapping through a screen that says what it costs. Joining an
// existing identity and creating a new one are both user decisions with real consequences
// (spec/architecture.md §6.3), and a boot path must not choose silently between them.
//
// What HAS changed is that both are now reachable from the device: the intro modal offers pairing
// and creation, and either can be declined (hyper-social #54 §8).
log('no identity yet — read-only until paired or created (see spec §6.3)')
}
// ---- there is ONE index on this phone, and favela-web owns it ----
//
// ⛔ WHAT USED TO BE HERE, AND WHY IT WAS WRONG. The worklet built its OWN `Appview` on its OWN
// Corestore (<storage>/favela/index) and fed it by folding hpp's records straight off local disk
// (`indexPeer({_noRepo:true})`) on every StateBase advance. The premise was that a co-owner does not
// need favela-web's ingest loop because the records are already here. Both halves of that failed:
//
// 1. NOBODY READ IT. Every screen reads favela-web's index (see the compose handler's note — this
// file already recorded the same discovery once, for optimistic writes). So the fold logged a
// healthy "indexed … statebase-update" on every advance while updating an index with no readers.
// 2. IT SKIPPED THE VERIFICATION. atproto's sync spec is explicit that "receiving services are
// responsible for validating message structure and verifying authenticity" — verify the commit
// signature against current identity data, check the message against the signed commit in the
// blocks CAR. Reading records off disk does none of that. Defensible for a DID we sign for;
// wrong for anyone else, and this path made no distinction.
//
// It also opened a SECOND OverlayRepoStore over hpp's private record directory, in the same process
// where favela-web already has one. Each instance keeps its own in-memory oid→disk map, rebuilt only
// from its own ready() seed (atproto-store invariant #5) — the shape behind the 75-hour wj62
// byte-resolution outage. One process, one reader.
//
// favela-web already implements exactly the split this needs (bin/favela-web.js:577-616): a DID it
// co-hosts baselines from the shared store and tails from the current end — no refetch of what is on
// disk — and everyone else backfills from a VERIFIED firehose front, failover-aware, applying each
// #commit incrementally instead of re-folding every record. It just needed to be told who to watch,
// which is what FAVELA_ROSTER below does.
// ── PORT, NOT REIMPLEMENT ────────────────────────────────────────────────────────────────────────
// favela-web is a bare app (runtime "bare", bare-http1) and it holds the appview and every social
// query; hpp holds identity/repo/blobs and exposes no timeline, thread, profile or search at all. So
// the client logic has to run HERE, and hand-writing a second, thinner set of queries beside it — one
// bespoke IPC verb per feature — is what produced a crippled cousin built from the same parts.
//
// Bring up the REAL favela-web in-process, bound to loopback, and let the UI call its routes. Its own
// store root, deliberately: it constructs its own Corestore, and pointing it at the one our appview
// already holds would collide on the lock.
//
// Non-fatal: if it fails the app still runs on the existing surface, and the failure is REPORTED
// rather than leaving the UI to guess why every screen is empty.
try {
process.env.FAVELA_PORT = String(FAVELA_PORT)
process.env.FAVELA_HOST = '127.0.0.1'
process.env.FAVELA_STORE_ROOT = path.join(dirs.root, 'favela-web')
process.env.FAVELA_SOCK = path.join(dirs.root, 'favela-web', 'svc.sock')
// THE WRITE PATH. favela-web refuses writes unless it can NAME the hpp it writes to, and it was
// never told — so the embedded server logged "writes are DISABLED" and every compose/like/follow
// through it failed, while reads worked fine and hid it. The hpp is in THIS process with its control
// socket already open, so name that: local IPC, no DHT, and the DID key never leaves the handset
// (which is why FAVELA_CONTROL_KEY, the REMOTE-hpp transport, must stay unset here).
//
// The path comes from hpp itself (bootSwarmDevice's `uds`) rather than being rebuilt from its private
// <storage>/dids/<roomId>/ layout — see the note on that field in device-node.js.
const ctlSock = state.hpp && state.hpp.nodes && state.hpp.nodes[0] && state.hpp.nodes[0].uds
if (ctlSock) process.env.FAVELA_HPP_SOCK = ctlSock
else log('hpp exposed no control socket — favela-web will refuse writes (reads unaffected)')
// THE READ PATH, and it was never told either. favela-web SUBSCRIBES to its roster: per DID it
// resolves a verifyKey, joins the topic, and tails the freshest verified co-owner's firehose,
// applying each #commit incrementally (bin/favela-web.js:565-687). It reads FAVELA_ROSTER at module
// top level, so this must be set BEFORE the import below — and it never was. The phone therefore
// booted with `joined 0 DID topic(s)` and an index that could only contain what was already in it:
// records authored on another co-owner of our OWN DID reached the overlay store (pointers converged,
// bytes fetched) and never reached the index every screen reads. Measured 2026-08-18 — three posts,
// 404 on the handset two and a half hours and two boots later, with nothing in any log to say so.
//
// Hosted DIDs are in the roster because we INDEX them, not because we sign for them — the signing set
// is the one control socket above. addPeer's coHosted branch baselines a hosted DID from the shared
// store and tails from the current end, so this costs no refetch of what is already on disk.
const roster = rosterEnv({ hosted: hostedDids(), tracked: readTracked() })
if (roster) process.env.FAVELA_ROSTER = roster
log('favela-web roster', { dids: roster ? roster.split(',').length : 0 })
stage('favela-web starting')
fs.mkdirSync(process.env.FAVELA_STORE_ROOT, { recursive: true })
await import('favela-web/bin/favela-web.js')
state.favelaUrl = 'http://127.0.0.1:' + FAVELA_PORT
// Prove it SERVES, do not just assume the import worked: fetch its own status over loopback.
try {
const fetchImpl = (await import('bare-fetch')).default
const res = await fetchImpl(state.favelaUrl + '/status')
log('favela-web in-process', { url: state.favelaUrl, status: res.status })
stage('favela-web serving')
// SIGN IN to our own favela-web. Without this every UI fetch is ANONYMOUS: viewerOf(req) returns
// null, so liked/reposted state and per-viewer mutes silently vanish from every row and every
// write route 401s. The failure mode is invisible — posts just render un-liked — which is exactly
// the kind of quiet degradation this port exists to remove, so it is logged either way.
//
// Non-fatal: a phone that cannot sign in still reads (anonymously). Reporting beats guessing.
await signInToFavela()
stage('signed in')
} catch (e) { log('favela-web started but did not answer /status', { error: e.message }) }
} catch (e) {
state.favelaUrl = null
log('favela-web could NOT start in-process — UI stays on the legacy surface', { error: e.message })
}
// Re-subscribe to what the user chose, now that favela-web is up to receive it.
//
// ⛔ ORDER IS THE WHOLE POINT. This used to run BEFORE favela-web started, because it fed the worklet's
// own index and had nothing to wait for. It now goes through favela-web, so it must come after — and
// after sign-in, because /api/track requires a viewer.
//
// FAVELA_ROSTER already carried the tracked DIDs into favela-web's boot, so this is not how they get
// subscribed; it is how we find out whether each one is REACHABLE and say so. Both loops are
// non-blocking: a community nobody is serving right now must not hold up the app.
if (state.favelaUrl) {
handlers.syncFollows({}).then(
(r) => { if (r && r.queued) log('syncing ' + r.queued + ' followed account(s)', { following: r.following }) },
(e) => log('could not read follows yet', { error: (e && e.message) || String(e) })
)
for (const did of readTracked()) {
handlers.viewDid({ did, track: false }).then(
(r) => log('viewing ' + did.slice(0, 20) + '…', { rev: r.rev }),
(e) => log('cannot view ' + did.slice(0, 20) + '… yet', { error: e.message })
)
}
}
state.ready = true
send({ t: 'ready' })
// ⛔ BEFORE THE OTA ATTEMPT, NOT AFTER IT. The settings section is gated on this file existing, and it
// carries the RESTART button — which is also the fix for a stale session and for a node that has
// wedged. Writing it only on a successful startOta would withdraw the recovery control from exactly
// the builds that most need it (pgh-runtime is deferred in the pack and may not be there at all).
writeUpdateState()
// OTA for this bundle. Deferred in the pack (--defer pgh-runtime) and dynamically imported, so a
// build without it still runs: an update path that can break the node is worse than no update path.
//
// ⛔ THE HANDLE IS KEPT NOW. It used to be dropped on the floor, which is why there was no way to ask
// for a check: pgh-runtime's `checkNow()` is public and was simply unreachable from here.
//
// And `currentTreeHash` is finally passed. Without it pgh-runtime's "nothing to do if we already run
// this tree" test can never fire, so every boot logged `downloading … / staged … — active on next
// start` naming the bundle it was already running. No bytes moved (installTree short-circuits on
// hasTree), but that log is what a whole evening's wrong conclusion was read off.
try {
const { startOta } = await import('./ota.js')
ota = await startOta({ storage: dirs.root, say: (m) => log(m), currentTreeHash: shell.bundle })
writeUpdateState()
} catch (e) {
log('ota unavailable (non-fatal)', { error: (e && e.message) || String(e) })
}
}
// The settings page's three fields, on disk where favela-web can render them SERVER-side. Kept as a file
// rather than an IPC reply because the page is HTML from favela-web, not React Native — a reply channel
// would mean a second source of truth for the same three fields.
//
// Never throws: this is a status file. A node that cannot write it still runs, and the page then shows
// what it last knew.
function writeUpdateState (checkedAt = _lastCheck) {
try {
let staged = null
try { staged = (JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8')) || {}).treeHash } catch {}
fs.writeFileSync(process.env.FAVELA_SHELL_STATE, JSON.stringify(shellState({ shell, staged, checkedAt })))
} catch { /* status only */ }
}
// ---- the IPC surface (spec §3) ---------------------------------------------------------------------
// ── talking to our own favela-web ─────────────────────────────────────────────────────────────────
//
// Same client the screens use (lib/favela-api.js), for the same reason its header gives: the moment a
// second caller builds its own request, session handling has two definitions and the one that forgets
// the cookie degrades SILENTLY — favela-web answers 200 with viewerDid null.
//
// ⛔ THIS REPLACED A SECOND P2P STACK. Viewing a foreign DID used to construct favela-web's `P2PSource`
// HERE, in the worklet: its own storage, its own Hyperswarm, its own topic joins — a third subscriber
// beside favela-web's swarm and hpp's, in one process, doing what favela-web's addPeer already does with
// failover-aware front selection and an incremental #commit tail. `/api/track` is that same work, done
// once, by the component that owns the index.
let _api = null
async function api () {
if (!state.favelaUrl) throw new Error('favela-web is not up on this node')
const fetchImpl = (await import('bare-fetch')).default
// Rebuilt when the session changes: the token is captured at construction, and a client still holding
// a pre-sign-in null would read anonymously forever.
if (!_api || _api.session !== state.favelaSession) {
const { FavelaApi } = await import('./favela-api.js')
_api = new FavelaApi({ baseUrl: state.favelaUrl, session: state.favelaSession, fetchImpl })
}
return _api
}
const TRACKED = () => path.join(dirs.root, 'tracked-dids.json')
function readTracked () { try { return JSON.parse(fs.readFileSync(TRACKED(), 'utf8')) } catch { return [] } }
// EVERY hosted DID, not nodes[0]. A handset can house two accounts — reads are already multi-DID (one
// index, DID-keyed, `viewerDid` per query), so half-subscribing the second one would show up as a
// timeline quietly missing a person rather than as an error. (Writes are still single-DID: one control
// socket, see favela-web/lib/writer.js:283. Roster membership carries no authority.)
function hostedDids () { return ((state.hpp && state.hpp.nodes) || []).map((n) => n && n.did).filter(Boolean) }
function writeTracked (list) { try { fs.writeFileSync(TRACKED(), JSON.stringify(list, null, 2) + '\n') } catch {} }
// SIGN IN to our own favela-web. Without this every UI fetch is ANONYMOUS: viewerOf(req) returns null,
// so liked/reposted state and per-viewer mutes silently vanish from every row and every write route 401s.
// The failure mode is invisible — posts just render un-liked — so it is logged either way.
//
// ⛔ CALLABLE AGAIN, not just at boot. favela-web's session store is an in-memory Map, so it is emptied
// by every restart of that process while this side still holds the old token. The WebView then hits
// /auth/handoff, gets "unknown or expired token", and RENDERS THE REFUSAL — a recoverable condition
// presented as a dead end, with force-stopping the app as the only way out. Nothing about the
// credentials changed: this side can mint a fresh session whenever asked, and now does.
//
// Non-fatal by design: a phone that cannot sign in still reads, anonymously. Reporting beats guessing.
async function signInToFavela () {
const entry = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
if (!entry) {
log('no DID yet — favela-web stays anonymous until this device is paired')
return { ok: false, reason: 'no DID' }
}
try {
const fetchImpl = (await import('bare-fetch')).default
const { loadOrCreateSubKey, establishSession, sessionNodeOf } = await import('./session.js')
// hpp hands back a WRAPPER, not the SwarmDevice — see sessionNodeOf. Passing `entry` straight
// through is what made every boot sign-in fail with "an hpp node with delegate() required".
const node = sessionNodeOf(entry)
if (!node) throw new Error('hpp entry exposes no delegate() — cannot sign in')
const key = loadOrCreateSubKey(path.join(dirs.root, 'ui-subkey.json'), fs, path.dirname)
if (key.created) log('minted a UI sub-key for this device (delegated by our own DID)')
const s = await establishSession({
favelaUrl: state.favelaUrl, node, privHex: key.privHex, pubHex: key.pubHex, fetchImpl
})
state.favelaSession = s.token
log('signed in to favela-web', { did: s.did.slice(0, 20) + '…', handle: s.handle })
return { ok: true, token: s.token }
} catch (e) {
state.favelaSession = null
log('favela-web sign-in FAILED — the UI will read anonymously (no liked/reposted state, no writes)', { error: e.message })
return { ok: false, reason: e.message }
}
}
const handlers = {
// Mint a FRESH favela-web session and hand back the token.
//
// Exists because favela-web's session store is in memory: when that process restarts, this side's
// token is instantly unknown and /auth/handoff answers 403. The WebView used to render that refusal
// and stop, which reads as "signed out" and is only escapable by force-stopping the app. It is a
// recoverable condition — nothing about the credentials changed — so the UI now asks for a new
// session instead of dead-ending. See App.jsx's onHttpError.
async resession () {
const r = await signInToFavela()
return { ok: !!r.ok, favelaSession: state.favelaSession, favelaUrl: state.favelaUrl, reason: r.reason || null }
},
// ── app updates ──────────────────────────────────────────────────────────────────────────────────
// What is running, what is staged, when we last looked. The same three fields the settings page reads
// from disk; this verb exists for the shell and the suite, which have no HTML to read.
async updateStatus () {
let staged = null
try { staged = (JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8')) || {}).treeHash } catch {}
return { ...shellState({ shell, staged, checkedAt: _lastCheck }), available: !!ota }
},
// Ask NOW instead of waiting up to six hours.
//
// ⛔ IT STILL DOES NOT APPLY ANYTHING. Staging and applying are separate on purpose (applying to a
// running node means swapping code under itself), so this downloads and stages; the restart that runs
// it belongs to the shell, which owns the runtime. Saying "updated" here would be the same false
// report the OTA log used to make.
async checkForUpdate () {
if (!ota || typeof ota.checkNow !== 'function') return { ok: false, reason: 'ota unavailable in this build' }
try {
await ota.checkNow()
_lastCheck = new Date().toISOString()
writeUpdateState()
return { ok: true, ...(await handlers.updateStatus()) }
} catch (e) {
// A failed check is a network fact, not a broken node — report it and leave the state alone.
return { ok: false, reason: (e && e.message) || String(e) }
}
},
async status () {
// favelaSession is the session COOKIE VALUE, handed to the UI so its fetches can carry
// `Cookie: favela_session=…`. HttpOnly is a browser protection against document-script access; there
// is no document here, and the UI shares this process's trust boundary — the worklet already holds
// the DID signing key, which is strictly more sensitive than a 30-day session token.
//
// Null means the UI is anonymous, and it must SAY so rather than render as if signed in: no
// liked/reposted state, no per-viewer mutes, and every write will 401.
return {
ready: state.ready,
signing: !!state.hpp,
storage: dirs.root,
favelaUrl: state.favelaUrl,
favelaSession: state.favelaSession
}
},
// HOME IS WHO YOU FOLLOW, not what happens to be indexed. Unscoped, getTimeline returns everything in
// the local index — and this node indexes only the DIDs it hosts, so "the timeline" was your own posts
// read back to you. Two separate causes, and scoping alone fixes neither: there also has to BE content
// from other people, which is what syncFollows below is for.
//
// scope 'following' (default) — you + everyone you follow + anything you track (communities)
// scope 'all' — every author in the local index, unfiltered (what it used to do)
// scope '<did>' — one account/community
// Pull the repos of accounts you follow but do not host, so the scoped timeline has something in it.
// Bounded and non-blocking on purpose: this is a phone, each one is a topic join plus a verify, and a
// home feed must not wait on the slowest peer. Already-tracked DIDs are skipped (viewDid tracks them).
async syncFollows ({ viewer = null, max = 10 } = {}) {
const me = viewerDid(viewer)
if (!me) return { synced: 0 }
const hosted = new Set(((state.hpp && state.hpp.nodes) || []).map((n) => n.did))
const tracked = new Set(readTracked())
// The graph lives in favela-web's index, which is now the only one. `.catch(() => [])` is kept
// deliberately: a favela-web that is not up yet must degrade to "nothing to sync", not throw on boot.
const following = (await api().then((a) => a.follows({ did: me })).then((r) => (r && r.dids) || []).catch(() => [])) || []
const todo = following.filter((d) => !hosted.has(d) && !tracked.has(d)).slice(0, max)
for (const did of todo) {
handlers.viewDid({ did }).then(
(r) => log('followed ' + did.slice(0, 20) + '… indexed', { rev: r.rev }),
(e) => log('cannot reach ' + did.slice(0, 20) + '… yet', { error: e.message })
)
}
return { queued: todo.length, following: following.length }
},
// The pairing ceremony runs IN THE WORKLET, never in the UI. lib/adopt.js touches keys, proofs and the
// blind-pairing tunnel; a React Native component must not. The screen collects an invite and shows
// progress — that is the whole of its involvement.
async adopt ({ inviteHex, did = null } = {}) {
// ONE STRING. The device mints its key from a mnemonic (which needs no identity), sends the public
// half up the tunnel, and learns the DID from the bundle that comes back. `did` is accepted only as
// an ASSERTION of which identity you expect — omitted, it is learned. Proven in
// hyper-proto-peer/test/coowner-join-didless + this repo's adopt e2e.
if (!inviteHex) throw new Error('adopt requires { inviteHex }')
const { joinAdoptInvite } = await import('./adopt.js')
const r = await joinAdoptInvite({
did,
inviteHex,
configFile: process.env.PEER_CONFIG,
onStep: (msg, extra) => log('adopt: ' + msg, extra)
})
// Deliberately NOT rebooting hpp here. The config is written; the node picks the DID up on next
// start. A pairing call that also restarts the node is two operations, and the failure modes of the
// second would surface as failures of the first.
return { paired: true, devicePublicKey: r.devicePublicKey, entry: r.entry, restartRequired: true }
},
// CREATING an identity — the other half of the intro modal. Pairing copies an identity that already
// exists; a phone that has never been paired has nothing to copy, so without this a fresh device could
// never become a full node at all. That step used to exist only as bin/hpp-onboard.js on a server with
// a hyperident and a shell, which a handset has neither of (hyper-social #54 §8).
//
// Runs IN THE WORKLET for the same reason adopt does: it touches keys and the recovery secret, and a
// React Native component must not. The screen collects a passphrase, shows the words once, and asks
// for confirmation — that is the whole of its involvement.
async mint ({ passphrase = null, submit = true } = {}) {
// ⛔ REFUSE IF THIS DEVICE ALREADY SIGNS. mintIdentity guards the config too, but this catches the
// case earlier and with a message about what the user can see: a booted node. Minting is cheap to
// repeat and impossible to undo, so both guards stay.
if (state.hpp && state.hpp.nodes && state.hpp.nodes.length) {
throw new Error('this device already has an identity — refusing to mint another')
}
// The passphrase is the ONLY thing protecting the recovery vault. The blob is designed to be
// replicated, so it will land on disks we do not control; it is safe there only because it is
// useless without something in a person's head (#54 §6). A short one makes the Argon2id cost the
// sole defence, which is not what it is for.
const pass = typeof passphrase === 'string' ? passphrase : ''
if (pass.length < 8) throw new Error('mint requires a passphrase of at least 8 characters — it is the only thing protecting your recovery vault')
const { mintIdentity, submitMintedGenesis } = await import('./mint.js')
const r = await mintIdentity({
configFile: process.env.PEER_CONFIG,
onStep: (msg, extra) => log('mint: ' + msg, extra)
})
// ── SEAL BEFORE ANYTHING CAN FAIL ────────────────────────────────────────────────────────────
// The root mnemonic exists in memory exactly once, here. Sealing it comes before the network call
// so that a directory that is unreachable cannot cost the user their recovery secret — the identity
// is already usable P2P at this point, and submission is retryable.
let sealed = false
let sealError = null
try {
const { sealVault } = await import('atproto-identity/lib/vault.js')
const envelope = sealVault({ secret: r.rootMnemonic, passphrase: pass, did: r.did })
const dir = path.join(dirs.hpp, 'recovery')
fs.mkdirSync(dir, { recursive: true })
// Named by the DID's own suffix: one device may later hold several identities, and a single
// vault.json would silently mean "the last one minted".
fs.writeFileSync(path.join(dir, r.did.split(':').pop() + '.vault.json'), JSON.stringify(envelope, null, 2) + '\n')
sealed = true
log('mint: recovery vault sealed', { did: r.did })
} catch (e) {
// Loud, and NOT fatal: the identity exists and the words are about to be shown. A user who writes
// them down has lost nothing. Swallowing this would be the real failure.
sealError = (e && e.message) || String(e)
log('mint: could not seal the recovery vault — WRITE THE WORDS DOWN', { error: sealError })
}
// ── THE IRREVERSIBLE PART, LAST AND ON ITS OWN ───────────────────────────────────────────────
// A PLC genesis is a permanent, public append to a global log. It is last so that everything
// recoverable has already succeeded, and its failure is REPORTED rather than thrown: the identity
// is real and works P2P without it, and the op re-derives from the mnemonic for a later retry.
let submitted = false
let submitError = null
if (submit) {
try {
await submitMintedGenesis({ did: r.did, signedOp: r.signedOp, onStep: (msg) => log('mint: ' + msg) })
submitted = true
} catch (e) {
submitError = (e && e.message) || String(e)
log('mint: genesis NOT registered — the identity still works P2P', { error: submitError })
}
}
// Deliberately NOT rebooting hpp here, for the reason adopt gives: two operations in one call means
// the second one's failures surface as the first one's.
return {
did: r.did,
// SHOWN ONCE. The only copy the user will ever see; the vault holds the other.
mnemonic: r.rootMnemonic,
devicePublicKey: r.devicePublicKey,
sealed,
sealError,
submitted,
submitError,
restartRequired: true
}
},
// ACCEPTING A BRIDGE'S INVITE — the other end of the split mint created.
//
// mint produces a P2P-only identity: no handle, no PDS, nobody's permission required. Being FRONTED by
// a membrane is what an invite grants, and accepting one is a PLC update adding a handle and a service
// endpoint. It must be signed by a current rotation key — the root recovery key — which mint sealed
// into the vault under a passphrase for exactly this moment.
//
// ⛔ THE PASSPHRASE IS ALL THAT CROSSES THE BRIDGE. The page collects it; this opens the vault, derives
// the key, signs and submits. The mnemonic never reaches React Native, the WebView, or a log line.
//
// ⛔ PREVIEWS UNLESS TOLD OTHERWISE. A PLC append is permanent and public. The caller asks twice: once
// to see the exact change, once to make it. A confirm screen is only honest if what it describes is
// what will happen, so both calls go through the same code path.
async hostingAttach ({ handle = null, pdsUrl = null, passphrase = null, submit = false, plcUrl = null } = {}) {
const did = (state.cfgDids && state.cfgDids[0] && state.cfgDids[0].did) || null
if (!did) throw new Error('this device has no identity to host — pair or create one first')
if (!handle) throw new Error('hosting requires the handle the invite grants')
if (!pdsUrl) throw new Error('hosting requires the service endpoint the invite grants')
if (!passphrase) throw new Error('hosting requires your vault passphrase — it is what unlocks the recovery key')
const { vaultPathFor, resolveSigner, hostingAttach: run } = await import('./hosting.js')
const vaultPath = vaultPathFor(dirs.hpp, did)
let envelope = null
try { envelope = JSON.parse(fs.readFileSync(vaultPath, 'utf8')) } catch {
// A phone that PAIRED rather than minted has no vault: the recovery key stayed with whoever created
// the identity. Saying so is the whole answer — there is no fallback that would be correct.
throw new Error('no recovery vault on this device — this identity was not created here, so its recovery key is on the device that made it')
}
// Fails identically for a wrong passphrase, a tampered blob and the wrong identity, deliberately.
const signer = resolveSigner({ vault: envelope, passphrase, did })
log('hosting: vault unlocked', { did, submit })
const r = await run({ did, handle, pdsUrl, signer, plcUrl, submit })
if (r.submitted) log('hosting: PLC updated — this identity now names a handle and a PDS', { did, handle, pdsUrl })
return {
did,
submitted: r.submitted,
preview: r.preview,
// ⛔ SAY WHAT IS STILL MISSING. Naming a PDS does not make the bridge serve you: fronting is the
// operator's half and cannot be done from here. A screen that reported success and left the user
// wondering why nothing federated would be the same lie as "installed" meaning "running".
operatorStepRemaining: r.submitted
? 'the bridge operator must add ' + did + ' to frontedDids before it will front this identity'
: null
}
},
// View a DID this node does not host: join its topic over P2P, verify its head, index it into the
// SAME appview the hosted DIDs use. After this it is an ordinary account everywhere downstream —
// author feed, thread, search — with no special case. Tracked so it survives a restart.
async viewDid ({ did, track = true, timeout = 45000 } = {}) {
if (!did || !did.startsWith('did:')) throw new Error('viewDid requires { did }')
// ONE ROUTE FOR BOTH CASES, because favela-web already distinguishes them better than this did.
// /api/track is HOSTED-FIRST: a DID hpp holds is baselined from the shared store on disk rather than
// chased over the network — which is the same correction made here once before, when tapping our own
// identity reported "nobody is serving it right now" while its posts were on screen. A DID we do not
// hold gets a verified front, and the route waits for one so an unreachable DID SAYS so.
const a = await api()
const r = await a.track({ did, wait: timeout })
if (track) {
const list = readTracked()
if (!list.includes(did)) { list.push(did); writeTracked(list) }
}
// `rev` is the front's head where there is one; a hosted DID has no front and reports null, exactly
// as the local branch did before.
return { did, indexed: r && r.indexed != null ? r.indexed : null, rev: (r && r.rev) || null, local: !!(r && r.hosted) }
},
async trackedDids () {
const tracked = readTracked()
// Hydrate what we know, so the caller renders names rather than DIDs. A tracked DID with no profile
// yet is normal (indexed but no app.bsky.actor.profile record) and must not read as an error.
const out = []
for (const did of tracked) {
// GET /api/profile resolves the handle too, raced against a deadline — so a row is a render rather
// than a DID the UI then has to look up. `held:false` (we hold no profile record) reads the same as
// a failed request here, which is correct: both mean "no name to show", and neither is an error.
let p = null
try { p = await (await api()).profile({ did }) } catch {}
out.push({ did, handle: (p && p.handle) || null, displayName: (p && p.displayName) || null, avatar: (p && p.avatar) || null })
}
return { dids: out }
},
async untrackDid ({ did } = {}) {
writeTracked(readTracked().filter((d) => d !== did))
return { did, tracked: false }
},
// People, hydrated. `follows`/`followers` returned bare DID strings, which a UI can only render as
// hex — the reason there is no way to browse anyone. Same queries, with the profile attached.
// ── identities ────────────────────────────────────────────────────────────────────────────────────
// Every DID this node hosts, not just the first. The daemon has always been multi-DID — a user's own
// identity plus each co-owned community (loser-club, ashatron's) — but the UI resolved everything
// through nodes[0], so the others were unreachable: no feed, no switcher, and `thread`/`search`/`blob`
// silently meant "DID #0". A client that hosts four identities and can address one is not multi-DID.
//
// `handle` comes from the CONFIG, not the node: bootSwarmDevice does not retain it. `signing` reports
// whether this node can actually author for that DID, which is the difference between an identity you
// can post as and one you can only read — and the UI must not offer a compose box for the latter.
async hostedDids () {
const nodes = (state.hpp && state.hpp.nodes) || []
const byDid = new Map((state.cfgDids || []).map((d) => [d.did, d]))
return {
primary: nodes.length ? nodes[0].did : null,
dids: nodes.map((n, i) => {
const cfg = byDid.get(n.did) || {}
return {
did: n.did,
handle: cfg.handle || null,
primary: i === 0,
// writable = the StateBase admitted us; without it this identity reads but cannot author.
signing: !!(n.sb && n.sb.base && n.sb.base.writable)
}
})
}
},
// ── media ─────────────────────────────────────────────────────────────────────────────────────────
// Blob bytes for the UI. The UI cannot reach the store itself — it lives across the worklet boundary,
// and there is no local HTTP origin on a phone the way favela-web has one — so bytes cross the same
// line-delimited JSON wire as everything else, base64'd, and the component renders a data: URI.
//
// Resolution order matches favela-web's getBlob exactly (lib/blob-serve.js + lib/render.js are
// imported, not re-implemented, so a ref form that works on the web works here):
// 1. the SHARED content-addressed overlay file — <overlay>/<roomKey16(did)>/blobs/<cid>. This is the
// one copy hpp already wrote, and it covers ANY did whose bytes this node holds, not just ours.
// 2. this node's own blob store — pointers + the legacy dual-read, for our own pre-migration blobs.
//
// `ref` is passed through verbatim from the record so the tolerant parsing ({$link} / {'/'} / CID /
// string) has ONE definition, in favela-web, rather than a second one in a React component.
//
// SIZE-CAPPED. A base64 payload rides in a single JSON line, and a phone should not try to move a
// video that way — the cap fails loudly with the real size instead of stalling the channel. Video
// needs a streaming transport (and range support) and is deliberately not solved here.
async blob ({ did = null, ref = null, cid = null, maxBytes = 8 * 1024 * 1024 } = {}) {
const { blobCid } = await import('favela-web/lib/render.js')
const { blobPath, sniffMime, isSafeCid } = await import('favela-web/lib/blob-serve.js')
const id = cid || blobCid(ref)
if (!id || !isSafeCid(id)) throw new Error('blob requires { cid } or a record { ref }')
const owner = did || viewerDid()
if (!owner) throw new Error('blob requires { did } (no local identity to default to)')
let bytes = null
try { bytes = fs.readFileSync(blobPath(dirs.overlay, owner, id)) } catch { /* fall through to the store */ }
if (!bytes) {
const node = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
const store = node && node.blobs
if (store) { try { const got = await store.get(String(id)); if (got && got.bytes) bytes = got.bytes } catch { /* not ours */ } }
}
// A MISSING blob is a normal answer, not an error: the pointer replicates before the bytes do, and
// some blobs are genuinely lost (the dual-read migration dropped wj62's avatar). The UI shows a
// placeholder for `found:false` and an error only for a real failure — those must not look alike.
if (!bytes) return { found: false, cid: id }
if (bytes.length > maxBytes) return { found: false, cid: id, tooLarge: true, size: bytes.length }
return { found: true, cid: id, size: bytes.length, mimeType: sniffMime(bytes), base64: b4a.toString(bytes, 'base64') }
},
async collections () {
// The #33 ledger — useful on a phone for the same reason it is on a server: an unclassified lexicon
// arriving should be visible, not silent.
// favela-web already exposes this on its control surface (`svc.handle('collections')`) — the ledger
// is a property of the index, and the index is favela-web's. Over its UDS rather than HTTP because
// that is where the verb lives; a diagnostic does not deserve a new public route.
//
// ⚠ THE SHAPE CHANGED, and nothing consumes it: this returned `{ ledger: [...] }` from the worklet's
// own index; it now passes favela-web's payload through — `{ ok, tracked, ignored, unknown }`, which
// is strictly more (it says which lexicons are deliberately ignored and whether we have ever seen
// one, not just which are unclassified). No screen reads this verb today; a future one should read
// `unknown`.
const { Client } = await import('hyper-svc')
const sock = process.env.FAVELA_SOCK
if (!sock) throw new Error('favela-web control socket unknown')
const c = await Client.connectUds(sock)
try { return await c.call('collections', {}) } finally { try { await c.close() } catch {} }
},
// ── the ported read surface ────────────────────────────────────────────────────────────────────────
// favela-web's pages, as ops. Each is a thin wrapper on the appview the phone ALREADY runs — the index
// and every query are shared verbatim, so a screen here answers the same question the web page does
// from the same code. `viewerDid` is this node's own DID so viewer state (liked/reposted/muted) is
// hydrated before anything crosses IPC, which is what keeps a row a render rather than a lookup.
async compose ({ text, replyTo = null, quoteOf = null, lang = null, scope = 'public' } = {}) {
const w = await writer()
const langs = /^[a-z]{2}(-[A-Za-z0-9]{2,8})?$/.test(String(lang || '')) ? [String(lang)] : ['en']
const r = await w.post(String(text || ''), { scope, langs, reply: replyTo, quote: quoteOf })
const uri = 'at://' + viewerDid() + '/' + r.collection + '/' + r.rkey
// No optimistic index write. Every screen now reads favela-web's index, so writing into the
// worklet's own appview updated something nobody reads — a no-op that still looked like it worked.
// favela-web picks the record up itself: it holds hpp's overlay (ATPROTO_OVERLAY_ROOT) and follows
// the DID's firehose, and it indexes a local write promptly — observed on tabertron, where a post
// appeared in /api/timeline immediately after the write and left it immediately on delete.
return { ...r, uri }
},
async deletePost ({ rkey, collection = 'app.bsky.feed.post' } = {}) {
if (!rkey) throw new Error('deletePost requires { rkey }')
const w = await writer()
await w.putRecord(collection, rkey, null, { action: 'delete' })
// Same as compose: the optimistic delete updated an index no screen reads. favela-web drops it
// from its own on the firehose round-trip.
return { ok: true, rkey, collection }
},
// Follow is a TOGGLE and needs no strongRef — its subject is a plain DID, unlike a like or a repost
// whose subject is a strongRef (uri AND cid) and must fail closed when the bytes are not held.
async follow ({ did } = {}) {
if (!did || !String(did).startsWith('did:')) throw new Error('follow requires { did }')
const me = viewerDid()
// ⛔ REFUSE HERE, NOT DOWNSTREAM. Every write op on a node with no identity must say the same thing —
// "pair it" — rather than surfacing whatever the layer below happened to notice first. Delegating
// straight to favela-web returned its 403 "this instance is read-only", which is true of favela-web
// and useless to someone holding an unpaired phone. Same wording as writer(), deliberately.
if (!me) throw new Error('this node has no identity yet — pair it before writing')
if (did === me) throw new Error('cannot follow yourself')
// ⛔ THE TOGGLE IS NOT OURS TO REIMPLEMENT. This read the existing rkey out of the worklet's index,
// asked hpp to write, then wrote back into that same index — a second follow implementation racing
// favela-web's, with its own idea of what "already following" means. /api/follow does all three
// steps against the one index, and its writer.state() check refuses a write whose signed-in viewer
// is not the DID this hpp signs for — a guard the local path never had.
const r = await (await api()).follow({ did })
return { following: !!(r && r.following) }
},
// invite — mint an adopt invite for ANOTHER device. Symmetric by design: keet's `addDevice` is
// available to every active member and confers nothing on the adder, and coownerInvite no longer
// claims the genesis, so a phone can pair the next device exactly as a desktop can.
//
// ⚠ APPROVAL IS THE BUTTON PRESS. mintAdoptInvite takes an `approve` callback because the bundle
// carries the shared signing key and coownerInvite MUTATES the config — so it must never fire
// speculatively. Here the user has already tapped "invite a device" behind a screen that says what it
// grants, which is the same posture favela-web's `pair` command takes when it auto-delegates.
async invite () {
const me = viewerDid()
if (!me) throw new Error('this node has no identity yet — pair it before inviting')
const { mintAdoptInvite } = await import('./adopt.js')
const src = await mintAdoptInvite({ did: me, approve: () => true, configFile: process.env.PEER_CONFIG })
// The tunnel stays open for ONE pairing session. Await the peer in the background so the op can
// return the code immediately — the UI shows progress from these log lines.
src.waitForPeer()
.then((r) => log(r ? 'invite: device joined' : 'invite: refused', r ? { devicePublicKey: r.devicePublicKey } : {}))
.catch((e) => log('invite: failed', { error: (e && e.message) || String(e) }))
.then(() => { try { src.close() } catch {} })
return { inviteHex: src.inviteHex, did: me }
},
async devices () {
const w = await writer()
return { devices: await w.listDevices() }
},
async revokeDevice ({ subKey, restore = false } = {}) {
if (!subKey) throw new Error('revokeDevice requires { subKey }')
const w = await writer()
await w.revokeDevice(subKey, { restore })
return { ok: true, subKey, restored: !!restore }
}
}
// This node's own DID — the first hosted one. Used as viewerDid for every query so viewer state is
// hydrated server-side (well, worklet-side), and as the author of every write.
// The DID a read is answered AS. Defaults to this node's own (the first hosted DID), but every read op
// accepts `viewer` so a client acting as one of the OTHER identities this node hosts — a co-owned
// community — gets ITS viewer state (liked/reposted/muted) rather than the primary's.
//
// VALIDATED against the hosted set, never trusted: an arbitrary DID from the UI would silently hydrate
// viewer state for an identity this node cannot act as, which reads as "my like vanished" rather than
// as the rejection it is.
function viewerDid (override = null) {
if (!state.hpp || !state.hpp.nodes || !state.hpp.nodes.length) return null
if (override) {
const hit = state.hpp.nodes.find((n) => n.did === override)
if (!hit) throw new Error('this node does not host ' + override)
return hit.did
}
return state.hpp.nodes[0].did
}
// A TID rkey. Same generator the rest of the stack uses, imported lazily so a read-only node (no hpp)
// never pulls it in.
let _tid = null
function tid () {
if (!_tid) throw new Error('tid unavailable — node not booted')
return _tid()
}
// favela-web's Writer, wired to the IN-PROCESS device instead of a UDS socket. Built once, lazily: a
// node with no DID has nothing to write with, and asking for one should say so rather than crash a read.
let _writer = null
async function writer () {
if (_writer) return _writer
const node = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
if (!node) throw new Error('this node has no identity yet — pair it before writing')
const { Writer } = await import('favela-web/lib/writer.js')
if (!_tid) _tid = (await import('atproto-store/lib/tid.js')).tidNow
_writer = new Writer({ device: node.device, blobs: node.blobs })
return _writer
}
async function onRequest (req) {
const { id, op, params } = req
if (!state.ready && op !== 'status') return send({ id, error: 'not ready' })
const fn = handlers[op]
if (!fn) return send({ id, error: 'unknown op: ' + op })
try {
send({ id, result: await fn(params || {}) })
} catch (e) {
send({ id, error: (e && e.message) || String(e) })
}
}
// ⛔ `IPC.on('data')`, NOT `IPC.read(cb)`. BareKit.IPC is a streamx Duplex: `read()` takes no callback
// and returns the next chunk (or null) synchronously. The previous pump passed a callback that streamx
// simply ignored, so it fired once, read nothing, and never re-armed — the worklet consumed ZERO inbound
// bytes for its whole life. It still booted and still sent its own log lines, so it looked alive while
// every request from the UI went unanswered and every screen hung after `ready`.
//
// The decoder is stateful for the same reason it is on the RN side: chunk boundaries are not character
// boundaries (lib/utf8.js).
if (IPC) {
let buf = ''
const decode = createUtf8Decoder()
IPC.on('data', (data) => {
buf += decode(data)
let i
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1)
if (!line.trim()) continue
try { onRequest(JSON.parse(line)) } catch { send({ error: 'bad request json' }) }
}
})
}
boot().catch((e) => send({ t: 'fatal', error: (e && e.stack) || String(e) }))