| … | … | @@ -183,3 +183,59 @@ export function forgetBridge (storage, acct) { |
| 183 | 183 | delete all[acct] |
| 184 | 184 | try { fs.writeFileSync(bridgesPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {} |
| 185 | 185 | } |
| 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 | ||
| 197 | import { verifyObserver } from 'favela-identity/lib/observer-grant.js' | |
| 198 | ||
| 199 | const observersPath = (storage) => join(storage, 'observers.json') | |
| 200 | const readObservers = (storage) => { | |
| 201 | try { return JSON.parse(fs.readFileSync(observersPath(storage), 'utf8')) || {} } catch { return {} } | |
| 202 | } | |
| 203 | const 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]. */ | |
| 208 | export 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 | */ | |
| 213 | export 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. */ | |
| 234 | export function observers (storage) { return Object.values(readObservers(storage)) } | |
| 235 | ||
| 236 | export function forgetObserver (storage, acct) { | |
| 237 | const all = readObservers(storage) | |
| 238 | if (!(acct in all)) return | |
| 239 | delete all[acct] | |
| 240 | writeObservers(storage, all) | |
| 241 | } |
| … | … | @@ -30,6 +30,7 @@ import { loadNamespace, saveNamespace, namespaceSlot, slotFor, loadNamespaces, s |
| 30 | 30 | import { Room } from './room.js' |
| 31 | 31 | import { readAttested } from './attestation.js' |
| 32 | 32 | import { createMirror } from './mirror.js' |
| 33 | import { connectBridge, buildClaim, bridges, observers, acceptObserver } from './bridge.js' | |
| 33 | 34 | |
| 34 | 35 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| 35 | 36 | |
| … | … | @@ -253,6 +254,16 @@ export class FavelaNode { |
| 253 | 254 | try { await this.hold(name) } catch {} |
| 254 | 255 | } |
| 255 | 256 | |
| 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 | ||
| 256 | 267 | // ⚠ NOT awaited, and NOT sufficient on its own. A first sweep must not sit inside boot. And an |
| 257 | 268 | // end-to-end run on two machines showed the boot pass alone finds nothing: at boot a followed |
| 258 | 269 | // 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 { |
| 274 | 285 | |
| 275 | 286 | get key () { return this.peer.feed.key } |
| 276 | 287 | |
| 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 | ||
| 277 | 320 | /** |
| 278 | 321 | * Announce everything `ready()` opened. |
| 279 | 322 | * |
| … | … | @@ -174,6 +174,10 @@ export class Peer { |
| 174 | 174 | // to this device's identity root. The node sets it from its credential (node.js); unset means |
| 175 | 175 | // this device is not linked, and a knock refuses rather than going out unproven (feed.js). |
| 176 | 176 | 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() | |
| 177 | 181 | } |
| 178 | 182 | |
| 179 | 183 | _binder () { |
| … | … | @@ -802,6 +806,40 @@ export class Peer { |
| 802 | 806 | * |
| 803 | 807 | * `kind` is one of: reaction · repost · reply · mention · follow · knock. |
| 804 | 808 | */ |
| 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 | ||
| 805 | 843 | async notifications ({ limit = 100 } = {}) { |
| 806 | 844 | const mine = hex(this.feed.key) |
| 807 | 845 | const founder = await this.feed.founder() |
| … | … | @@ -818,6 +856,13 @@ export class Peer { |
| 818 | 856 | // ⛔ Not my own doing. Liking your own post is ordinary; being told about it is not. |
| 819 | 857 | if (author === mine) continue |
| 820 | 858 | |
| 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 | ||
| 821 | 866 | for (const collection of [REACTION, REPOST, REPLY]) { |
| 822 | 867 | for (const row of await feed.list(collection)) { |
| 823 | 868 | const target = aimedAtMe(row) |
| … | … | @@ -443,8 +443,12 @@ export async function dispatch (node, cmd, a) { |
| 443 | 443 | // ⛔ Remember WHICH bridge. Releasing needs the key again and `name@host` does not contain it; |
| 444 | 444 | // asking a person to paste it a second time, for the one irreversible act here, is how the |
| 445 | 445 | // wrong bridge gets asked. |
| 446 | const { rememberBridge } = await import('./bridge.js') | |
| 446 | const { rememberBridge, acceptObserver } = await import('./bridge.js') | |
| 447 | 447 | 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(() => {}) | |
| 448 | 452 | return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false } |
| 449 | 453 | } |
| 450 | 454 | // ⛔ 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) { |
| 459 | 463 | } |
| 460 | 464 | |
| 461 | 465 | case 'bridge-release': { |
| 462 | const { connectBridge, buildRelease, bridgeFor, forgetBridge } = await import('./bridge.js') | |
| 466 | const { connectBridge, buildRelease, bridgeFor, forgetBridge, forgetObserver } = await import('./bridge.js') | |
| 463 | 467 | // ⚠ The remembered key wins over one supplied by the caller: the node knows which bridge it |
| 464 | 468 | // actually claimed on, and a release sent to the wrong bridge is a request to burn a name |
| 465 | 469 | // somewhere it was never held. |
| … | … | @@ -469,6 +473,10 @@ export async function dispatch (node, cmd, a) { |
| 469 | 473 | const r = await rpc.request('release', buildRelease({ nonce, username: a.name, root: node.rootHex(), cred: node.cred })) |
| 470 | 474 | if (r.status !== 200) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status)) |
| 471 | 475 | 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) | |
| 472 | 480 | return r.body |
| 473 | 481 | } |
| 474 | 482 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.63", | |
| 3 | "version": "0.0.64", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -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 | ||
| 8 | import test from 'brittle' | |
| 9 | import fs from '#node:fs' | |
| 10 | import os from '#node:os' | |
| 11 | import { join } from '#node:path' | |
| 12 | import b4a from 'b4a' | |
| 13 | import crypto from 'hypercore-crypto' | |
| 14 | import { signObserver } from 'favela-identity/lib/observer-grant.js' | |
| 15 | import { acceptObserver, observers, forgetObserver } from '../lib/bridge.js' | |
| 16 | ||
| 17 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-obs-')) | |
| 18 | ||
| 19 | function 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 | ||
| 24 | test('★ 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 | ||
| 33 | test('⛔ 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 | ||
| 46 | test('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 | }) |
| … | … | @@ -166,3 +166,77 @@ test('⚠ only feeds I HOLD can notify me', async (t) => { |
| 166 | 166 | // copy of can like your post and you will never know — which is exactly why the doorbell exists. |
| 167 | 167 | t.alike(await me.peer.notifications(), []) |
| 168 | 168 | }) |
| 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. | |
| 176 | const OBSERVATION = 'observation' | |
| 177 | ||
| 178 | async 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 | ||
| 188 | const 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 | ||
| 193 | test('★★ 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 | ||
| 219 | test('⛔ 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 | ||
| 232 | test('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 | }) |