// Peer — your feed, the feeds you hold, and the timeline you compute from them.
//
// ── Following is a record, not a subscription ──────────────────────────────────────────────────
//
// To follow someone you write a record in YOUR OWN feed naming their feed key. Nothing is written
// to theirs, and they need not be online, consent, or ever know. That is the same rule every
// interaction obeys here, and it is what keeps a feed's writer set at its owner's devices no matter
// how many people follow it.
//
// ── The timeline is a projection, and it is LOCAL ──────────────────────────────────────────────
//
// There is no relay and no aggregator. The timeline is a pure function of the feeds this node
// happens to hold, so two peers legitimately compute different timelines from the same follow list.
// A feed you follow but have not replicated contributes NOTHING — which is correct, and is why
// `missing()` exists: a gap you can name is a different thing from a gap that is silently empty.
//
// ⚠ No networking. Feeds are handed in. There is no transport seam yet, and adding one should not
// change anything in this file.
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
// ⛔ The codec, not a regex. A mention is a markdown LINK with a `favela://` target, and deciding
// that from the source text means re-implementing link parsing — including the cases where a `@`
// inside code fences or a link label is not a mention at all.
import { strip } from 'favela-markdown/lib/strip.js'
import { NETWORKS } from 'favela-markdown/lib/reach.js'
// Collections. Bare names, deliberately: reverse-DNS is atproto's convention and this stack is not
// atproto's. A third party's collection would be namespaced; ours are not.
export const POST = 'post'
export const FOLLOW = 'follow'
export const LIKE = 'like'
// ⛔ REACTION is the primitive and `like` is a reserved VALUE of it — keet's shape, and the only
// one of the three networks that gets like for free rather than bolting reactions beside it
// (`keet-core/lib/core/contract/reactions.js`).
//
// ⛔ `like` is the string, never an emoji. A bridge must be able to name the one reaction both
// targets carry without guessing, and if the value were 👍 then "did they like it or react with a
// thumb" would be undecidable in both directions. The narrower target carries no other reaction, so
// every other value is a stated `loss[]` where that applies — which the BRIDGE states, not us.
export const REACTION = 'reaction'
// ⛔ A BODYLESS edge, per `interaction.md` §5.2: "if it has bytes it gets a file; if it does not,
// the log entry is the whole thing". Same class as like and follow, and deliberately NOT the same
// as reply, which is "the edge that is also a document". It is one of the five verbs the spec calls
// consensus-critical (§5.4) — both bridge targets carry it, under their own names, which is the
// bridges' business and not this file's.
//
// ⛔ And not the same as a QUOTE. `record-vocabulary.md` §3.5: "quotes are just links" — a quote is
// an ordinary post whose body references the quoted record, so it has a body and its own text. A
// repost adds nothing of its own, which is exactly what separates the two everywhere.
export const REPOST = 'repost'
// ⛔ A bodyless edge naming an ACTOR, like follow — `interaction.md` §5.2 and §5.4.
//
// ⛔ What it can and cannot do has to be said plainly, because the honest answer is weaker than
// people expect. A feed is public and replication is the READER's to perform, so a block cannot
// stop someone reading you, holding your feed, or writing about you. It is a decision about what
// YOU render, published as a signal — `SOCIAL-MODEL.md` §6.7's "signals, not enforcement", one
// level down. Anything stronger would be a promise this substrate cannot keep.
export const BLOCK = 'block'
export const LIKE_VALUE = 'like'
// A label, not a payload. Long enough for an emoji sequence with modifiers, short enough that the
// collection cannot become a message channel.
const MAX_VALUE = 64
export const REPLY = 'reply'
// The published profile. ONE record per feed, named by a constant — a feed is one identity, so
// there is nothing to enumerate.
//
// ⛔ Why this is a RECORD and not only a registry row. The registry is LOCAL to a node: it holds
// browser credentials and never replicates. So a profile kept only there is invisible to everyone
// who reads your feed — no name, no picture, nothing to search. Seeing who wrote something requires
// the profile to travel, and the feed is the only thing that travels.
//
// ⛔ What travels is a SELF-LABEL, and that is the whole justification. NAMING.md §2 forbids a name
// in a record because pgh's NameRegistry is first-claim-wins and a released name is RECLAIMABLE, so
// a claimed name in immutable bytes can later denote a different person. A self-label is claimed
// from nobody, unique to nobody, and reclaimable by nobody — it denotes only "what this feed's owner
// calls themselves". The resolvable handle is a different object and belongs in a HandleRegistry,
// beside this rather than instead of it.
//
// ⚠ Which is why the key is ALWAYS rendered next to it. A self-label is a claim, and two feeds may
// carry the same one.
export const PROFILE = 'profile'
// Which networks this identity publishes to. A PREFERENCE, in the feed, so it follows the identity
// to every device rather than living on whichever machine happened to set it.
export const REACH = 'reach'
// ⛔ The blob POINTER collection — record-vocabulary §3.5. A tiny record in the feed so every reader
// converges on which levels a blob has and the oid to fetch each by, while the bytes stay out of the
// log. Keyed by the BASE oid, which is what `favela://blob/<oid>` already names.
export const BLOB = 'blob'
export const SELF = 'self'
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
// Non-overlapping occurrences of `needle` in `haystack`. Both are already lower-cased by the caller;
// no normalisation happens here, deliberately — normalising would change what an author wrote for
// reasons invisible to them, which is the rule the strip already follows.
function countOf (haystack, needle) {
let n = 0
let i = haystack.indexOf(needle)
while (i !== -1) {
n++
i = haystack.indexOf(needle, i + needle.length)
}
return n
}
export class Peer {
// A feed key is 32 bytes of hex, which is a legal single path segment, so a follow record is named
// by the key itself. No mapping table, and the name is a pure function of what it names.
static nameFor (key) { return hex(key) }
// A stable id for a target, used as the record NAME of a like.
//
// Deriving the name from the target is what makes liking idempotent: liking twice writes the same
// key twice, which is one record, not two. keet reaches the same place from the other direction by
// enforcing uniqueness of {id, sender, reaction} inside apply.
//
// It is a hash rather than the components joined, because a separator that cannot appear in a
// collection or a name does not exist — and a joined string would silently collide for two
// different targets. The components are stored in the record's frontmatter, so nothing needs to
// read this backwards.
/**
* The record NAME of a reaction — derived from (target, value).
*
* ⛔ The value is IN the id, which is what lets one person hold 👍 and ❤️ on one target while
* each stays idempotent. keet reaches the same place by enforcing uniqueness of
* {message, sender, reaction} inside apply; deriving the key does it without a rule.
*/
static reactionId (target, value) {
const t = Peer.normalizeTarget(target)
if (!t) throw new Error('Peer: a target needs { feed, collection, name }')
return b4a.toString(crypto.data(
b4a.from(JSON.stringify([t.feed, t.collection, t.name, String(value)]), 'utf8')), 'hex')
}
static normalizeValue (value) {
const v = typeof value === 'string' ? value.trim() : ''
if (!v || v.length > MAX_VALUE) return null
// No control characters: a reaction is rendered next to a byline and must not carry newlines.
return /[\u0000-\u001f\u007f]/.test(v) ? null : v
}
static targetId (target) {
const t = Peer.normalizeTarget(target)
if (!t) throw new Error('Peer: a target needs { feed, collection, name }')
return b4a.toString(crypto.data(b4a.from(JSON.stringify([t.feed, t.collection, t.name]), 'utf8')), 'hex')
}
// ⛔ Components, never a URI string. The address form is open (hyper-social #61 §12 #1) and a URI
// baked into a record's bytes is exactly what record-vocabulary §8 forbids rewriting. Components
// serialise into whatever the scheme becomes; a URI does not un-serialise into components that
// were never stored.
static normalizeTarget (target) {
if (!target || typeof target !== 'object') return null
const feed = hex(target.feed)
const { collection, name } = target
if (typeof feed !== 'string' || !feed) return null
if (typeof collection !== 'string' || !collection) return null
if (typeof name !== 'string' || !name) return null
return { feed, collection, name }
}
constructor (feed) {
this.feed = feed
this.feeds = new Map() // hex key -> Feed, the ones we actually hold
// ⛔ Held to READ, not because I follow them. A peeked feed is readable — its page renders —
// and is excluded from the timeline, because a stranger whose posts appear in your timeline is
// someone you are following whether or not a record says so.
this.peeked = new Set()
// Feeds I have blocked. Cached like `_follows`, and consulted in ONE place — see `_readable`.
this._blocked = new Set()
// ⛔ How a knock proves who is knocking: `bind(writerKeyHex)` → a proof binding that writer core
// to this device's identity root. The node sets it from its credential (node.js); unset means
// this device is not linked, and a knock refuses rather than going out unproven (feed.js).
this.bind = null
// Observation feeds a bridge vouched for: hex feed key -> the founder root it vouched for (hyper-social #82, I1).
// Held like any feed, but read ONLY as notifications from outside actors, and only while that feed's founder
// IS the pinned root. The node fills this from the stored grants (bridge.js acceptObserver).
this.observers = new Map()
}
_binder () {
if (typeof this.bind !== 'function') {
throw new Error('this device is not linked, so it cannot prove who is knocking — run `favela link`')
}
return this.bind
}
// The identity root behind a live knock that named `feedKey`, or null. Callers (the CLI, the
// surface) still speak in feed keys; a knock is keyed by the root that proved it.
async _knockRootFor (feedKey) {
const feed = Peer.nameFor(feedKey)
for (const k of await this.feed.knocks()) if (k.feed === feed) return k.root
return null
}
async ready () {
await this.feed.ready?.()
// What I follow, cached so `peek` can refuse to demote one. Rebuilt on `update()`, because the
// follow set is a projection of my own log like everything else.
this._follows = new Set(await this.following())
this._blocked = await this.blocked()
return this
}
// Record the follow, and hold the feed if one was supplied. The two are independent on purpose:
// you can record a follow for a feed you cannot reach yet, and hold a feed you never followed.
/**
* Hold a feed to READ it, without following.
*
* Writes nothing — no record enters my feed, and nothing enters theirs. ⚠ Not invisible: holding
* a feed means joining its discovery topic, so the peer can see that somebody arrived. It is
* "not announced", not "anonymous".
*/
/**
* Stop rendering a feed. Writes nothing to theirs, and cannot stop them doing anything.
*
* ⛔ It names a FEED, not an identity root, because rendering is per-feed and follow — the
* operation it undoes in practice — names a feed too. A root-scoped block would be the stronger
* form and this is not it.
*/
async block (key) {
const name = Peer.nameFor(key)
// ⛔ Blocking myself would delete me from my own timeline, which is nobody's intent and is
// trivially reachable from a page that lists feeds including your own.
if (name === hex(this.feed.key)) throw new Error('you cannot block yourself')
await this.feed.put(BLOCK, name, { frontmatter: { createdAt: new Date().toISOString() }, body: '' })
this._blocked.add(name)
return name
}
// A tombstone, like every other undo here.
async unblock (key) {
const name = Peer.nameFor(key)
await this.feed.del(BLOCK, name)
this._blocked.delete(name)
}
/** Feeds I have blocked. */
async blocked () {
return new Set((await this.feed.list(BLOCK)).map((r) => r.name))
}
peek (key, feed) {
const name = Peer.nameFor(key)
if (feed) this.feeds.set(name, feed)
// ⚠ Never demote a feed I already follow — that would silently drop them out of my timeline.
if (!this._follows.has(name)) this.peeked.add(name)
return name
}
async follow (key, feed = null) {
const name = Peer.nameFor(key)
// Wall time is fine HERE. The determinism rule binds `apply`, which is replayed on every boot
// and reorg; this is the write path, which runs once. An epoch placeholder would have been
// deterministic and false, and a record that lies to avoid a rule the rule does not impose is
// the worse trade.
await this.feed.put(FOLLOW, name, {
frontmatter: { createdAt: new Date().toISOString() },
body: ''
})
if (feed) this.feeds.set(name, feed)
// A peek that stayed a peek after you followed would leave the timeline silently short.
this.peeked.delete(name)
this._follows.add(name)
return name
}
// Tombstone, never a silent removal — absence is ambiguous, and a peer replicating my feed has to
// be able to tell "unfollowed" from "not replicated to me yet".
async unfollow (key) {
const name = Peer.nameFor(key)
await this.feed.del(FOLLOW, name)
this.feeds.delete(name)
this.peeked.delete(name)
this._follows.delete(name)
}
// ── the profile ────────────────────────────────────────────────────────────
//
// An ordinary record: same put, same pointer, same tombstone, same replication as a post.
/**
* Publish this feed's profile. Only the fields given are touched — a settings form showing two
* fields must not erase a third it never rendered.
*/
/**
* Which networks this identity publishes to, and under which identifier.
*
* ⛔ OFF BY DEFAULT, AND ABSENCE FAILS CLOSED. No record means `[]` — publish nowhere. Reading a
* missing value as "everything" is the one mistake here that cannot be taken back, because the
* result is words under someone's name on a network that does not forget.
*
* ⚠ This is a preference, NOT a bridge. The peer names networks — `favela-markdown/lib/reach.js`
* defines them and this file already depends on it — and holds nothing that knows what any of
* them are: no schema, no signer, no wire format. Whatever reads this preference and acts on it
* is a separate component, which is what `test/boundary.test.js` exists to keep true.
*/
async reach () {
await this.feed.update()
const rec = await this.feed.get(REACH, SELF)
const fm = (rec && rec.frontmatter) || {}
return {
networks: Array.isArray(fm.networks) ? fm.networks : [],
// identity.md §5.1 — the DESIGNATED credential. With none, a projection that needs one is
// withheld rather than published under a guess.
credential: fm.credential || null,
// The name a bridge grant produced, e.g. `alice@ap.example`. Absent until one is claimed.
fediverse: fm.fediverse || null,
// ⛔ AND WHICH BRIDGE CARRIES IT (hyper-social #82, I1): its swarm key, public. bridges.json is per device, so
// without this a second device — or any device after a registration older than bridges.json — could not ask
// its bridge for the observation feed. Here it replicates with the identity.
fediverseBridge: fm.fediverseBridge || null
}
}
/**
* The mip pointer for one blob of THIS feed, or null.
*
* ⛔ Null means "no pointer", which for a blob written before levels existed is the SINGLE-LEVEL
* case and not an error — record-vocabulary §3.5 calls today's avatar exactly that. A caller must
* treat absence as one level, never as a missing asset.
*/
async blobPointer (oid) {
return this.blobPointerOf(this.feed.key, oid)
}
/** One feed's pointer for a blob, or null. Reads any feed this node holds. */
async blobPointerOf (feedKey, oid) {
if (typeof oid !== 'string' || !oid) return null
const id = hex(feedKey)
for (const [author, feed] of this._readable()) {
if (author !== id) continue
const rec = await feed.get(BLOB, oid)
const fm = (rec && rec.frontmatter) || null
if (!fm || !Array.isArray(fm.levels)) return null
return fm
}
return null
}
/**
* Publish the pointer for a blob's pyramid.
*
* ⛔ THE POINTER, NOT A MANIFEST FILE. §3.5: one pointer, N oids, zero extra artifacts. It rides
* the feed so a bridge holding this feed can choose a level without asking anyone.
*/
async putBlobPointer (oid, pointer) {
if (typeof oid !== 'string' || !oid) throw new Error('putBlobPointer: an oid is required')
await this.feed.update()
await this.feed.put(BLOB, oid, { frontmatter: pointer, body: '' })
return pointer
}
/** Merges: a write naming only `networks` leaves the credential alone, and the reverse. */
async setReach ({ networks, credential, fediverse, fediverseBridge } = {}) {
await this.feed.update()
const current = await this.reach()
const fm = { networks: current.networks, credential: current.credential, fediverse: current.fediverse, fediverseBridge: current.fediverseBridge }
if (fediverseBridge !== undefined) {
if (fediverseBridge !== null && !/^[0-9a-f]{64}$/i.test(String(fediverseBridge))) {
throw new Error('setReach: fediverseBridge must be a 64-hex bridge key')
}
fm.fediverseBridge = fediverseBridge ? String(fediverseBridge).toLowerCase() : null
}
if (networks !== undefined) {
if (!Array.isArray(networks)) throw new Error('setReach: networks must be an array')
for (const n of networks) {
// ⛔ Refused, not silently dropped. A name nothing can resolve to a bridge would sit in the
// feed looking enabled, and the reader would have no way to tell it apart from one that works.
if (n === 'favela') {
throw new Error('setReach: favela is the substrate, not a destination — it cannot be switched on or off')
}
if (!NETWORKS.includes(n)) throw new Error('setReach: unknown network "' + String(n) + '"')
}
fm.networks = [...new Set(networks)]
}
if (credential !== undefined) {
fm.credential = credential ? String(credential).slice(0, 256) : null
}
// ⛔ A HANDLE, NOT A CREDENTIAL. identity.md §5.1's `credential` is the DESIGNATED ATPROTO one
// and says so; the fediverse has no equivalent because §6 makes a bridge a RESOURCE you hold a
// grant on. What is recorded here is the name that grant produced — `alice@ap.example` — so the
// node can say where it publishes without pretending the two networks work the same way.
//
// ⚠ The daemon never learns what a fediverse IS. `fediverse` is already one of reach.js's
// NETWORKS, so this names a destination it can already name and nothing more; boundary.test.js
// forbids the projection vocabulary, not the word.
if (fediverse !== undefined) {
fm.fediverse = fediverse ? String(fediverse).slice(0, 256) : null
}
if (!fm.credential) delete fm.credential
if (!fm.fediverse) delete fm.fediverse
if (!fm.fediverseBridge) delete fm.fediverseBridge
await this.feed.put(REACH, SELF, { frontmatter: fm, body: '' })
return this.reach()
}
async setProfile ({ name, avatar, bio, banner } = {}) {
await this.feed.update()
const current = await this.feed.get(PROFILE, SELF)
const fm = { ...((current && current.frontmatter) || {}) }
if (name !== undefined) {
const n = typeof name === 'string'
? name.replace(/[\n\r\t]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '').trim().replace(/\s+/g, ' ').slice(0, 64)
: ''
// A blank name is a removal, not an error. Absence asserts nothing; "" would assert that the
// name is empty.
if (n) fm.name = n
else delete fm.name
}
if (avatar !== undefined) {
if (avatar) fm.avatar = String(avatar)
else delete fm.avatar
}
// ⛔ A SECOND PICTURE, not a variant of the first. AS2 has carried both `icon` and `image`
// since forever and Mastodon renders both, so an identity that can only hold one is missing a
// field the destination already had (hyper-social #67 §7.6). Same shape as the avatar — an oid,
// bytes in the overlay — because nothing about a banner needs a second mechanism.
if (banner !== undefined) {
if (banner) fm.banner = String(banner)
else delete fm.banner
}
// The record is mutable and overwritten in place, so createdAt keeps meaning creation.
if (typeof fm.createdAt !== 'string') fm.createdAt = new Date().toISOString()
/**
* ⛔ The bio is the record's BODY, not another frontmatter field. It was designed that way and
* this method has been carefully PRESERVING that body on every write while nothing ever wrote
* it — so a profile could carry a name and a picture and never a sentence about who someone is.
*
* A body means it is markdown like every other record body, and it travels with the feed like
* every other record. A frontmatter copy would be a second place for the same fact.
*/
let body = (current && current.body) || ''
if (bio !== undefined) {
const b = typeof bio === 'string'
// Control characters out, but newlines KEPT — it is markdown, and a bio with paragraphs is
// an ordinary thing to write.
? bio.replace(/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, 512)
: ''
// ⚠ Blank REMOVES it. Absence asserts nothing; an empty string asserts that the bio is empty.
body = b
}
await this.feed.put(PROFILE, SELF, { frontmatter: fm, body })
return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: body.trim() || null }
}
/** This feed's own profile. */
async profile () {
return (await this.profileOf(this.feed.key)) || { name: null, avatar: null, banner: null, bio: null }
}
/**
* One feed's profile, or null.
*
* Null covers all three unreadable states, which is right HERE and nowhere else in this file: the
* fallback is the key, which is always shown and never wrong. An unreadable POST has no such
* fallback, which is why `unresolved()` exists and this does not need one.
*/
async profileOf (feedKey) {
const id = hex(feedKey)
for (const [author, feed] of this._readable()) {
if (author !== id) continue
const rec = await feed.get(PROFILE, SELF)
if (!rec) return null
const fm = rec.frontmatter || {}
return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: (rec.body || '').trim() || null }
}
return null
}
/**
* Every readable feed's profile, keyed by feed.
*
* A feed with no profile is ABSENT from the map rather than present with empty fields — "has not
* published one" and "published an empty one" are different facts.
*/
async profiles () {
const out = {}
for (const [author, feed] of this._readable()) {
const rec = await feed.get(PROFILE, SELF)
if (!rec) continue
const fm = rec.frontmatter || {}
/**
* ⛔ The identity ROOT travels with the profile. A mention names a root
* (`markdown-strip.md` §3.4) and a bare `@name` is never a guess — so without a root on the
* row there is no way to WRITE a mention from any interface, only to read one. That is what
* shipped: mentions were findable, renderable, listable and unwritable.
*
* ⚠ It is a FEED fact rather than a profile one, and it rides here because this is the map
* every byline already fetches. The alternative is a founder lookup per rendered author.
*/
const founder = await feed.founder()
out[author] = {
name: fm.name || null,
avatar: fm.avatar || null,
banner: fm.banner || null,
bio: (rec.body || '').trim() || null,
root: (founder && founder.identityRoot) || null
}
}
return out
}
/**
* People whose PROFILE matches — searching who, not what.
*
* Kept separate from `search()` rather than folded in: a person and a post are different answers
* to a query, and a UI that mixed them would have to guess which the reader meant.
*/
async searchProfiles (query) {
const q = String(query == null ? '' : query).toLowerCase()
if (!q) return []
const out = []
for (const [author, profile] of Object.entries(await this.profiles())) {
const name = (profile.name || '').toLowerCase()
// The KEY is searchable too — pasting a feed key is how you find someone with no name yet.
if (name.includes(q) || author.toLowerCase().startsWith(q)) out.push({ author, ...profile })
}
return out.sort((a, b) => (a.author < b.author ? -1 : 1))
}
// ── interactions ──────────────────────────────────────────────────────────────────────────────
//
// Every one of these writes to MY feed and names a target in someone's. Nothing is written to
// theirs, so a feed's writer set never grows past its owner's devices however popular it gets.
/** React to a target. `like` is the reserved value; anything else is an emoji or a short label. */
async react (target, value, createdAt = new Date().toISOString()) {
const t = Peer.normalizeTarget(target)
if (!t) throw new Error('Peer.react: a target needs { feed, collection, name }')
const v = Peer.normalizeValue(value)
if (!v) throw new Error('Peer.react: a reaction value is 1-' + MAX_VALUE + ' printable characters')
const name = Peer.reactionId(t, v)
await this.feed.put(REACTION, name, { frontmatter: { createdAt, target: t, value: v }, body: '' })
await this._ring(t)
return name
}
/**
* ⛔ RING THE TARGET'S DOORBELL. An interaction lives in its AUTHOR's feed, so the target learns of
* it by replication — and only if they hold that feed. Without this, a like from anyone whose feed
* they do not hold is invisible and nothing rings. hyper-social #61 §11.2 wrote that down in
* advance as the falsification: "A never learns ⇒ the doorbell is mandatory, not optional."
*
* ⛔ IT CARRIES NOTHING. One row saying a feed would like to be seen, in a namespace that can never
* become a record, granting no write and no admission (doorbell-wire.test.js proves both across a
* real pipe). The edge itself stays in our feed — writing it into theirs would be authoring as
* them, which #61 §4 removed the room primitive to avoid.
*
* ⚠ Bounded by construction: applyKnock keys on the FEED, so a thousand acts are one row. Inherited
* from the knock, not added here.
*
* ⚠ BEST EFFORT, NEVER FATAL. The edge is ours and is already written; an unreachable target must
* not undo it. But it is said out loud rather than swallowed — a doorbell that silently fails is
* the exact condition this exists to end.
*/
async _ring (t) {
if (!t || t.feed === Peer.nameFor(this.feed.key)) return false // being told about yourself is not a notification
const theirs = this.feeds.get(t.feed)
if (!theirs || typeof theirs.knock !== 'function') return false // we do not hold it; there is nothing to append to
try {
await theirs.knock(this.feed.key, this._binder())
return true
} catch (err) {
try { console.error(JSON.stringify({ msg: 'doorbell: could not ring — they will not learn of this interaction until they read our feed', feed: t.feed, error: (err && err.message) || String(err) })) } catch {}
return false
}
}
async unreact (target, value) {
const v = Peer.normalizeValue(value)
if (!v) return
await this.feed.del(REACTION, Peer.reactionId(target, v))
}
// `like` is a reaction whose value is the reserved one. Kept as its own method because it is the
// one value both target networks can carry, so callers that mean exactly that should say so.
async like (target, createdAt = new Date().toISOString()) {
return this.react(target, LIKE_VALUE, createdAt)
}
async unlike (target) { return this.unreact(target, LIKE_VALUE) }
// A reply IS a post that names a target — the body is what separates it from a like, which is why
// the two need no different machinery.
async reply (target, body, name, createdAt = new Date().toISOString()) {
const t = Peer.normalizeTarget(target)
if (!t) throw new Error('Peer.reply: a target needs { feed, collection, name }')
if (typeof name !== 'string' || !name) throw new Error('Peer.reply: a reply needs its own name')
await this.feed.put(REPLY, name, { frontmatter: { createdAt, target: t }, body: String(body || '') })
await this._ring(t)
return name
}
// Everything in `collection`, across readable feeds, whose frontmatter target matches.
//
// ⛔ There is no count(). You get the entries and may count what you can SEE — so no number exists
// that could be mistaken for a global total, and none can be summed with a bridged one.
async _pointingAt (collection, target) {
const t = Peer.normalizeTarget(target)
if (!t) return []
const out = []
for (const [author, feed] of this._readable()) {
for (const row of await feed.list(collection)) {
const rt = Peer.normalizeTarget(row.record.frontmatter && row.record.frontmatter.target)
if (!rt) continue
if (rt.feed === t.feed && rt.collection === t.collection && rt.name === t.name) {
out.push({ author, ...row })
}
}
}
return out
}
/** Every reaction on a target, from feeds I can read — `{ author, value, ... }` per row. */
async reactionsOn (target) {
return (await this._pointingAt(REACTION, target)).map((row) => ({
...row, value: (row.record.frontmatter && row.record.frontmatter.value) || null
}))
}
/**
* Repost a target — no body, and no RECORD written to their feed.
*
* ⚠ It does ring their doorbell (`_ring`), which is one row in a reserved namespace and never a
* record. The older wording here said "nothing written to their feed" and that is no longer exact.
*/
async repost (target, createdAt = new Date().toISOString()) {
const t = Peer.normalizeTarget(target)
if (!t) throw new Error('Peer.repost: a target needs { feed, collection, name }')
await this.feed.put(REPOST, Peer.targetId(t), { frontmatter: { createdAt, target: t }, body: '' })
await this._ring(t)
return Peer.targetId(t)
}
// A tombstone, like every other undo here: absence is ambiguous in a replicated log.
async unrepost (target) {
await this.feed.del(REPOST, Peer.targetId(target))
}
/** Who reposted a target, across the feeds I can read. No count — you may count what you SEE. */
async repostsOf (target) { return this._pointingAt(REPOST, target) }
/** The targets I have reposted, as a Set of targetId. My own feed, one listing for a page. */
async myReposts () {
return new Set((await this.feed.list(REPOST)).map((r) => r.name))
}
async likesOn (target) {
return (await this.reactionsOn(target)).filter((r) => r.value === LIKE_VALUE)
}
/**
* The targets I have liked — a Set of `targetId`.
*
* ⛔ MY OWN feed only. Every like lives in its author's feed, so reading this across readable
* feeds would report someone else's like as mine and light the button up for everyone.
*
* ⚠ Cheap on purpose: a like is NAMED by its target, so this is one listing of one log I already
* hold — never a scan of everyone's, which is what `likesOn` has to do and why it takes a target.
* A tombstoned like is absent because `list()` filters to resolvable records.
*/
async myLikes () {
const out = new Set()
for (const [id, values] of await this.myReactions()) {
if (values.has(LIKE_VALUE)) out.add(id)
}
return out
}
/**
* What I have reacted with, as `targetId -> Set(value)`.
*
* ⛔ MY OWN feed only, and one listing of it for a whole page. Reading this across readable feeds
* would report someone else's reaction as mine.
*/
async myReactions () {
const out = new Map()
for (const row of await this.feed.list(REACTION)) {
const fm = row.record.frontmatter || {}
const t = Peer.normalizeTarget(fm.target)
const v = Peer.normalizeValue(fm.value)
if (!t || !v) continue
const id = Peer.targetId(t)
if (!out.has(id)) out.set(id, new Set())
out.get(id).add(v)
}
return out
}
/**
* MY OWN edges in one collection, whole — target components included.
*
* ⛔ `myReactions()` COLLAPSES TO targetId -> Set(value), which is right for rendering a page ("is
* this one liked?") and useless for projecting one: the targetId is a HASH, so the feed, collection
* and name that built it cannot be recovered from it, and a bridge needs those to name the subject
* on another network.
*
* ⚠ MY OWN FEED ONLY. Reading this across readable feeds would project somebody else's like as mine
* — the same reason myReactions is scoped that way.
*/
async ownEdges (collection) {
const out = []
for (const row of await this.feed.list(collection)) {
const fm = (row.record && row.record.frontmatter) || {}
const target = Peer.normalizeTarget(fm.target)
// A row whose target will not normalise names nothing, so there is nothing to project. Skipped
// rather than thrown: one malformed record must not stop a projection pass.
if (!target) continue
out.push({
name: row.key,
collection,
target,
value: fm.value || null,
createdAt: fm.createdAt || null,
body: (row.record && row.record.body) || ''
})
}
// Oldest first: an edge is projected in the order it was made, so a reply lands after the thing
// it replies to when a whole history is projected at once.
out.sort((a, b) => String(a.createdAt || '') < String(b.createdAt || '') ? -1 : 1)
return out
}
/** Have I liked this target? The single-target form, for a page that renders one post. */
async likedByMe (target) {
const t = Peer.normalizeTarget(target)
if (!t) return false
const mine = (await this.myReactions()).get(Peer.targetId(t))
return !!mine && mine.has(LIKE_VALUE)
}
// Oldest first — a thread reads forwards, where a timeline reads backwards.
async repliesTo (target) {
const rows = await this._pointingAt(REPLY, target)
rows.sort((a, b) => {
const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
if (ta !== tb) return ta < tb ? -1 : 1
const ka = a.author + '/' + a.key
const kb = b.author + '/' + b.key
return ka < kb ? -1 : ka > kb ? 1 : 0
})
return rows
}
// ── mentions ──────────────────────────────────────────────────────────────────────────────────
//
// ⚠ There is no mention EDGE. A mention lives in the body of a post (`markdown-strip.md` §3.4), so
// finding them is a SCAN of what this node holds — the same class of work as `search`, and it will
// want an index long before it wants anything else.
/**
* Posts naming `root`, across the feeds I can read.
*
* ⛔ The target is an identity ROOT. Not a feed key and not a network identifier: §3.4 was revised
* precisely because a rotatable identifier in canonical bytes makes rotation a rewrite of every
* record that ever named you.
*/
async mentionsOf (root) {
if (typeof root !== 'string' || !root) return []
const uri = 'favela://' + root.toLowerCase()
const out = []
for (const [author, feed] of this._readable()) {
for (const row of await feed.list(POST)) {
const body = (row.record && row.record.body) || ''
// Cheap reject before parsing: the target must appear verbatim for a facet to carry it.
if (!body.toLowerCase().includes(uri)) continue
let facets = []
try { facets = strip(body).facets || [] } catch { continue }
// ⛔ A facet, never a substring. A bare `@name` is plain text with no facet and must never
// count — guessing who it meant is how a mention list starts naming people who were not.
if (!facets.some((f) => f.type === 'mention' && String(f.value).toLowerCase() === uri)) continue
out.push({ author, ...row })
}
}
return out
}
/** Posts naming ME — resolved from my own feed's founder, which is the only root I can prove. */
async myMentions () {
const founder = await this.feed.founder()
if (!founder || !founder.identityRoot) return []
return this.mentionsOf(founder.identityRoot)
}
// ── notifications ─────────────────────────────────────────────────────────────────────────────
//
// ⛔ A PROJECTION, per `SOCIAL-MODEL.md` §3: "a pure function from records to a view — derived on
// read, never stored, never replicated". Nothing is delivered, because nothing needs to be: every
// interaction is written in its AUTHOR's feed, so if you hold their feed you already have it.
//
// That settles what hyper-social #62 was stuck on. #62 framed notification as DELIVERY and found
// {stranger can notify, survives offline, bounded cost, Sybil-resistant, no relay} unsatisfiable.
// It is — for the half this does not do. What is genuinely left is a stranger you hold nothing
// of, which the DOORBELL answers and which is included below, and waking a sleeping device, which
// is a separate job entirely.
//
// ⚠ No READ STATE, deliberately. The obvious cheap version is a "seen" timestamp, and ordering
// here is by `createdAt` — which `timeline()` already documents as a CLAIM BY ITS AUTHOR, not a
// fact the log agrees on. A seen-marker compared against attacker-controlled timestamps is
// trivially defeated in both directions: post in the future to stay unread for ever, or in the
// past to be missed. Read state needs a local-only store and an ordering nobody else supplies,
// and shipping the broken version would be worse than not having it.
//
// ⚠ It is a SCAN of what this node holds, like `search` and `mentions`. It will want an index
// long before it wants anything else.
/**
* Everything other people did that concerns me, newest first.
*
* `kind` is one of: reaction · repost · reply · mention · follow · knock.
*/
/**
* What an outside network did to me, from a bridge's observation feed (hyper-social #82, I1).
*
* ⛔ ONLY WHILE ITS FOUNDER IS THE ROOT THE BRIDGE VOUCHED FOR. A feed key alone proves nothing about who writes
* it; the founder does, and it was pinned from a grant the bridge signed. A mismatch yields nothing rather than
* rows attributed to whoever happens to have founded it.
*/
async _observationRows (feed, pinnedRoot, mine) {
let founder = null
try { founder = await feed.founder() } catch {}
if (!founder || !founder.identityRoot || founder.identityRoot !== pinnedRoot) return []
const KIND = { like: 'reaction', repost: 'repost', reply: 'reply', mention: 'mention', follow: 'follow' }
const out = []
for (const row of await feed.list('observation')) {
const fm = (row.record && row.record.frontmatter) || {}
const kind = KIND[fm.kind]
if (!kind || typeof fm.actor !== 'string' || !fm.actor) continue
const t = Peer.normalizeTarget(fm.target)
const ms = Number(fm.at)
out.push({
kind,
// One observation feed carries every outside network (hyper-social #82, I2): a bsky actor is a DID.
source: fm.actor.startsWith('did:') ? 'bsky' : 'fediverse',
author: fm.actor,
at: Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : '',
target: t && t.feed === mine ? t : null,
value: kind === 'reaction' ? 'like' : null,
name: row.name,
// The record codec ends a body with a newline; the text the outside actor wrote does not.
body: ((row.record && row.record.body) || '').replace(/\n+$/, '')
})
}
return out
}
async notifications ({ limit = 100 } = {}) {
const mine = hex(this.feed.key)
const founder = await this.feed.founder()
const root = founder && founder.identityRoot ? founder.identityRoot : null
const out = []
const at = (row) => (row.record && row.record.frontmatter && row.record.frontmatter.createdAt) || ''
const aimedAtMe = (row) => {
const t = Peer.normalizeTarget(row.record && row.record.frontmatter && row.record.frontmatter.target)
return t && t.feed === mine ? t : null
}
for (const [author, feed] of this._readable()) {
// ⛔ Not my own doing. Liking your own post is ordinary; being told about it is not.
if (author === mine) continue
// ⛔ AN OBSERVATION FEED IS NOT AN AUTHOR. A bridge's feed carries what OUTSIDE actors did to me; its rows are
// theirs, never the bridge's, and it is not scanned for the bridge's own interactions.
if (this.observers.has(author)) {
for (const row of await this._observationRows(feed, this.observers.get(author), mine)) out.push(row)
continue
}
for (const collection of [REACTION, REPOST, REPLY]) {
for (const row of await feed.list(collection)) {
const target = aimedAtMe(row)
if (!target) continue
out.push({
kind: collection === REACTION ? 'reaction' : collection === REPOST ? 'repost' : 'reply',
author,
at: at(row),
target,
value: (row.record.frontmatter && row.record.frontmatter.value) || null,
name: row.name,
body: (row.record && row.record.body) || ''
})
}
}
// A follow of ME is a record in THEIR feed named by MY feed key.
for (const row of await feed.list(FOLLOW)) {
if (row.name !== mine) continue
out.push({ kind: 'follow', author, at: at(row), target: null, value: null, name: row.name, body: '' })
}
}
// Mentions reuse the facet-based projection rather than re-deciding what a mention is.
if (root) {
for (const row of await this.mentionsOf(root)) {
if (row.author === mine) continue
out.push({
kind: 'mention',
author: row.author,
at: (row.record.frontmatter && row.record.frontmatter.createdAt) || '',
target: { feed: row.author, collection: row.collection, name: row.name },
value: null,
name: row.name,
body: (row.record && row.record.body) || ''
})
}
}
// ⛔ The doorbell. Everything above needs their feed; this is what somebody you hold NOTHING of
// can still do, and it is the whole answer to the stranger half of the problem.
//
// ⛔ A KNOCK FROM A FEED WE ALREADY HOLD IS NOT NEWS, and it is skipped. Since an interaction
// rings the doorbell (`_ring`), someone we already follow now rings it every time they react —
// and we can already READ everything they did, so the knock would be a second notification for
// an act that already produced one. The doorbell's own purpose is the stranger; that is exactly
// the set left here.
//
// ⚠ Skipped for RENDERING, not dismissed. The row stays in the feed, so accepting it later still
// works and nothing about consent is decided by this filter.
const held = new Set([...this.feeds.keys()])
for (const k of await this.feed.knocks()) {
if (held.has(k.feed)) continue
out.push({ kind: 'knock', author: k.feed, at: '', target: null, value: null, name: k.feed, body: '' })
}
out.sort((a, b) => {
if (a.at !== b.at) return a.at < b.at ? 1 : -1
const ka = a.kind + '/' + a.author + '/' + a.name
const kb = b.kind + '/' + b.author + '/' + b.name
return ka < kb ? -1 : ka > kb ? 1 : 0
})
return limit === Infinity ? out : out.slice(0, limit)
}
// ── the registry ──────────────────────────────────────────────────────────────────────────────
//
// A registry is a FEED whose records are listings. Mine is just my own feed used that way; someone
// else's is one I follow. There is no registry type, no registry server, and nothing central —
// which is the only thing that keeps the one we happen to run replaceable rather than privileged.
// Ask a registry to list me. This is a knock, which is to say: the doorbell already IS the consent
// mechanism, and being listed needs no separate protocol.
async askToBeListed (registryFeed, myFeedKey = this.feed.key) {
await registryFeed.knock(myFeedKey, this._binder())
}
// Honour a request. Listing then dismissing, in that order — apply refuses a listing whose knock
// is already tombstoned.
async listFeed (feedKey, record) {
const root = await this._knockRootFor(feedKey)
if (!root) throw new Error('no live, proven knock names that feed — it has not asked to be listed')
const r = await this.feed.listFeed(root, feedKey, record)
await this.feed.dismissKnock(root)
return r
}
async delist (feedKey) { await this.feed.delist(feedKey) }
// Every listing across the registries I hold, plus my own. Duplicates are kept: the same feed
// listed by two registries is two endorsements, and collapsing them would throw away who vouched.
//
// ⛔ `verified` closes the half of consent apply cannot see. A listing records the identity root
// that knocked; only a reader holding the listed feed can check that the feed's founder IS that
// root. true = checked and matches; false = checked and does NOT (treat as not listed); null = we do
// not hold that feed, so it is unchecked.
async directory () {
const out = []
for (const [registry, feed] of this._readable()) {
for (const l of await feed.listings()) {
let verified = null
const listed = this.feeds.get(l.feed)
if (listed && typeof listed.founder === 'function') {
const founder = await listed.founder()
verified = !!(founder && l.root && founder.identityRoot === l.root)
}
out.push({ registry, ...l, verified })
}
}
out.sort((a, b) => {
const ka = a.registry + '/' + a.feed
const kb = b.registry + '/' + b.feed
return ka < kb ? -1 : ka > kb ? 1 : 0
})
return out
}
// ── the doorbell ──────────────────────────────────────────────────────────────────────────────
async knocks () { return this.feed.knocks() }
// ⛔ Accepting a knock means I FOLLOW THEM. It does not make them a writer on my feed, and there
// is deliberately no method here that would. Write access to a feed belongs to its owner's devices
// and to nobody else.
async admitKnock (feedKey, feed = null) {
const name = await this.follow(feedKey, feed)
const root = await this._knockRootFor(feedKey)
if (root) await this.feed.dismissKnock(root)
return name
}
async dismissKnock (feedKey) {
const root = await this._knockRootFor(feedKey)
if (root) await this.feed.dismissKnock(root)
}
async following () {
const rows = await this.feed.list(FOLLOW)
return rows.map((r) => r.name).sort()
}
// Follows we cannot read: recorded, but no feed held. Named rather than silently absent, because
// "nobody posted" and "I have not replicated them" look identical in a timeline and are not the
// same fact.
async missing () {
const following = await this.following()
return following.filter((name) => !this.feeds.has(name))
}
// Every feed we can read: our own, plus the held ones. Following is NOT required to be held, and
// holding is not required to be followed.
/**
* Every feed I can read: mine, plus the held ones, MINUS the blocked.
*
* ⛔ Blocking is enforced HERE and nowhere else. Every projection in this file goes through this
* method, so filtering the timeline and forgetting search is how a blocked person reappears in
* the one view nobody thought about. One place decides.
*
* ⚠ A blocked feed is still HELD — still on disk, still replicating. Nothing here can
* un-replicate what already arrived, and a block that claimed to delete their bytes would be a
* promise this substrate cannot keep. It stops being rendered; it does not vanish.
*/
_readable () {
const out = [[hex(this.feed.key), this.feed]]
for (const [name, feed] of this.feeds) {
if (this._blocked.has(name)) continue
out.push([name, feed])
}
return out
}
async update () {
await this.feed.update()
for (const [, feed] of this.feeds) await feed.update()
this._follows = new Set(await this.following())
this._blocked = await this.blocked()
for (const name of this._follows) this.peeked.delete(name)
}
/**
* The timeline: posts from every readable feed, newest first.
*
* ⚠ Ordered by `createdAt`, which is WALL TIME and therefore a claim by its author, not a fact the
* log agrees on. Positions are per-feed counters and are not comparable across feeds, so there is
* no cross-feed ordering the substrate can supply. A reader's timeline is a rendering; this is the
* ordering readers expect, and it is stated rather than implied.
*
* Ties break on (author, name) so the same inputs always produce the same order — a timeline that
* reshuffled on refresh would be indistinguishable from one that had new content.
*/
async timeline ({ limit = Infinity } = {}) {
const out = []
// ⛔ Followed feeds and my own — NOT everything readable. A peeked feed is held so its page can
// render; putting it here would make reading a stranger's page indistinguishable from
// following them, which is the whole thing peek exists to avoid.
for (const [author, feed] of this._readable()) {
if (this.peeked.has(author)) continue
for (const row of await feed.list(POST)) {
out.push({
key: author + '/' + row.key,
author,
collection: row.collection,
name: row.name,
record: row.record
})
}
}
out.sort((a, b) => {
const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
if (ta !== tb) return ta < tb ? 1 : -1 // newest first
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0
})
return limit === Infinity ? out : out.slice(0, limit)
}
/**
* Every post by one author, newest first.
*
* ⛔ Its own read rather than a filter over a 200-row timeline, which is what the person page
* did. Once the timeline stopped including peeked feeds, that filter could no longer render the
* page of somebody you had only peeked — the exact case peek exists for.
*/
async postsBy (feedKey) {
const id = hex(feedKey)
// ⚠ This one reads a feed DIRECTLY rather than through `_readable`, so it repeats the check.
// A person page that still rendered someone you blocked would be the same bug by another door.
if (this._blocked.has(id)) return []
const feed = id === hex(this.feed.key) ? this.feed : this.feeds.get(id)
if (!feed) return []
const out = (await feed.list(POST)).map((row) => ({
key: id + '/' + row.key, author: id, collection: row.collection, name: row.name, record: row.record
}))
out.sort((a, b) => {
const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
if (ta !== tb) return ta < tb ? 1 : -1
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0
})
return out
}
/**
* Local full-text search over the records this node holds. No index, no crawler, no relay.
*
* It searches the markdown SOURCE rather than the stripped text. The source is the canonical
* bytes, so a substring match over it finds strictly more and never less — "world" matches
* "**world**", and text inside a table or a link target stays findable. Searching a lossy
* projection would make findability depend on whatever that projection happened to drop.
*
* ⛔ Returns `{ results, unreadable }`. "No results" and "no results, and twelve records I hold
* pointers for but cannot fetch" are different answers. A search box that renders them alike is
* the silent-absence bug this codebase refuses everywhere else.
*
* No relevance ranking. Ordering is newest first with a deterministic tie-break, the same as the
* timeline — a score nobody can explain is worse than an order everyone can predict.
*
* ⚠ It is a SCAN. Every held record is read on every query. That is honest for the sizes this is
* built for and it will need an index long before it needs a ranker.
*/
async search (query, { collection = null, limit = Infinity } = {}) {
const q = String(query == null ? '' : query).toLowerCase()
// Fail closed: an empty box is not a request for everything.
if (!q) return { results: [], unreadable: 0 }
const results = []
let unreadable = 0
for (const [author, feed] of this._readable()) {
for (const row of await feed.list(collection)) {
const body = (row.record && row.record.body) || ''
const hits = countOf(body.toLowerCase(), q)
if (hits > 0) results.push({ author, hits, ...row })
}
// Counted, not skipped. Note what this number is and is not: we cannot know whether an
// unreadable record WOULD have matched, because we cannot read it. That is precisely why it is
// reported as a separate figure rather than folded into the results — the honest statement is
// "n records in scope could not be read", not "n matches were lost".
unreadable += (await feed.unresolved(collection)).length
}
results.sort((a, b) => {
const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
if (ta !== tb) return ta < tb ? 1 : -1
const ka = a.author + '/' + a.key
const kb = b.author + '/' + b.key
return ka < kb ? -1 : ka > kb ? 1 : 0
})
return { results: limit === Infinity ? results : results.slice(0, limit), unreadable }
}
// Records we hold a pointer for and cannot read, across every readable feed. Kept separate from
// the timeline for the same reason `missing()` is: an unreadable record must never look like an
// absent one.
async unresolved () {
const out = []
for (const [author, feed] of this._readable()) {
for (const row of await feed.unresolved()) out.push({ author, ...row })
}
return out
}
async close () {
await this.feed.close()
for (const [, feed] of this.feeds) await feed.close()
}
}