OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 5b647c5

0.0.64: hold the observation feed a bridge vouched for; outside interactions become notifications (I1 S4)
A claim reply's observer grant is kept only if the bridge we claimed with signed it (favela-identity 0.0.5), stored in observers.json beside bridges.json, and the feed is held at once and at every boot. notifications() reads it ONLY while its founder is the pinned root, as source:fediverse rows authored by the outside actor. At boot, a remembered bridge with no grant is re-claimed once (idempotent at the bridge), so existing registrations pick up their feed. Release forgets it. Test-first; 416/416. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5b647c5b2e46cef32a3e3f489cd9c7d32ca6c3b8
by plankatron <fleeky@gmail.com> · 2026-10-14T19:57:53.000Z

Changed files (7) +281 -3

lib/bridge.js +56 -0
……@@ -183,3 +183,59 @@ export function forgetBridge (storage, acct) {
183183 delete all[acct]
184184 try { fs.writeFileSync(bridgesPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
185185}
186
187// ── the observation feed a bridge vouched for (hyper-social #82, I1 S4) ─────────────────────────────────────
188//
189// ⛔ VERIFIED AGAINST THE BRIDGE KEY THIS NODE CLAIMED WITH (operator decision O-2 b). A bridge writes what the
190// outside world did to this user into a feed IT founded, and tells the node which feed and which founder root in
191// the claim reply, signed with its swarm key. The grant is kept only if that signature holds for this user and
192// generation; a node never holds — so never renders — a feed its bridge did not vouch for.
193//
194// ⚠ LOCAL, like bridges.json: which feed a bridge keeps for this node is operational detail, not something the
195// identity publishes. Revocation is local too: forget the bridge, forget its feed.
196
197import { verifyObserver } from 'favela-identity/lib/observer-grant.js'
198
199const observersPath = (storage) => join(storage, 'observers.json')
200const readObservers = (storage) => {
201 try { return JSON.parse(fs.readFileSync(observersPath(storage), 'utf8')) || {} } catch { return {} }
202}
203const writeObservers = (storage, all) => {
204 try { fs.writeFileSync(observersPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
205}
206
207/** Every remembered bridge, as [acct, bridgeKeyHex]. */
208export function bridges (storage) { return Object.entries(readBridges(storage)) }
209
210/**
211 * Keep the observer grant from a claim reply, if `bridgeKey` signed it for this user. → the stored record, or null.
212 */
213export function acceptObserver (storage, bridgeKey, body) {
214 if (!body || !body.observer || !body.acct || !body.username) return null
215 const key = String(bridgeKey || '').replace(/^bridge:/, '').toLowerCase()
216 const o = body.observer
217 const grant = { username: body.username, generation: body.generation, observationFeed: o.observationFeed, observerRoot: o.observerRoot }
218 if (!verifyObserver(grant, o.sig, key)) return null
219 const rec = {
220 acct: body.acct,
221 bridge: key,
222 username: body.username,
223 generation: Number(body.generation) | 0,
224 observationFeed: String(o.observationFeed).toLowerCase(),
225 observerRoot: String(o.observerRoot).toLowerCase()
226 }
227 const all = readObservers(storage)
228 all[body.acct] = rec
229 writeObservers(storage, all)
230 return rec
231}
232
233/** Every observation feed this node holds on a bridge's word. */
234export function observers (storage) { return Object.values(readObservers(storage)) }
235
236export function forgetObserver (storage, acct) {
237 const all = readObservers(storage)
238 if (!(acct in all)) return
239 delete all[acct]
240 writeObservers(storage, all)
241}
lib/node.js +43 -0
……@@ -30,6 +30,7 @@ import { loadNamespace, saveNamespace, namespaceSlot, slotFor, loadNamespaces, s
3030import { Room } from './room.js'
3131import { readAttested } from './attestation.js'
3232import { createMirror } from './mirror.js'
33import { connectBridge, buildClaim, bridges, observers, acceptObserver } from './bridge.js'
3334
3435const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
3536
……@@ -253,6 +254,16 @@ export class FavelaNode {
253254 try { await this.hold(name) } catch {}
254255 }
255256
257 // ⛔ AND THE OBSERVATION FEEDS BRIDGES VOUCHED FOR (hyper-social #82, I1): what the outside world did to this
258 // user, written by a bridge and held here like any feed — read only as notifications, only while its founder is
259 // the pinned root. Then, NOT awaited: any bridge remembered from before grants existed is asked once, by a
260 // re-claim of the same name (idempotent at the bridge), so an existing registration picks up its feed with
261 // nobody pressing anything.
262 for (const o of observers(this.storage)) {
263 try { await this.holdObserver(o) } catch {}
264 }
265 this._refreshObserverGrants().catch(() => {})
266
256267 // ⚠ NOT awaited, and NOT sufficient on its own. A first sweep must not sit inside boot. And an
257268 // end-to-end run on two machines showed the boot pass alone finds nothing: at boot a followed
258269 // feed's log has usually not replicated yet, so the real work is done by the 'update' watcher
……@@ -274,6 +285,38 @@ export class FavelaNode {
274285
275286 get key () { return this.peer.feed.key }
276287
288 /** Hold a bridge's observation feed, pinned to the founder root the bridge vouched for. */
289 async holdObserver (o) {
290 if (!o || !o.observationFeed || !o.observerRoot) return null
291 this.peer.observers.set(String(o.observationFeed).toLowerCase(), String(o.observerRoot).toLowerCase())
292 return this.hold(o.observationFeed)
293 }
294
295 /**
296 * Ask each remembered bridge with no grant yet for this node's observation feed, by re-claiming the same name.
297 *
298 * ⚠ A re-claim of the SAME name, root and feed is idempotent at the bridge (it answers with the grant). It needs
299 * this device's credential to sign, and a reachable bridge; either missing just means "next boot".
300 */
301 async _refreshObserverGrants () {
302 if (!this.cred || !this.swarm) return
303 const have = new Set(observers(this.storage).map((o) => o.acct))
304 for (const [acct, bridgeKey] of bridges(this.storage)) {
305 if (have.has(acct)) continue
306 const name = String(acct).split('@')[0]
307 if (!name) continue
308 try {
309 const { rpc } = await connectBridge(this.swarm, bridgeKey)
310 const { nonce } = await rpc.request('challenge', {})
311 const r = await rpc.request('claim', buildClaim({ nonce, username: name, feedKey: this.key.toString('hex'), root: this.rootHex(), cred: this.cred }))
312 if (r && (r.status === 200 || r.status === 202)) {
313 const o = acceptObserver(this.storage, bridgeKey, r.body)
314 if (o) await this.holdObserver(o)
315 }
316 } catch { /* unreachable or refused: asked again on the next boot */ }
317 }
318 }
319
277320 /**
278321 * Announce everything `ready()` opened.
279322 *
lib/peer.js +45 -0
……@@ -174,6 +174,10 @@ export class Peer {
174174 // to this device's identity root. The node sets it from its credential (node.js); unset means
175175 // this device is not linked, and a knock refuses rather than going out unproven (feed.js).
176176 this.bind = null
177 // Observation feeds a bridge vouched for: hex feed key -> the founder root it vouched for (hyper-social #82, I1).
178 // Held like any feed, but read ONLY as notifications from outside actors, and only while that feed's founder
179 // IS the pinned root. The node fills this from the stored grants (bridge.js acceptObserver).
180 this.observers = new Map()
177181 }
178182
179183 _binder () {
……@@ -802,6 +806,40 @@ export class Peer {
802806 *
803807 * `kind` is one of: reaction · repost · reply · mention · follow · knock.
804808 */
809 /**
810 * What an outside network did to me, from a bridge's observation feed (hyper-social #82, I1).
811 *
812 * ⛔ ONLY WHILE ITS FOUNDER IS THE ROOT THE BRIDGE VOUCHED FOR. A feed key alone proves nothing about who writes
813 * it; the founder does, and it was pinned from a grant the bridge signed. A mismatch yields nothing rather than
814 * rows attributed to whoever happens to have founded it.
815 */
816 async _observationRows (feed, pinnedRoot, mine) {
817 let founder = null
818 try { founder = await feed.founder() } catch {}
819 if (!founder || !founder.identityRoot || founder.identityRoot !== pinnedRoot) return []
820 const KIND = { like: 'reaction', repost: 'repost', reply: 'reply', mention: 'mention', follow: 'follow' }
821 const out = []
822 for (const row of await feed.list('observation')) {
823 const fm = (row.record && row.record.frontmatter) || {}
824 const kind = KIND[fm.kind]
825 if (!kind || typeof fm.actor !== 'string' || !fm.actor) continue
826 const t = Peer.normalizeTarget(fm.target)
827 const ms = Number(fm.at)
828 out.push({
829 kind,
830 source: 'fediverse',
831 author: fm.actor,
832 at: Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : '',
833 target: t && t.feed === mine ? t : null,
834 value: kind === 'reaction' ? 'like' : null,
835 name: row.name,
836 // The record codec ends a body with a newline; the text the outside actor wrote does not.
837 body: ((row.record && row.record.body) || '').replace(/\n+$/, '')
838 })
839 }
840 return out
841 }
842
805843 async notifications ({ limit = 100 } = {}) {
806844 const mine = hex(this.feed.key)
807845 const founder = await this.feed.founder()
……@@ -818,6 +856,13 @@ export class Peer {
818856 // ⛔ Not my own doing. Liking your own post is ordinary; being told about it is not.
819857 if (author === mine) continue
820858
859 // ⛔ AN OBSERVATION FEED IS NOT AN AUTHOR. A bridge's feed carries what OUTSIDE actors did to me; its rows are
860 // theirs, never the bridge's, and it is not scanned for the bridge's own interactions.
861 if (this.observers.has(author)) {
862 for (const row of await this._observationRows(feed, this.observers.get(author), mine)) out.push(row)
863 continue
864 }
865
821866 for (const collection of [REACTION, REPOST, REPLY]) {
822867 for (const row of await feed.list(collection)) {
823868 const target = aimedAtMe(row)
lib/rpc.js +10 -2
……@@ -443,8 +443,12 @@ export async function dispatch (node, cmd, a) {
443443 // ⛔ Remember WHICH bridge. Releasing needs the key again and `name@host` does not contain it;
444444 // asking a person to paste it a second time, for the one irreversible act here, is how the
445445 // wrong bridge gets asked.
446 const { rememberBridge } = await import('./bridge.js')
446 const { rememberBridge, acceptObserver } = await import('./bridge.js')
447447 rememberBridge(node.storage, r.body && r.body.acct, a.bridge)
448 // ⛔ AND THE OBSERVATION FEED THE BRIDGE VOUCHED FOR (hyper-social #82, I1) — kept only if the bridge we just
449 // claimed with signed it, then held right away so outside interactions start arriving without a restart.
450 const o = acceptObserver(node.storage, a.bridge, r.body)
451 if (o && typeof node.holdObserver === 'function') node.holdObserver(o).catch(() => {})
448452 return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false }
449453 }
450454 // ⛔ CARRIED, not named. A bridge that fronts an identity serves what that identity SIGNED — it
……@@ -459,7 +463,7 @@ export async function dispatch (node, cmd, a) {
459463 }
460464
461465 case 'bridge-release': {
462 const { connectBridge, buildRelease, bridgeFor, forgetBridge } = await import('./bridge.js')
466 const { connectBridge, buildRelease, bridgeFor, forgetBridge, forgetObserver } = await import('./bridge.js')
463467 // ⚠ The remembered key wins over one supplied by the caller: the node knows which bridge it
464468 // actually claimed on, and a release sent to the wrong bridge is a request to burn a name
465469 // somewhere it was never held.
……@@ -469,6 +473,10 @@ export async function dispatch (node, cmd, a) {
469473 const r = await rpc.request('release', buildRelease({ nonce, username: a.name, root: node.rootHex(), cred: node.cred }))
470474 if (r.status !== 200) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
471475 forgetBridge(node.storage, a.acct)
476 // The bridge's observation feed goes with it: its founder is no longer vouched for, so it renders nothing.
477 const gone = (await import('./bridge.js')).observers(node.storage).find((o) => o.acct === a.acct)
478 forgetObserver(node.storage, a.acct)
479 if (gone && node.peer && node.peer.observers) node.peer.observers.delete(gone.observationFeed)
472480 return r.body
473481 }
474482
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.63",
3 "version": "0.0.64",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/bridge-observer.test.js +52 -0
……@@ -0,0 +1,52 @@
1// bridge-observer.test.js — a node keeps the observation feed a bridge vouched for, and nothing it did not
2// (hyper-social #82, I1 S4, operator decision O-2 b).
3//
4// ⛔ VERIFIED AGAINST THE BRIDGE KEY THIS NODE CLAIMED WITH. The grant arrives in the claim reply; it is stored
5// only if it is signed by that bridge for this user and generation. A grant that fails is not stored, so the node
6// never holds — and never renders — a feed the bridge did not vouch for.
7
8import test from 'brittle'
9import fs from '#node:fs'
10import os from '#node:os'
11import { join } from '#node:path'
12import b4a from 'b4a'
13import crypto from 'hypercore-crypto'
14import { signObserver } from 'favela-identity/lib/observer-grant.js'
15import { acceptObserver, observers, forgetObserver } from '../lib/bridge.js'
16
17const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-obs-'))
18
19function claimReply (bridge, over = {}) {
20 const grant = { username: 'fleeky', generation: 1, observationFeed: 'f6'.repeat(32), observerRoot: 'e4'.repeat(32), ...over }
21 return { username: 'fleeky', generation: 1, acct: 'fleeky@ap.7rnx.net', observer: { observationFeed: grant.observationFeed, observerRoot: grant.observerRoot, sig: signObserver(grant, bridge) } }
22}
23
24test('★ a grant signed by the bridge this node claimed with is stored', (t) => {
25 const storage = tmp()
26 const bridge = crypto.keyPair()
27 const key = b4a.toString(bridge.publicKey, 'hex')
28 const o = acceptObserver(storage, key, claimReply(bridge))
29 t.alike(o, { acct: 'fleeky@ap.7rnx.net', bridge: key, username: 'fleeky', generation: 1, observationFeed: 'f6'.repeat(32), observerRoot: 'e4'.repeat(32) })
30 t.alike(observers(storage), [o], 'and listed')
31})
32
33test('⛔ a grant from another key, or for another user, is refused and NOT stored', (t) => {
34 const storage = tmp()
35 const bridge = crypto.keyPair()
36 const other = crypto.keyPair()
37 const key = b4a.toString(bridge.publicKey, 'hex')
38 t.is(acceptObserver(storage, key, claimReply(other)), null, 'signed by someone else')
39 const forged = claimReply(bridge)
40 forged.username = 'ashatron' // the grant was for fleeky
41 t.is(acceptObserver(storage, key, forged), null, 'replayed to another user')
42 t.is(acceptObserver(storage, key, { username: 'fleeky', generation: 1, acct: 'fleeky@ap.7rnx.net' }), null, 'no grant at all')
43 t.alike(observers(storage), [])
44})
45
46test('forgetting a bridge forgets its observation feed', (t) => {
47 const storage = tmp()
48 const bridge = crypto.keyPair()
49 acceptObserver(storage, b4a.toString(bridge.publicKey, 'hex'), claimReply(bridge))
50 forgetObserver(storage, 'fleeky@ap.7rnx.net')
51 t.alike(observers(storage), [])
52})
test/notifications.test.js +74 -0
……@@ -166,3 +166,77 @@ test('⚠ only feeds I HOLD can notify me', async (t) => {
166166 // copy of can like your post and you will never know — which is exactly why the doorbell exists.
167167 t.alike(await me.peer.notifications(), [])
168168})
169
170// ── the fediverse, through a bridge's OBSERVATION FEED (hyper-social #82, I1 S4) ──────────────────────────────
171//
172// ⛔ A FEED THIS NODE HOLDS BUT DOES NOT TRUST BLINDLY. A bridge writes what the fediverse did to me into a feed IT
173// founded; I hold it like any feed, and it becomes notifications only while its founder is the observer root the
174// bridge vouched for at registration (pinned in `peer.observers`). The author is the outside actor — an AP URI,
175// never a favela feed — and the row says where it came from.
176const OBSERVATION = 'observation'
177
178async function gatewayFeed () {
179 const gw = await identity()
180 const d = await gw.device()
181 const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(tmp()), optimistic: true })
182 await f.ready()
183 await f.addDevice(d.bind(f.deviceKey))
184 await f.update()
185 return { feed: f, root: gw.rootHex }
186}
187
188const obs = (kind, extra = {}) => ({
189 frontmatter: { kind, actor: 'https://prsm.space/users/alice', subject: null, target: null, at: 1792005677219, activity: 'https://prsm.space/a/' + kind, ...extra },
190 body: extra.body || ''
191})
192
193test('★★ observations from a pinned bridge feed become fediverse notifications', async (t) => {
194 const me = await mkPeer()
195 const gw = await gatewayFeed()
196 t.teardown(() => Promise.all([me.peer.close(), gw.feed.close()]))
197 await me.peer.feed.put(POST, 'p1', rec('my post'))
198 await me.peer.update()
199 const target = { feed: me.peer.feed.key.toString('hex'), collection: POST, name: 'p1' }
200
201 await gw.feed.put(OBSERVATION, 'o1', obs('like', { target }))
202 await gw.feed.put(OBSERVATION, 'o2', { ...obs('reply', { target, activity: 'https://prsm.space/notes/9' }), body: 'nice post!' })
203 await gw.feed.put(OBSERVATION, 'o3', obs('follow'))
204 await gw.feed.update()
205
206 const gwHex = gw.feed.key.toString('hex')
207 me.peer.feeds.set(Peer.nameFor(gw.feed.key), gw.feed)
208 me.peer.observers.set(gwHex, gw.root)
209
210 const n = (await me.peer.notifications()).filter((x) => x.source === 'fediverse')
211 t.alike(n.map((x) => x.kind).sort(), ['follow', 'reaction', 'reply'])
212 t.ok(n.every((x) => x.author === 'https://prsm.space/users/alice'), 'the author is the outside actor, not the bridge feed')
213 const reply = n.find((x) => x.kind === 'reply')
214 t.is(reply.body, 'nice post!', 'with the reply text')
215 t.alike(reply.target, target, 'aimed at my post')
216 t.ok(/^2026-/.test(reply.at), 'an ISO time, so it sorts with every other notification')
217})
218
219test('⛔ a held feed whose founder is NOT the pinned observer root yields nothing', async (t) => {
220 const me = await mkPeer()
221 const gw = await gatewayFeed()
222 const impostor = await identity()
223 t.teardown(() => Promise.all([me.peer.close(), gw.feed.close()]))
224 await gw.feed.put(OBSERVATION, 'o1', obs('follow'))
225 await gw.feed.update()
226 me.peer.feeds.set(Peer.nameFor(gw.feed.key), gw.feed)
227 me.peer.observers.set(gw.feed.key.toString('hex'), impostor.rootHex) // pinned to somebody else
228 const n = (await me.peer.notifications()).filter((x) => x.source === 'fediverse')
229 t.is(n.length, 0, 'not the founder the bridge vouched for → not rendered')
230})
231
232test('an observation feed is never scanned as an ordinary feed', async (t) => {
233 const me = await mkPeer()
234 const gw = await gatewayFeed()
235 t.teardown(() => Promise.all([me.peer.close(), gw.feed.close()]))
236 await gw.feed.put(OBSERVATION, 'o1', obs('follow'))
237 await gw.feed.update()
238 me.peer.feeds.set(Peer.nameFor(gw.feed.key), gw.feed)
239 me.peer.observers.set(gw.feed.key.toString('hex'), gw.root)
240 const n = await me.peer.notifications()
241 t.ok(n.every((x) => x.author !== gw.feed.key.toString('hex')), 'no row is attributed to the bridge feed itself')
242})