// FavelaNode — one process, many feeds, ONE of everything underneath.
//
// The shape is not a convenience. Three separate lessons land on it:
//
// ⛔ Two Autobees on one Corestore need SEPARATE NAMESPACES or the second derives the same local
// writer as the first and the pair DEADLOCKS — it does not error, it hangs (measured
// 2026-09-06, replicate.test.js).
// ★ keet carries many rooms on ONE corestore and ONE swarm, per-room autobase keyed by discovery
// key. That topology is proven, not hypothetical.
// ⚠ hyper-proto-peer learned the same thing the expensive way: one shared store + one swarm +
// one overlay per DID, namespaced.
//
// So: one storage dir, one Corestore, one HyperOverlay, one swarm — and every feed is a namespace.
import Corestore from 'corestore'
import { HyperOverlay } from 'hyper-overlay'
import b4a from 'b4a'
import fs from '#node:fs'
import { join } from '#node:path'
import { Feed } from './feed-base.js'
import { RecordStore } from './record-store.js'
import { Peer } from './peer.js'
import { loadCredential, saveCredential, bindWriter, loadFeedKey, saveFeedKey } from './device.js'
import { Browsers } from './browsers.js'
import { Pairing } from './pairing.js'
import { Avatars } from './avatars.js'
import { Files } from './files.js'
import { Namespace } from './namespace.js'
import { loadNamespace, saveNamespace, namespaceSlot, slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms } from './device.js'
import { Room } from './room.js'
import { readAttested } from './attestation.js'
import { createMirror } from './mirror.js'
import { connectBridge, buildClaim, observers, acceptObserver, claimTargets } from './bridge.js'
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
/**
* May this node adopt `incomingFeed` as its own?
*
* ⛔ Pairing happens AT INIT — a phone joining an identity it has never written under — so adopting
* is the normal path and this guard is for the case that is not init.
*
* ⚠ It counts RECORDS, not founders. An unlinked node has an empty, unfounded feed (opening a log
* is not founding it), so a "no founder" test would refuse every legitimate pairing; counting
* records refuses exactly the case that would lose something.
*
* Pure, so it can be tested without a swarm.
*/
export function adoptionRefusal (recordCount, incomingFeed, ownFeed) {
if (!incomingFeed || incomingFeed === ownFeed) return null // nothing to adopt
if (recordCount > 0) {
return 'this node has already written ' + recordCount + ' record(s) to its own feed, and ' +
'joining another would abandon them. Pair a fresh node, or move those records first.'
}
return null
}
/**
* Does the feed a claim points at AGREE that the claim's identity owns it?
*
* Pure, so it can be tested without a node, a store or a swarm — the same reason `adoptionRefusal`
* above is pure. Everything that decides anything lives here; `resolveHandle` is only the lookup
* that finds the two inputs.
*
* confirmed the feed's founder IS the claim's anchor
* MISMATCH the feed is founded by someone else — the claim is a lie, refuse it
* unfounded the feed is held but nobody founded it, so it names nobody yet
*/
export function handleVerdict (claim, founder) {
if (!founder || !founder.identityRoot) return { ...claim, verdict: 'unfounded' }
// ⛔ Compared as hex STRINGS, both lower-cased at their source — apply stores `proof.root` from
// `attestation.js`, which returns hex, and `__founder__.identityRoot` comes from the same
// function. A Buffer compared by identity here would pass nothing and reject everything.
if (founder.identityRoot !== claim.root) {
return { ...claim, verdict: 'MISMATCH', founder: founder.identityRoot }
}
return { ...claim, verdict: 'confirmed', founder: founder.identityRoot }
}
export class FavelaNode {
constructor (storage, opts = {}) {
if (typeof storage !== 'string') throw new Error('FavelaNode: a storage directory is required')
this.storage = storage
this.corestore = new Corestore(join(storage, 'store'))
this.overlay = null
this._injectedOverlay = opts.overlay || null
this.swarm = opts.swarm || null
this.transport = opts.transport || null
this.peer = null
this._feeds = new Map() // hex feed key -> Feed
// Feeds being opened right now. ⛔ Keyed so a reader pressing twice, or a page polling, cannot
// stack duplicate opens of the same feed.
this._opening = new Map()
// hex room key -> Room. ⛔ One Corestore namespace EACH: two Autobees sharing one derive the
// same local writer and hang rather than erroring, and a node is expected to hold many rooms.
this._rooms = new Map()
this._optimistic = opts.optimistic !== false // the doorbell is on by default
// Browser identities. Its own Autobee on its own Corestore namespace — see browsers.js.
this.browsers = null
this.avatars = null
this.names = null
this._invitedWatch = null
this._answering = false
// At most one pairing offer in flight. A second would mint a second credential and leave the
// first dangling — an invite nobody can account for.
this._pairing = null
this._pairingState = null
}
/**
* Rebuild the overlay's serve map from what this node already indexed.
*
* ⛔ hyper-overlay resolves an incoming fetch-by-oid through IN-MEMORY maps that only
* `registerFile()` fills, and nothing rebuilds them at boot. Without this a restarted node serves
* ONLY what it has written since it started — every other file sits on disk, indexed, with a
* matching hash, and unreachable to peers. That cost 75+ silent hours on wj62; 0.3.9 added the
* seed and the CONSUMER has to call it. This one never did.
*
* MEASURED 2026-09-07: tabertron held 9 pointers from ashatron and could read 0, stable, while
* ashatron read its own posts fine off local disk — its daemon had been restarted minutes before.
*
* ⚠ Cheap by design: one index scan, no re-hashing and no file reads. `reindexOverlay()` is the
* heavy repair for an index that is itself stale, and is deliberately not on the boot path.
*/
async _seedOverlay () {
if (!this.overlay || typeof this.overlay.seedFilePathsFromIndex !== 'function') return 0
try {
return await this.overlay.seedFilePathsFromIndex((p) => this._resolveOverlayPath(p))
} catch {
// A serve map that could not be rebuilt is a degraded node, not a dead one: it still reads,
// writes and replicates logs. Refusing to boot over it would be worse.
return 0
}
}
/**
* An overlay path back to a file under this node's storage, or null.
*
* ⛔ The index is DATA ON DISK. An entry carrying a traversing path must not be able to point the
* serve map outside this storage root — that map is what gets handed to peers.
*/
_resolveOverlayPath (p) {
if (typeof p !== 'string' || !p.startsWith('/')) return null
const abs = join(this.storage, p)
const root = join(this.storage, '/')
if (!abs.startsWith(root)) return null
// ⚠ And it must EXIST. Entries written under the older overlay-path shape resolve to files that
// were never there, and seeding the map with those would replace "I cannot serve this" with "I
// will offer you a file I do not have" — a worse answer, and one the requester only discovers
// by timing out. One stat per index entry; the scan is already reading them.
try { return fs.existsSync(abs) ? abs : null } catch { return null }
}
// The records directory for one feed. Kept per-feed so a feed's bytes are never confused with
// another's, while the OVERLAY is shared — dedup and serving are node-wide, ownership is not.
_recordsDir (name) { return join(this.storage, 'records', name) }
async ready () {
fs.mkdirSync(this.storage, { recursive: true })
this.overlay = this._injectedOverlay || new HyperOverlay(this.corestore.namespace('overlay'), {
namespace: 'favela-records',
destDir: join(this.storage, '.fetch')
})
await this.overlay.ready()
await this._seedOverlay()
// My own feed. Opened first and by a stable namespace so it survives a restart.
//
// ⛔ With a stored feed key this node JOINS that feed instead of founding one — it is somebody's
// second device. Without one it founds its own, which is the first device of an identity. The
// key is written only by pairing, so the two cases never have to be guessed at.
const joining = loadFeedKey(this.storage)
const mine = await this._open('self', joining ? b4a.from(joining, 'hex') : null)
// ⛔ The daemon does NOT touch the vault at boot. It looks for a device credential — a proof
// for this feed's writer key — and if there is none it starts anyway, unadmitted and not
// writable, so that `favela link` has something to talk to. A daemon that crashed without a
// credential would leave no way to give it one.
// The credential is a DEVICE keypair; the writer core key is bound to it here, at boot, because
// the writer key is only known once the feed is open.
const cred = loadCredential(this.storage)
if (cred) {
await mine.addDevice(bindWriter(cred, mine.deviceKey))
await mine.update()
}
// ⛔ Kept, because writing to the feed is not the only thing this device's identity is for: a
// name CLAIM is signed by the same credential, in a different log. Null on an unlinked node,
// and `claim` refuses loudly rather than appending something every peer would silently drop.
this.cred = cred
this.peer = await new Peer(mine).ready()
// How this node proves who is knocking: a proof binding a writer core to our identity root.
// Reads `this.cred` LIVE, so linking later (`link`) is picked up without rewiring; unlinked, it
// refuses rather than letting a knock go out unproven (feed.js applyKnock would drop it anyway).
this.peer.bind = (writerKeyHex) => {
if (!this.cred) throw new Error('this device is not linked, so it cannot prove who is knocking — run `favela link`')
return bindWriter(this.cred, writerKeyHex)
}
this.browsers = await new Browsers(this.corestore).ready()
this.avatars = new Avatars(this.storage, this.overlay)
// Files embedded in records — the same overlay, a different directory and a wider type table.
this.files = new Files(this.storage, this.overlay)
// The namespace this node resolves handles in.
//
// ⛔ The key is CONFIGURATION, stored on disk and replaceable. NAMING.md §8 #2: a default that
// cannot be changed IS a root. Generating one on first boot makes this node the origin of ITS
// default and nobody else's — several namespaces coexist, and joining a different one is a
// runtime act (`joinNamespace`) rather than a file edit and a restart.
//
// ⚠ So "global" here means "the one you happen to have joined". Two nodes that never exchange
// this key resolve handles in different worlds, correctly.
await this._openNames(loadNamespace(this.storage))
// ⛔ Founded HERE, at creation, because the op is refused once an admin exists and the only
// thing closing that race is nobody else holding the key yet. An unlinked node cannot sign, so
// it founds nothing and `link()` does it instead — a daemon must still start without a
// credential or `favela link` would have nothing to talk to.
if (cred) await this._foundNames(cred)
// Re-open the rooms this node is in, for the same reason as the feeds below: a restart that
// forgot them would look exactly like having been removed from every one of them.
for (const r of loadRooms(this.storage)) {
try { await this._openRoom(r.key, r.slot) } catch {}
}
// ⛔ HOLD THE BYTES OF FEEDS WE FOLLOW, before anyone asks to read them.
//
// MEASURED 2026-09-08: this node held five pointers from ashatron and could read none of them.
// ashatron is a laptop, it went offline, and it was the only machine with the bytes — because a
// record's bytes are fetched only when a reader asks, and nobody had. All five resolved the
// moment it came back.
//
// Following is the decision to KEEP someone's bytes; peeking is not, which is why this reads
// `following()` rather than every readable feed. `peer.timeline` draws the same line for the
// same reason. See hyper-social/docs/HOLDING-AND-TELLING.md.
this._mirror = createMirror({
// ⛔ LEVEL 2. A record's bytes are one oid in the log; the files it NAMES are more oids inside
// its markdown, in the SAME overlay. Without this a mirrored post reads fine and its picture
// will not load — the outage this exists for, one level down.
files: this.files,
feeds: () => {
const out = []
for (const [name, feed] of this.peer.feeds) {
if (this.peer.peeked.has(name) || this.peer._blocked.has(name)) continue
out.push({ key: name, feed })
}
return out
},
// ⚠ No logger on this class, and a mirror pass must not invent one. Failures surface where
// they already do: a record whose bytes did not arrive stays UNRESOLVED and the surface says so.
})
// ⛔ CREATED BEFORE THE FEEDS ARE HELD, so `hold()` can attach a watcher to each as it opens.
// Re-open the feeds we already follow, so a restart resumes rather than forgetting.
for (const name of await this.peer.following()) {
try { await this.hold(name) } catch {}
}
// ⛔ AND THE OBSERVATION FEEDS BRIDGES VOUCHED FOR (hyper-social #82, I1): what the outside world did to this
// user, written by a bridge and held here like any feed — read only as notifications, only while its founder is
// the pinned root. Then, NOT awaited: any bridge remembered from before grants existed is asked once, by a
// re-claim of the same name (idempotent at the bridge), so an existing registration picks up its feed with
// nobody pressing anything.
for (const o of observers(this.storage)) {
try { await this.holdObserver(o) } catch {}
}
this._refreshObserverGrants().catch(() => {})
// ⚠ NOT awaited, and NOT sufficient on its own. A first sweep must not sit inside boot. And an
// end-to-end run on two machines showed the boot pass alone finds nothing: at boot a followed
// feed's log has usually not replicated yet, so the real work is done by the 'update' watcher
// attached in hold().
this._mirror.pass().catch(() => {})
// ⛔ AND A FLOOR UNDER IT. 0.0.40's trigger subscribed to an autobee event that only fires on
// an interrupt, so it never fired at all — and nothing said so. A slow local sweep cannot be
// wrong in that way: it reads a bee and stats files, and touches the network only when a record
// is genuinely missing.
// ⛔ A PEER ARRIVING IS AN EVENT. A record is unresolved because nobody holding its bytes is
// online; their return is a swarm 'connection', not an absence, so it must not be discovered by
// a clock. This is the trigger the timer was standing in for.
if (this.swarm && typeof this.swarm.on === 'function') {
this._onMirrorPeer = () => this._mirror.onPeer()
this.swarm.on('connection', this._onMirrorPeer)
}
return this
}
get key () { return this.peer.feed.key }
/** Hold a bridge's observation feed, pinned to the founder root the bridge vouched for. */
async holdObserver (o) {
if (!o || !o.observationFeed || !o.observerRoot) return null
this.peer.observers.set(String(o.observationFeed).toLowerCase(), String(o.observerRoot).toLowerCase())
return this.hold(o.observationFeed)
}
/**
* Ask each remembered bridge with no grant yet for this node's observation feed, by re-claiming the same name.
*
* ⚠ A re-claim of the SAME name, root and feed is idempotent at the bridge (it answers with the grant). It needs
* this device's credential to sign, and a reachable bridge; either missing just means "next boot".
*/
async _refreshObserverGrants () {
if (!this.cred || !this.swarm) return
// This device's bridges.json AND the bridge the identity's reach names — so a device that never claimed anything
// itself still asks the bridge its identity is registered on (claimTargets).
let reach = {}
try { reach = await this.peer.reach() } catch {}
for (const [acct, bridgeKey] of claimTargets(this.storage, reach)) {
const name = String(acct).split('@')[0]
if (!name) continue
try {
const { rpc } = await connectBridge(this.swarm, bridgeKey)
const { nonce } = await rpc.request('challenge', {})
const r = await rpc.request('claim', buildClaim({ nonce, username: name, feedKey: this.key.toString('hex'), root: this.rootHex(), cred: this.cred }))
if (r && (r.status === 200 || r.status === 202)) {
const o = acceptObserver(this.storage, bridgeKey, r.body)
if (o) await this.holdObserver(o)
}
} catch { /* unreachable or refused: asked again on the next boot */ }
}
}
/**
* Announce everything `ready()` opened.
*
* ⛔ `ready()` builds the overlay, and the transport needs the overlay — so the transport does not
* exist while ready() is opening things, and every `transport.join` inside it is a no-op. Three
* separate things have now had to be re-joined by hand afterwards (the feeds, the namespace, and
* rooms) and each was forgotten in turn, each time producing a log that replicates for nobody.
*
* One method, so the next thing ready() opens is announced by construction rather than by
* somebody remembering.
*/
async announceAll () {
if (!this.transport) return 0
let n = 0
const join = async (base) => { if (base) { try { await this.transport.join(base); n++ } catch {} } }
await join(this.peer && this.peer.feed)
await join(this.names)
for (const [, feed] of this._feeds) await join(feed)
for (const [, room] of this._rooms) await join(room)
return n
}
/**
* Open a namespace into `this.names` and announce it.
*
* ⚠ `stored` is `{ key, slot }` or null. A null mints one, in the legacy `names` slot — the key
* cannot name its own slot because it does not exist until the log is open.
*/
async _openNames (stored) {
this.names = await new Namespace(
this.corestore.namespace(stored ? stored.slot : 'names'),
stored ? b4a.from(stored.key, 'hex') : null
).ready()
if (!stored) saveNamespace(this.storage, { key: hex(this.names.key), slot: 'names' })
// ⛔ Announced, or a shared registry is three private ones. This is a no-op at boot — the
// transport is built AFTER ready() so the overlay exists first — which is why `index.js` joins
// it explicitly, exactly as it already does for the feeds.
if (this.transport) { try { await this.transport.join(this.names) } catch {} }
this._invitedWatch = null
this._watchInvited()
}
/**
* Answer vouched-for asks as they arrive, without a human.
*
* ⛔ Re-entrancy guarded: answering APPENDS, which fires `update` again. Without the flag the
* first invited request would recurse until the stack gave out.
*/
_watchInvited () {
if (!this.names || this._invitedWatch) return
this._invitedWatch = async () => {
if (this._answering || !this.cred || !this.names) return
this._answering = true
try { await this.names.answerInvited(this.cred) } catch {} finally { this._answering = false }
}
this.names.base.on('update', this._invitedWatch)
}
/** Found this namespace if it is ours to found. Idempotent: apply refuses a second founding. */
async _foundNames (cred) {
if (!this.names || !cred) return
// ⛔ ONLY FOUND A NAMESPACE THIS NODE CREATED. A joined one — now the default for a fresh
// install — has someone else as its genesis writer, so `found()` fails with "Not writable" from
// autobee's BACKGROUND drain, which the try/catch below cannot catch: the rejection never
// reaches the awaited path, it surfaces as an uncaught error and takes `ready()` down with it.
//
// ⚠ The existing `founder()` check is not enough on its own. A node that has just joined has
// replicated nothing yet, so it sees no founder and concludes the namespace needs founding —
// which is exactly the case that throws.
if (!this.names.base || !this.names.base.writable) return
await this.names.update()
if (await this.names.founder()) return
try { await this.names.found(cred) } catch { return }
await this.names.update()
}
/**
* Resolve handles in a different namespace from now on.
*
* ⛔ The one we leave stays on disk, in its own Corestore slot, and coming back finds it exactly
* as it was. Joining somebody else's registry must never be a one-way door that destroys the one
* you made.
*/
async joinNamespace (keyHex) {
if (typeof keyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(keyHex)) {
throw new Error('a namespace key is 32 bytes of hex — ' + String(keyHex).slice(0, 16))
}
const key = keyHex.toLowerCase()
if (this.names && hex(this.names.key) === key) return { key, joined: false }
const current = loadNamespace(this.storage)
const known = loadNamespaces(this.storage)
// ⚠ Back to one we already hold: reuse ITS slot. Opening the same log in a second slot would
// give this node two writer cores in one namespace — not fatal, and permanently confusing.
//
// ⛔ AND THE INDEX IS CONSULTED, not just the current one. The namespace this node FOUNDED lives
// in the legacy slot `names`, which `namespaceSlot(key)` cannot produce — so leaving it and
// coming back later computed `names:<key>`, opened a SECOND log, and left every handle claimed
// there resolving from a slot this node was no longer using. The claims were never lost; they
// were simply somewhere else, which is worse because it looks like loss.
const slot = slotFor(key, { current, known })
if (this.names) {
if (this.transport) { try { await this.transport.leave(this.names) } catch {} }
await this.names.close()
this.names = null
}
await this._openNames({ key, slot })
saveNamespace(this.storage, { key, slot })
// Remembered so it can be returned to by name rather than by a key someone has to have kept.
saveNamespaces(this.storage, [...known.filter((n) => n.key !== key), { key, slot }])
return { key, joined: true }
}
async _open (namespace, key, recordsName = namespace, bootHead = null) {
const feed = new Feed(this.corestore.namespace(namespace), key, {
// `base` is what makes an overlay path resolvable back to a file at boot — see
// `_seedOverlay` and `RecordStore._overlayPath`.
store: new RecordStore(this._recordsDir(recordsName), { overlay: this.overlay, base: this.storage }),
optimistic: this._optimistic,
bootHead // see Feed's constructor: without one, fast-forward is off rather than stalling
})
await feed.ready()
// ⛔ Guarded, like every other transport call in this file. It was the one that was not, so a
// swarm that refused a join threw AFTER the feed was open — leaving it draining, untracked and
// never closed, which then blew up as "RocksDB session is closed" when the store shut down.
//
// ⚠ A feed with no join is still a usable feed. It simply does not replicate, which is a
// degraded state and not a failure to open.
if (this.transport) { try { await this.transport.join(feed) } catch {} }
// ⛔ Repair what the boot seed could not: entries written under the older overlay-path shape
// resolve to nothing and stay unservable for ever. ⚠ NOT awaited — it hashes every record file,
// and making a feed open wait on that is the cold-start cost the overlay keeps off its own boot
// path for the same reason.
if (feed.store && typeof feed.store.reregisterLocal === 'function') {
feed.store.reregisterLocal().catch(() => {})
}
return feed
}
// Hold someone else's feed: open it read-only under its own namespace and hand it to the Peer.
/**
* Resolve a handle all the way — name → identity root → feed — and say how far it got.
*
* ⛔ NAMING.md §3 resolves a name to an identity ROOT and only then to a room key, and the two
* halves live in different places on purpose. `Namespace.resolve` proves WHO made the claim, from
* the claim's own bytes: apply can do that with no state at all. What it cannot do is prove that
* identity owns the feed the claim points at — the namespace is a separate log with no access to
* a feed's `__founder__` — so a stranger may still aim a name at someone else's feed under their
* OWN root. That lie is only detectable HERE, where the feed is actually held.
*
* Four verdicts, and the difference between the middle two is the whole point:
*
* confirmed the feed's founder IS the claim's anchor — the handle means what it says
* MISMATCH the feed is founded by someone else. Refuse it. This is the attack.
* unheld we do not hold the feed, so there is nothing to check against yet
* unfounded the feed is held but has no founder — an empty feed names nobody
*
* ⚠ `unheld` deliberately does NOT go and fetch the feed. Holding someone's feed is a decision
* with storage and replication behind it, and making a name lookup take it silently would turn
* reading a byline into following a stranger. NAMING.md §3.2: fail visibly, never guess.
*/
/**
* A bare NAME to the one person who holds it, or null.
*
* ⛔ Only when there is exactly ONE confirmed claimant. NAMING.md §3.2: an unresolvable name must
* fail visibly rather than have a namespace guessed for it, and "the first alice" is that guess
* wearing a helpful face. Two alices means `@alice` names nobody, and it stays plain text.
*/
async resolveName (name) {
await this.names.update()
let candidates = []
try { candidates = await this.names.candidates(name) } catch { return null }
const confirmed = []
for (const c of candidates) {
const judged = await this._judge(c)
if (judged.verdict === 'confirmed') confirmed.push(judged)
}
return confirmed.length === 1 ? confirmed[0] : null
}
/** A handle OR a bare name — what a person actually types after an @. */
async resolveMention (token) {
return (await this.resolveHandle(token)) || this.resolveName(token)
}
async resolveHandle (handle) {
await this.names.update()
const claim = await this.names.resolve(handle)
if (!claim) return null
return this._judge(claim)
}
/**
* Every handle in the namespace, each carrying its verdict. `query` narrows it to a search.
*
* ⛔ The verdict is attached HERE rather than by each caller, because every caller that forgets
* it renders a stranger's claim as somebody's name. There is exactly one place that decides.
*/
async handles (query = null) {
await this.names.update()
const claims = query ? await this.names.search(query) : await this.names.list()
const out = []
for (const c of claims) out.push(await this._judge(c))
return out
}
/**
* Confirmed handles only, as `feed -> handle`. What a byline may safely use.
*
* ⛔ A claim points at a feed; it does not prove the feed agrees. So an UNCONFIRMED claim must
* never reach a byline — rendering one would let anyone rename anyone by claiming a name at their
* feed key, which is the whole attack, arriving through the display layer instead of the log.
*
* ⚠ `unheld` is excluded too, not just MISMATCH. We cannot check a feed we do not hold, and
* "unchecked" is not "fine" — that conflation is how a check becomes decorative.
*/
async confirmedHandles () {
const out = {}
for (const c of await this.handles()) if (c.verdict === 'confirmed') out[c.feed] = c.handle
return out
}
/**
* Attach a verdict to one claim: does the feed it points at agree?
*
* ⚠ Does NOT fetch a feed it does not already hold. Holding someone's feed is a decision with
* storage and replication behind it, and making a name lookup take it silently would turn reading
* a byline into following a stranger. NAMING.md §3.2: fail visibly, never guess.
*/
async _judge (claim) {
const feed = this._feeds.get(claim.feed) ||
(claim.feed === hex(this.key) ? this.peer.feed : null)
if (!feed) return { ...claim, verdict: 'unheld' }
return handleVerdict(claim, await feed.founder())
}
// ── rooms ─────────────────────────────────────────────────────────────────────────────────────
async _openRoom (keyHex, slot) {
const key = String(keyHex).toLowerCase()
if (this._rooms.has(key)) return this._rooms.get(key)
const room = await new Room(this.corestore.namespace(slot), b4a.from(key, 'hex')).ready()
this._rooms.set(key, room)
// ⛔ Announced, or it is a private log wearing the name of a shared one — the exact defect the
// namespace had until today.
if (this.transport) { try { await this.transport.join(room) } catch {} }
return room
}
_persistRooms () {
saveRooms(this.storage, [...this._rooms.keys()].map((key) => ({ key, slot: 'room:' + key })))
}
/** Make a room. This node's identity founds it, so it is the room's first admin. */
async createRoom ({ initialMembers = [] } = {}) {
if (!this.cred) throw new Error('creating a room needs this device\'s credential — run `favela link`')
// ⚠ The key is not known until the log is open, so the slot cannot be named after it. A fresh
// slot per creation keeps each room's writer its own.
const slot = 'room:new:' + Date.now().toString(16) + Math.random().toString(16).slice(2, 8)
const room = await new Room(this.corestore.namespace(slot), null).ready()
await room.found(this.cred, { initialMembers })
await room.update()
const key = hex(room.key)
this._rooms.set(key, room)
if (this.transport) { try { await this.transport.join(room) } catch {} }
saveRooms(this.storage, [...loadRooms(this.storage).filter((r) => r.key !== key), { key, slot }])
return { key }
}
/**
* Join a room by key.
*
* ⚠ Joined is not admitted. Holding the key lets this node replicate and read; becoming a MEMBER
* is a decision somebody already inside makes, and it has to be, because a grant is capped at the
* weight of the block carrying it.
*/
async joinRoom (keyHex) {
if (typeof keyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(keyHex)) {
throw new Error('a room key is 32 bytes of hex')
}
const key = keyHex.toLowerCase()
if (this._rooms.has(key)) return { key, joined: false }
await this._openRoom(key, 'room:' + key)
saveRooms(this.storage, [...loadRooms(this.storage), { key, slot: 'room:' + key }])
return { key, joined: true }
}
/** Stop replicating a room. ⚠ It does not delete one anybody else is in, and it cannot. */
async leaveRoom (keyHex) {
const key = String(keyHex).toLowerCase()
const room = this._rooms.get(key)
if (!room) return { key, left: false }
if (this.transport) { try { await this.transport.leave(room) } catch {} }
await room.close()
this._rooms.delete(key)
saveRooms(this.storage, loadRooms(this.storage).filter((r) => r.key !== key))
return { key, left: true }
}
room (keyHex) { return this._rooms.get(String(keyHex).toLowerCase()) || null }
/** This node's identity root, or null when it cannot sign. What a room asks about membership. */
rootHex () {
if (!this.cred) return null
try { return readAttested(this.cred.proof, undefined).root } catch { return null }
}
async rooms () {
const out = []
for (const [key, room] of this._rooms) {
await room.update()
out.push({ key, config: await room.config(), members: (await room.members()).length })
}
return out
}
async hold (feedKey, bootHead = null) {
const id = hex(feedKey)
if (this._feeds.has(id)) return this._feeds.get(id)
const feed = await this._open('feed:' + id, b4a.from(id, 'hex'), id, bootHead)
this._feeds.set(id, feed)
// Mirror this feed's bytes as its log advances — the trigger the boot pass cannot be.
if (this._mirror) this._mirror.watch(feed.base)
this.peer.feeds.set(id, feed)
return feed
}
/**
* Hold a feed to READ it — no follow record, and nothing written to theirs.
*
* ⛔ An explicit act, never a side effect of a lookup. `resolveHandle` deliberately refuses to
* fetch a feed it does not hold, for exactly this reason: holding is a storage and replication
* commitment, and taking it silently would turn reading a byline into following a stranger.
*
* ⚠ It does not survive a restart. `ready()` re-opens only what `following()` names, so a peek
* lasts for the life of the daemon and then evaporates — which is the right default and costs no
* new state. The BYTES stay on disk until something collects them; that is a separate question.
*/
async peek (feedKey) {
const id = hex(feedKey)
if (this._feeds.has(id) || id === hex(this.key)) return { feed: id, status: 'held' }
if (!this._opening.has(id)) {
// ⛔ NOT awaited. Opening a feed we have never seen means an autobee on a remote key and a
// swarm join, and that is a DHT round trip — inside the request it is indistinguishable from
// a hung daemon. `pairOffer` learned the same thing and answers first too.
//
// ⚠ The failure path clears the entry. A peek that threw and stayed in this map would leave
// the page saying "fetching…" for ever with no way to retry.
const work = (async () => {
const feed = await this.hold(feedKey)
this.peer.peek(feedKey, feed)
})().catch(() => {}).finally(() => this._opening.delete(id))
this._opening.set(id, work)
}
return { feed: id, status: 'opening' }
}
/** The in-flight open for a feed, if there is one. Awaiting it is optional and never required. */
opening (feedKey) { return this._opening.get(hex(feedKey)) || Promise.resolve() }
/**
* What this node has of a feed — synchronous, because a page asks it on every render.
*
* ⛔ Four states, not two. "Not held" and "still arriving" look identical to a reader and are
* completely different facts: one means offer to look, the other means wait. Collapsing them is
* how a page ends up claiming somebody posted nothing when their feed simply has not landed.
*/
holding (feedKey) {
const id = hex(feedKey)
return {
held: this._feeds.has(id) || id === hex(this.key),
peeked: this.peer.peeked.has(id),
following: this.peer._follows ? this.peer._follows.has(id) : false,
opening: this._opening.has(id),
blocked: this.peer._blocked ? this.peer._blocked.has(id) : false
}
}
// Follow = record it AND hold it. The two are separable in Peer on purpose; here they travel
// together, because a node that recorded a follow and did not open the feed would silently show
// an empty timeline.
async follow (feedKey) {
const feed = await this.hold(feedKey)
await this.peer.follow(feedKey, feed)
await this.peer.update()
return hex(feedKey)
}
async unfollow (feedKey) {
const id = hex(feedKey)
await this.peer.unfollow(id)
const feed = this._feeds.get(id)
if (feed) {
if (this.transport) { try { await this.transport.leave(feed) } catch {} }
this._feeds.delete(id)
await feed.close()
}
await this.peer.update()
}
/**
* Link this device: record the proof and admit ourselves.
*
* Idempotent — admission is a no-op once the roster holds this device, and re-admitting after a
* revocation is refused by apply rather than here. The proof is stored first so a restart keeps
* the link even if the append does not land.
*/
async link (cred) {
saveCredential(this.storage, cred)
await this.peer.feed.addDevice(bindWriter(cred, this.peer.feed.deviceKey))
await this.peer.update()
// ⛔ Kept in step: a node that booted unlinked has an unfounded namespace, and this is the first
// moment it can sign anything. Leaving it admin-less would mean nobody could ever be admitted
// to carry it.
this.cred = cred
await this._foundNames(cred)
return { device: this.peer.feed.deviceKey, devices: await this.peer.feed.devices() }
}
/**
* Offer this device's credential to another machine.
*
* ⛔ Mints a NEW credential from ours — the vault is not opened and the root does not travel. The
* invite is returned at once and the wait continues in the background, because a socket request
* that blocked for five minutes would look like a hung daemon.
*/
async pairOffer (opts = {}) {
if (this._pairingState && this._pairingState.status === 'waiting') {
throw new Error('an invite is already outstanding — `favela pair --cancel` first')
}
const cred = loadCredential(this.storage)
if (!cred) throw new Error('this device is not linked, so it has nothing to pass on — run `favela link`')
this._pairing = new Pairing()
const { invite, joined } = await this._pairing.offer(cred, { ...opts, feedKey: hex(this.key) })
this._pairingState = { status: 'waiting', invite }
joined
.then((r) => { this._pairingState = { status: 'linked', devicePublicKey: r.devicePublicKey } })
.catch((err) => { this._pairingState = { status: 'failed', error: err.message } })
// ⛔ close() DRAINS before destroying — see pairing.js. Closing eagerly here would cut the
// frame that carries the credential and the other side would time out believing nothing
// happened, while this side reported success.
.finally(async () => { try { await this._pairing.close() } catch {}; this._pairing = null })
return { invite }
}
pairStatus () { return this._pairingState || { status: 'idle' } }
async pairCancel () {
if (this._pairing) { try { await this._pairing.close() } catch {}; this._pairing = null }
this._pairingState = { status: 'cancelled' }
return this._pairingState
}
/**
* Take an invite from another machine and link this one with what arrives.
*
* ⛔ Pairing happens AT INIT — a phone joining an identity it has never written under. So the
* normal case is adopting the offered feed, and the guard exists for the case that is not init:
* a node that has already founded its own feed and written to it would lose that log.
*
* The check is for RECORDS, not for a founder. An unlinked node has an empty unfounded feed
* (opening one is not founding it), so requiring "no founder" would refuse every legitimate
* pairing; requiring "nothing written" refuses exactly the case that would lose something.
*/
async pairAccept (invite) {
const p = new Pairing()
try {
const cred = await p.accept(invite)
if (cred.feedKey && cred.feedKey !== hex(this.key)) {
const refusal = adoptionRefusal((await this.peer.feed.list()).length, cred.feedKey, hex(this.key))
if (refusal) throw new Error(refusal)
saveFeedKey(this.storage, cred.feedKey)
saveCredential(this.storage, cred)
// ⚠ The feed is opened at boot, so adopting one means reopening. Reported rather than done
// in place: swapping the log under a live Peer is the kind of half-applied state this
// codebase refuses everywhere else.
return { adopted: cred.feedKey, restart: true, label: cred.label }
}
// The same `link` the vault path uses. Nothing downstream is pairing-specific.
return { ...(await this.link(cred)), label: cred.label }
} finally {
try { await p.close() } catch {}
}
}
async close () {
// Stop the mirror before anything it touches is torn down — a pass mid-fetch holding a
// corestore session while the store closes throws into nobody's hands.
if (this._mirror) this._mirror.stop()
if (this._onMirrorPeer && this.swarm) { try { this.swarm.off('connection', this._onMirrorPeer) } catch {} }
// ⚠ Settle in-flight peeks FIRST. A background open still holding a corestore session while
// this tears the store down throws "RocksDB session is closed" into nobody's hands — the open
// has no caller left to reject to. In production these settle promptly: `hold()` awaits a feed
// open and a swarm join, and the join no longer waits on the DHT.
if (this._opening.size) { try { await Promise.allSettled([...this._opening.values()]) } catch {} }
if (this._pairing) { try { await this._pairing.close() } catch {}; this._pairing = null }
if (this.names) { try { await this.names.close() } catch {} }
if (this.browsers) { try { await this.browsers.close() } catch {} }
if (this.transport) { try { await this.transport.destroy() } catch {} }
for (const [, room] of this._rooms) { try { await room.close() } catch {} }
for (const [, feed] of this._feeds) { try { await feed.close() } catch {} }
if (this.peer) { try { await this.peer.feed.close() } catch {} }
if (this.overlay && this.overlay.close) { try { await this.overlay.close() } catch {} }
// ⛔ LAST, and it was missing. Closing every session above does not release the store's RocksDB
// lock, so a node that closed cleanly still owned its storage directory: reopening it — a
// restart, or a test that boots twice — failed with "File descriptor could not be locked".
if (this.corestore) { try { await this.corestore.close() } catch {} }
}
}