// Feed — one identity's log, on autobee.
//
// The write path and the read path are NOT symmetric, and pretending otherwise is the usual way to
// misuse a multi-writer log. A write is an **op appended to your own writer core**; it becomes state
// only when the linearizer reaches it and `apply` runs. A read is against the linearized view. So
// `put()` returning does not mean `get()` will see it — `update()` does.
//
// Bytes never enter the log. `put` writes the record to the store first and appends only the
// pointer, so the core carries ~50 bytes per record instead of the record.
//
// ── Why autobee and not autobase ───────────────────────────────────────────────────────────────
//
// A feed's writers are one person's devices, so the writer set is usually TWO. Under autobase's
// majority (`floor(N/2)+1`) N=2 needs both indexers to ack every node: one device goes offline and
// `indexedLength` freezes while the view keeps growing, and you cannot self-heal by dropping to one.
// For a feed that is not an edge case, it is the normal posture. autobee replaces indexers and
// quorum with provable sort weights, which retires that class outright.
//
// ── The three autobee traps, all of which bite here ────────────────────────────────────────────
//
// 1. `node.key` is the writer key. autobase puts it on `node.from`. Attribution must never come
// from the op body, so getting this wrong is silent misattribution rather than a crash.
// 2. The `view` inside apply is a WRITABLE hyperbee2 batch; `db.view` is a read-only snapshot.
// 3. hyperbee2 has no put/del and no key/valueEncoding — Buffers, and every mutation is a batch.
import Autobee from 'autobee'
import b4a from 'b4a'
import { applyOp, ROSTER, REVOKED, FOUNDER, KNOCKS, LISTING, LISTING_ROOT } from './feed.js'
import { RecordLog } from './log.js'
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
const enc = (v) => b4a.from(JSON.stringify(v), 'utf8')
const dec = (b) => (b == null ? null : JSON.parse(b4a.toString(b, 'utf8')))
// The { get, set } surface applyOp expects: reads from the bee, writes into one batch.
//
// A WriteBatch has tryPut/tryDelete/flush and NO get — measured, not assumed. So reads go to the
// bee, and a pending overlay carries writes made EARLIER IN THE SAME BATCH: `applyAddDevice` writes
// the roster entry and a later op in the same batch checks it via `isAdmitted`, which would
// otherwise miss. The overlay also lets the whole apply call flush ONCE rather than once per op.
function batchView (bee, batch) {
const pending = new Map()
return {
pending,
async get (key) {
if (pending.has(key)) return pending.get(key)
const node = await bee.get(b4a.from(key, 'utf8'))
return node ? dec(node.value) : null
},
async set (key, value) {
pending.set(key, value)
batch.tryPut(b4a.from(key, 'utf8'), enc(value))
}
}
}
async function apply (nodes, view, host) {
const batch = view.write()
const v = batchView(view, batch)
for (const node of nodes) {
let op
try { op = dec(node.value) } catch { continue } // a malformed block is a no-op, deterministically
// ⛔ node.key, not node.from.key.
const device = node.key ? hex(node.key) : null
if (op && op.t === 'add-device' && typeof op.device === 'string') {
const before = await v.get(ROSTER + '/' + op.device)
await applyOp(v, op, { device })
const after = await v.get(ROSTER + '/' + op.device)
if (!before && after) {
// ⛔ Ack ONLY here — after applyOp accepted the proof. `add-device` arrives optimistically
// because an unadmitted device is not yet a writer and has nowhere else to put a block, and
// an optimistic block that is acked STICKS IN THE LOG. So the ack has to be downstream of
// the authorisation, not beside it: acking first would let anyone with a socket append.
try { host.ackWriter(node.key) } catch {}
// Writer admission is a HOST call and must happen with the roster entry, or an admitted
// device would have no core to write into. Only apply may do either.
try { host.addWriter(b4a.from(op.device, 'hex'), { isIndexer: true }) } catch {}
}
continue
}
// ⛔ Optimistic blocks must self-verify before being acked, or "any writer could append blocks
// even when not added to the system" (autobee README). The verification here is deliberately
// narrow: a KNOCK is acked, and nothing else ever is. An unadmitted device's put or add-device
// is already refused by applyOp, but refusing it is not enough — acking it would let the block
// itself stick in the log, which is the exploit the README warns about.
if (op && op.t === 'knock') {
try { host.ackWriter(node.key) } catch {}
}
await applyOp(v, op, { device })
}
await batch.flush()
}
export class Feed {
constructor (store, key, opts = {}) {
this.corestore = store
// fast-forward: OFF unless a caller hands us a boot head — a configuration choice, not a
// workaround for a defect.
//
// Fast-forward is on by default and PROBES for candidate oplog heads. Our joins are degenerate
// for that: one serving peer, no stamped heads anywhere, so there are zero candidates and each
// wakeup batch pays DEFAULT_OP_TIMEOUT (5s, lib/constants.js:5) looking for something that does
// not exist. Measured at N=25 writers: 25s vs 0.2s. Turning it off is not fixing autobee, it is
// declining to search when we know there is nothing to find.
//
// With a boot head it works and is fast — 201ms, and it does fast-forward.
//
// ⚠ A boot head is an OPLOG head — `{key, length}` of a writer's core — and a joiner cannot
// derive one from a feed key. It has to be CONVEYED or REMEMBERED: from a registry listing, a
// follow record, a knock, or our own record of a previous session. The seam is here for when we
// carry one.
//
// ★ If we ever want DISCOVERY-based fast-forward instead, the handler that matters is
// `mostRecentTrusted`, and it belongs on the peer that SERVES, not the one joining:
// `lib/writers.js:112` — flushLocal passes its result into `w.flush(views, trusted)`, stamping
// the vouched head into that peer's own oplog for joiners to read. Note also `trusted.js:19`:
// supplying `mostRecentTrusted` flips the `isTrusted` default from permissive to deny, because
// opting into the trust system means opting out of trusting everything. Both are coherent; they
// are just not obvious from the outside.
const bootHead = opts.bootHead || null
this.base = new Autobee(store, key || null, {
apply,
optimistic: !!opts.optimistic, // the stranger doorbell, off by default
fastForward: bootHead ? { boot: { head: bootHead } } : false,
...(opts.encryptionKey ? { encrypted: true, encryptionKey: opts.encryptionKey } : {})
})
this.store = opts.store || null // the RecordStore holding the bytes
this._log = null
}
async ready () {
await this.base.ready()
// RecordLog over a READ-ONLY adapter: this log resolves and never writes, because a write here
// has to be an append. Its three-state resolve() is the whole reason to reuse it.
this._log = new RecordLog(this._readKv(), this.store)
return this
}
get key () { return this.base.key }
get writable () { return this.base.writable }
get deviceKey () { return hex(this.base.local.key) }
_readKv () {
const bee = () => this.base.view
return {
set: async () => { throw new Error('Feed: the view is read-only — writes are appends') },
del: async () => { throw new Error('Feed: the view is read-only — writes are appends') },
get: async (key) => {
const node = await bee().get(b4a.from(key, 'utf8'))
return node ? dec(node.value) : null
},
list: async (prefix = '') => {
const out = {}
const range = prefix
? { gte: b4a.from(prefix, 'utf8'), lt: b4a.from(prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1), 'utf8') }
: {}
for await (const node of bee().createReadStream(range)) {
out[b4a.toString(node.key, 'utf8')] = dec(node.value)
}
return out
}
}
}
// ── write side: appends. None of these are visible until update() ──────────────────────────────
// Admit a device. The founding call is a device admitting ITSELF, which only takes effect while
// the view has no founder — see feed.js. Everything after needs an already-admitted device.
//
// ⚠ autobee also exposes `host.genesis` for exactly this, which would let feed.js drop its FOUNDER
// marker. Deliberately NOT taken here: this port must not change behaviour, and FOUNDER is what
// the substrate-agnostic tests prove. One change at a time.
/**
* Admit this device by proving it belongs to the feed's owner.
*
* The proof must attest THIS feed's writer key — `deviceKey`, i.e. `base.local.key`.
*
* ⛔ That key is not something a caller chooses. Passing `keyPair` to the log sets its BOOT
* MANIFEST, not the local writer core — measured: with a keyPair supplied, `base.local.key` was
* still a corestore-derived key and did not match. So the credential cannot be minted ahead of
* time; the feed is opened FIRST, its writer key read, and the proof minted for that key. Which
* is also the honest claim to be making: "this writer core belongs to me".
*
* Self-admission: no other device has to be online or to vouch. Genesis is the same call — the
* first one binds the proof's identity root into the feed permanently.
*
* ⛔ Appended OPTIMISTICALLY. A device not yet on the roster is not yet a writer and has nowhere
* else to put a block; apply acks it only once the proof verifies.
*/
async addDevice (proof) {
if (!proof) {
throw new Error('Feed.addDevice: a chain proof for ' + this.deviceKey + ' is required — ' +
'admission is gated by attestation, and there is no unattested feed')
}
await this.base.append(enc({ t: 'add-device', device: this.deviceKey, proof }),
{ optimistic: true })
}
/**
* Revoke a device. A tombstone, and it reaches everything that device attested.
*
* ⚠ You may revoke yourself, including the last device, which freezes the feed. Recoverable: a
* device minted fresh from the vault descends from nothing revoked and admits itself normally.
*/
async removeDevice (device) {
await this.base.append(enc({ t: 'remove-device', device: hex(device) }))
}
// Bytes to the store, pointer to the log. If the append never lands the bytes are merely
// unannounced; reversed, the pointer would name bytes nobody has.
async put (collection, name, record) {
const { oid, size } = await this.store.put(collection, name, record)
await this.base.append(enc({ t: 'put', collection, name, $oid: oid, size }))
return { oid, size }
}
// Ring someone's doorbell: append to THEIR feed, optimistically, naming MY feed. It grants
// nothing and asks for nothing except to be seen.
//
// ⚠ Requires holding their base, which requires a transport. PROVEN END TO END 2026-09-15 in
// doorbell-wire.test.js: a reader who is not a writer appends across a replication pipe and the
// row lands in the owner's feed, with her writer set untouched and a record from him refused.
//
// ⛔ `base.writable` GOES TRUE ON THE APPENDER after an optimistic append — it means "I have a
// local writer core", never "my appends are admitted". Do not use it as an authorisation check.
//
// ⛔ THE KNOCK PROVES WHO IS KNOCKING (feed.js applyKnock). `bind(writerKeyHex)` returns a proof
// binding OUR writer core on their log to our identity root — `bindWriter(cred, key)`, the same
// proof add-device carries. It is a function rather than a credential so that this file never
// imports the vault code (device.js): only the node holding the credential builds one, and a
// read-only holder that never knocks never loads it.
async knock (myFeedKey, bind) {
if (typeof bind !== 'function') {
throw new Error('Feed.knock: a binder is required — a knock must prove which identity is knocking ' +
'(run `favela link` so this device has a credential)')
}
await this.base.append(enc({ t: 'knock', feed: hex(myFeedKey), proof: bind(this.deviceKey) }), { optimistic: true })
}
// Honour a knock by listing that feed. Bytes first, then the op — same order as `put`, and for the
// same reason: a pointer naming bytes nobody has is worse than bytes nobody has announced.
//
// ⚠ apply refuses this unless an active knock exists for the feed, so listing something that never
// asked is not merely discouraged, it does not land. Order matters: list, THEN dismiss.
//
// `root` is the identity whose proven knock is being honoured; apply refuses the listing unless that
// knock is live and named THIS feed.
async listFeed (root, feedKey, record) {
const feed = hex(feedKey)
const { oid, size } = await this.store.put(LISTING, feed, record)
await this.base.append(enc({ t: 'list-feed', root: hex(root), feed, $oid: oid, size }))
return { oid, size }
}
async delist (feedKey) {
await this.base.append(enc({ t: 'delist', feed: hex(feedKey) }))
try { await this.store.del(LISTING, hex(feedKey)) } catch {}
}
// Each listing with the identity root it was granted to. A reader must still check that the feed's
// founder IS that root before trusting it (Peer.directory): apply cannot read the listed feed.
async listings () {
const rows = await this.list(LISTING)
const out = []
for (const r of rows) {
const granted = await this._readKv().get(LISTING_ROOT + '/' + r.name)
out.push({ feed: r.name, record: r.record, root: granted && granted.$deleted !== true ? granted.root : null })
}
return out
}
async dismissKnock (root) {
await this.base.append(enc({ t: 'dismiss-knock', root: hex(root) }))
}
// Live knocks: the PROVEN identity root that knocked, the feed it asked about, and its writer core.
//
// ⛔ A row without `feed` was written by 0.0.57's apply (`__knocks__/<feed key>`, no proof). An
// upgrade does not reindex the view, so such rows survive; the proven-knock apply never writes one,
// so a fresh node computing the same log has no knock there. Dropping them here makes an upgraded
// node agree with a fresh one (test/knock-legacy.test.js).
async knocks () {
const map = await this._readKv().list(KNOCKS + '/')
return Object.entries(map)
.filter(([, v]) => v && v.$deleted !== true && typeof v.feed === 'string')
.map(([k, v]) => ({ root: k.slice(KNOCKS.length + 1), feed: v.feed, device: v.device }))
.sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0))
}
async del (collection, name) {
await this.base.append(enc({ t: 'del', collection, name }))
try { await this.store.del(collection, name) } catch {}
}
// ── read side: the linearized view ────────────────────────────────────────────────────────────
async update () { await this.base.update() }
async resolve (collection, name) { return this._log.resolve(collection, name) }
async get (collection, name) { return this._log.get(collection, name) }
async list (collection = null) { return this._log.list(collection) }
// Live pointers, WITHOUT fetching any bytes — see RecordLog.pointers. This is what lets the
// mirror decide which records to go and get instead of getting all of them at once.
async pointers (collection = null) { return this._log.pointers(collection) }
async unresolved (collection = null) { return this._log.unresolved(collection) }
// Positively deleted names — see RecordLog.tombstones. What a bridge holding this feed retracts on.
async tombstones (collection) { return this._log.tombstones(collection) }
// Admitted WRITER cores, excluding any whose device has been revoked.
async devices () {
return (await this.roster()).map((r) => r.writer).sort()
}
/**
* The roster as { writer, device } pairs, revoked devices excluded.
*
* ⛔ Both keys, because they are different things and only one of them can be revoked. A listing
* that showed only writer cores would name something `unlink` cannot take — you would be able to
* see a device and not retire it.
*/
async roster () {
const rows = await this._readKv().list(ROSTER + '/')
const dead = await this._readKv().list(REVOKED + '/')
const out = []
for (const [k, v] of Object.entries(rows)) {
if (!v || !v.device) continue
if (dead[REVOKED + '/' + v.device] != null) continue
out.push({ writer: k.slice(ROSTER.length + 1), device: v.device })
}
return out.sort((a, b) => (a.writer < b.writer ? -1 : a.writer > b.writer ? 1 : 0))
}
/** Revoked device keys — named, because a retired device is a fact, not an absence. */
async revokedDevices () {
const dead = await this._readKv().list(REVOKED + '/')
return Object.keys(dead).map((k) => k.slice(REVOKED.length + 1)).sort()
}
async founder () { return this._readKv().get(FOUNDER) }
// ── replication ───────────────────────────────────────────────────────────────────────────────
//
// Replicate through the BASE. autobee owns which cores a peer needs — the writer set changes as
// devices are admitted, so the base is the only thing that knows the full set at any moment.
// `isInitiatorOrStream` is a boolean (make a stream) or an existing duplex, so a transport can
// hand us a socket without this file knowing what a socket is.
replicate (isInitiatorOrStream) { return this.base.replicate(isInitiatorOrStream) }
get discoveryKey () { return this.base.discoveryKey }
async close () { await this.base.close() }
}