// favela-feed.js — read a favela feed the bridge HOLDS, over the swarm.
//
// ⛔ THE DEFECT THIS REPLACES, and it was mine. `FavelaSource` reads its peer over a UNIX SOCKET,
// so the bridge has to run on the same box as the peer it projects. That is incompatible with what
// a bridge IS: a service peers CONNECT TO. `PerUser` exists so one bridge fronts many people, and
// it could never actually do that — they would all have had to share the bridge's machine. One
// instance per person is a hostname per person, which is the thing #65 §7 set out to remove.
//
// A favela feed is a hypercore on a discovery topic. The bridge holds it read-only exactly as any
// follower does — the same way tabertron holds ashatron's — and reads the same records out of it.
//
// ★ NOTHING IN FavelaSource CHANGES. It needs exactly three things from its peer: `reach()`,
// `ownPosts()` and `call('file-get')`. This presents those three from a held feed, so the socket
// becomes one adapter of two rather than the only way in. That is the same shape favela-peer's own
// `rpc.js` already has, where `dispatch()` is the interface and the socket is an adapter over it.
//
// ⛔ READ-ONLY BY CONSTRUCTION. There is no put, no append, no setReach — not disabled, ABSENT. A
// bridge holds somebody else's feed; the entire keyless claim rests on it being unable to write
// there, and a bridge that could write would be authoring under their name.
import b4a from 'b4a'
// ⛔ ONE ladder, owned by the vocabulary that defines it (record-vocabulary §3.5).
import { selectLevel } from 'favela-markdown/lib/levels.js'
const REACH = 'reach'
const SELF = 'self'
const POST = 'post'
const PROFILE = 'profile'
const BLOB = 'blob'
export class FavelaFeed {
/**
* @param feed a held favela Feed — read-only. `update()`, `get(collection, name)`, `list(c)`.
* @param files optional; describes a blob by oid for attachments. Without one, a record's
* pictures are omitted rather than guessed at.
*/
constructor ({ feed, files = null }) {
if (!feed) throw new Error('FavelaFeed: a held feed is required')
this.feed = feed
this.files = files
}
/** The feed key, hex — what `ownPosts()` reports and what the registration recorded. */
get key () {
const k = this.feed.key
return typeof k === 'string' ? k : b4a.toString(k, 'hex')
}
/**
* Which networks this identity has turned on, from its own feed.
*
* ⛔ UNKNOWN CONSENT IS NOT CONSENT. A feed whose reach record has not replicated yet returns an
* empty list, so a projection that needs the fediverse is withheld. Reading absence as "carry on"
* would publish somebody's records under a guess about what they agreed to.
*/
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 : [],
credential: fm.credential || null
}
}
/**
* This feed's posts, in the shape `posts-by` returns over the socket — so FavelaSource cannot
* tell which adapter it was handed.
*/
async ownPosts () {
await this.feed.update()
const rows = await this.feed.list(POST)
return {
feed: this.key,
rows: rows.map((r) => ({
name: r.name,
collection: r.collection,
body: (r.record && r.record.body) || '',
createdAt: (r.record && r.record.frontmatter && r.record.frontmatter.createdAt) || null
}))
}
}
/**
* The posts and replies this feed's owner DELETED — tombstones, read off the held feed.
*
* ⛔ POSITIVE evidence, the only kind a Delete may be sent on: a post missing from ownPosts() could
* be deleted or not replicated yet. A held feed from a favela-peer without `tombstones()` reports
* none rather than failing — it simply cannot say.
*/
async ownDeleted () {
await this.feed.update()
if (typeof this.feed.tombstones !== 'function') return { post: [], reply: [] }
return { post: await this.feed.tombstones(POST), reply: await this.feed.tombstones('reply') }
}
/**
* This identity's profile record — `profile/self` on their own feed.
*
* ⛔ It travels WITH the feed, which is the point: a bridge holding somebody's feed knows who
* they say they are without being told separately, and cannot be handed a name that is not theirs.
*/
async profile () {
await this.feed.update()
return this.feed.get(PROFILE, SELF)
}
/**
* The socket verbs this adapter supports — currently one.
*
* ⛔ An unknown command THROWS rather than returning null. A silent null here would look exactly
* like "that blob does not exist", and a caller would render a post with its picture quietly
* missing instead of learning that the bridge cannot do what it asked.
*/
async call (cmd, args = {}) {
if (cmd === 'file-get') {
if (!this.files || typeof this.files.get !== 'function') return null
return this.files.get(args && args.oid)
}
/**
* ⛔ SELECT A LEVEL — the bridge's half of the mip pyramid (SOCIAL-MODEL §6.6.1). The pointer
* travels IN the feed, which is the whole reason it is a record rather than a local index: this
* adapter holds somebody else's feed and nothing else, and can still choose.
*
* ⚠ A blob with no pointer has one level, which is every blob written before the pyramid
* existed. Absence is the single-level case, never an error.
*/
if (cmd === 'blob-level') {
const oid = args && args.oid
const maxBytes = (args && args.maxBytes) || Infinity
await this.feed.update()
const rec = await this.feed.get(BLOB, oid)
const fm = (rec && rec.frontmatter) || null
if (fm && Array.isArray(fm.levels)) return selectLevel(fm, { maxBytes })
const info = this.files && typeof this.files.get === 'function' ? await this.files.get(oid) : null
if (!info) return { level: null, reason: 'no such blob' }
return (info.size || 0) <= maxBytes
? { level: { oid, w: 0, h: 0, size: info.size }, reason: 'single level' }
: { level: null, reason: 'the only level is ' + info.size + ' bytes against a ceiling of ' + maxBytes }
}
throw new Error('FavelaFeed: unsupported command "' + cmd + '" — this adapter is read-only')
}
}
export default { FavelaFeed }