OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / d6db2ed

0.0.65: the bridge that carries the fediverse name travels with the identity
bridges.json is per device, so a second device - or any device after a registration older than bridges.json - could not ask its bridge for the observation feed and needed a manual re-claim. reach/self now holds fediverseBridge (the public bridge swarm key) beside fediverse; bridge-claim writes both, release clears it; the boot refresh asks claimTargets = local bridges + reach, minus granted names. Test-first; 419/419. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d6db2ed477da65a17c5ecf7792fd2032926641bd
by plankatron <fleeky@gmail.com> · 2026-10-14T20:49:27.000Z

Changed files (7) +95 -8

lib/bridge.js +14 -0
……@@ -239,3 +239,17 @@ export function forgetObserver (storage, acct) {
239239 delete all[acct]
240240 writeObservers(storage, all)
241241}
242
243/**
244 * Which [acct, bridgeKey] pairs a device should ask for an observation grant: its own bridges.json PLUS the bridge
245 * the identity's reach names (replicated, so every device of the identity knows it), minus names already granted.
246 */
247export function claimTargets (storage, reach = {}) {
248 const granted = new Set(observers(storage).map((o) => o.acct))
249 const out = new Map()
250 for (const [acct, key] of bridges(storage)) if (acct && key) out.set(acct, String(key).toLowerCase())
251 if (reach && reach.fediverse && reach.fediverseBridge && !out.has(reach.fediverse)) {
252 out.set(reach.fediverse, String(reach.fediverseBridge).toLowerCase())
253 }
254 return [...out.entries()].filter(([acct]) => !granted.has(acct))
255}
lib/node.js +6 -4
……@@ -30,7 +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'
33import { connectBridge, buildClaim, observers, acceptObserver, claimTargets } from './bridge.js'
3434
3535const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
3636
……@@ -300,9 +300,11 @@ export class FavelaNode {
300300 */
301301 async _refreshObserverGrants () {
302302 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
303 // This device's bridges.json AND the bridge the identity's reach names — so a device that never claimed anything
304 // itself still asks the bridge its identity is registered on (claimTargets).
305 let reach = {}
306 try { reach = await this.peer.reach() } catch {}
307 for (const [acct, bridgeKey] of claimTargets(this.storage, reach)) {
306308 const name = String(acct).split('@')[0]
307309 if (!name) continue
308310 try {
lib/peer.js +14 -3
……@@ -307,7 +307,11 @@ export class Peer {
307307 // withheld rather than published under a guess.
308308 credential: fm.credential || null,
309309 // The name a bridge grant produced, e.g. `alice@ap.example`. Absent until one is claimed.
310 fediverse: fm.fediverse || null
310 fediverse: fm.fediverse || null,
311 // ⛔ AND WHICH BRIDGE CARRIES IT (hyper-social #82, I1): its swarm key, public. bridges.json is per device, so
312 // without this a second device — or any device after a registration older than bridges.json — could not ask
313 // its bridge for the observation feed. Here it replicates with the identity.
314 fediverseBridge: fm.fediverseBridge || null
311315 }
312316 }
313317
……@@ -350,10 +354,16 @@ export class Peer {
350354 }
351355
352356 /** Merges: a write naming only `networks` leaves the credential alone, and the reverse. */
353 async setReach ({ networks, credential, fediverse } = {}) {
357 async setReach ({ networks, credential, fediverse, fediverseBridge } = {}) {
354358 await this.feed.update()
355359 const current = await this.reach()
356 const fm = { networks: current.networks, credential: current.credential, fediverse: current.fediverse }
360 const fm = { networks: current.networks, credential: current.credential, fediverse: current.fediverse, fediverseBridge: current.fediverseBridge }
361 if (fediverseBridge !== undefined) {
362 if (fediverseBridge !== null && !/^[0-9a-f]{64}$/i.test(String(fediverseBridge))) {
363 throw new Error('setReach: fediverseBridge must be a 64-hex bridge key')
364 }
365 fm.fediverseBridge = fediverseBridge ? String(fediverseBridge).toLowerCase() : null
366 }
357367
358368 if (networks !== undefined) {
359369 if (!Array.isArray(networks)) throw new Error('setReach: networks must be an array')
……@@ -383,6 +393,7 @@ export class Peer {
383393 }
384394 if (!fm.credential) delete fm.credential
385395 if (!fm.fediverse) delete fm.fediverse
396 if (!fm.fediverseBridge) delete fm.fediverseBridge
386397 await this.feed.put(REACH, SELF, { frontmatter: fm, body: '' })
387398 return this.reach()
388399 }
lib/rpc.js +12 -0
……@@ -449,6 +449,13 @@ export async function dispatch (node, cmd, a) {
449449 // claimed with signed it, then held right away so outside interactions start arriving without a restart.
450450 const o = acceptObserver(node.storage, a.bridge, r.body)
451451 if (o && typeof node.holdObserver === 'function') node.holdObserver(o).catch(() => {})
452 // ⛔ AND RECORD WHICH BRIDGE CARRIES THE NAME IN THE IDENTITY, not only on this device: reach replicates, so
453 // every device of this identity can ask that bridge for its observation feed at boot (hyper-social #82, I1).
454 if (r.body && r.body.acct && node.peer) {
455 try {
456 await node.peer.setReach({ fediverse: r.body.acct, fediverseBridge: String(a.bridge).replace(/^bridge:/, '').toLowerCase() })
457 } catch { /* an unattested device cannot write reach; the claim itself still stands */ }
458 }
452459 return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false }
453460 }
454461 // ⛔ CARRIED, not named. A bridge that fronts an identity serves what that identity SIGNED — it
……@@ -477,6 +484,11 @@ export async function dispatch (node, cmd, a) {
477484 const gone = (await import('./bridge.js')).observers(node.storage).find((o) => o.acct === a.acct)
478485 forgetObserver(node.storage, a.acct)
479486 if (gone && node.peer && node.peer.observers) node.peer.observers.delete(gone.observationFeed)
487 // The identity no longer names a bridge for this name.
488 try {
489 const cur = node.peer ? await node.peer.reach() : null
490 if (cur && cur.fediverse === a.acct) await node.peer.setReach({ fediverseBridge: null })
491 } catch {}
480492 return r.body
481493 }
482494
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.64",
3 "version": "0.0.65",
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 +21 -0
……@@ -50,3 +50,24 @@ test('forgetting a bridge forgets its observation feed', (t) => {
5050 forgetObserver(storage, 'fleeky@ap.7rnx.net')
5151 t.alike(observers(storage), [])
5252})
53
54// ⛔ WHERE THE BOOT REFRESH LOOKS (claimTargets). This device's own bridges.json AND the identity's reach — so a
55// device that never claimed anything itself still asks the bridge its identity names, and nothing already holding a
56// grant is asked again.
57test('★ claim targets: local bridges + the identity\'s reach, minus names that already have a grant', async (t) => {
58 const { claimTargets, rememberBridge } = await import('../lib/bridge.js')
59 const storage = tmp()
60 const A = 'aa'.repeat(32)
61 const B = 'bb'.repeat(32)
62 rememberBridge(storage, 'local@ap.example', A)
63 t.alike(claimTargets(storage, { fediverse: 'fleeky@ap.7rnx.net', fediverseBridge: B }).sort(),
64 [['fleeky@ap.7rnx.net', B], ['local@ap.example', A]].sort(), 'both sources')
65 t.alike(claimTargets(storage, { fediverse: 'local@ap.example', fediverseBridge: A }), [['local@ap.example', A]], 'deduplicated')
66 t.alike(claimTargets(storage, { fediverse: 'x@y', fediverseBridge: null }), [['local@ap.example', A]], 'a name without a bridge is not a target')
67
68 const bridge = crypto.keyPair()
69 const key = b4a.toString(bridge.publicKey, 'hex')
70 rememberBridge(storage, 'fleeky@ap.7rnx.net', key)
71 acceptObserver(storage, key, claimReply(bridge))
72 t.absent(claimTargets(storage, {}).some(([acct]) => acct === 'fleeky@ap.7rnx.net'), 'a name with a grant is not asked again')
73})
test/reach.test.js +27 -0
……@@ -109,3 +109,30 @@ test('⛔ set-reach requires an ATTESTED device, like posting does', async (t) =
109109 t.absent(set.includes("'set-profile'"), 'set-profile is not — a self-label asserts nothing')
110110 t.ok(typeof dispatch === 'function')
111111})
112
113// ⛔ WHICH BRIDGE CARRIES THE NAME TRAVELS WITH THE IDENTITY (hyper-social #82, I1). bridges.json is per DEVICE, so a
114// second device of the same identity — or any device after a registration that predates bridges.json — could not
115// tell which bridge to ask for its observation feed, and needed a manual re-claim. The bridge key is a public swarm
116// address, so it rides in reach beside the name it carries, and every device of the identity learns it.
117test('★ the bridge that carries the fediverse name is stored beside it, and merges like every other field', async (t) => {
118 const p = await mkPeer()
119 t.teardown(() => p.close())
120 const KEY = 'fe'.repeat(32)
121 await p.setReach({ fediverse: 'fleeky@ap.7rnx.net', fediverseBridge: KEY })
122 let r = await p.reach()
123 t.is(r.fediverse, 'fleeky@ap.7rnx.net')
124 t.is(r.fediverseBridge, KEY)
125 await p.setReach({ networks: ['fediverse'] })
126 t.is((await p.reach()).fediverseBridge, KEY, 'survives a networks-only write')
127 await p.setReach({ fediverseBridge: null })
128 r = await p.reach()
129 t.is(r.fediverseBridge, null, 'and can be cleared')
130 t.is(r.fediverse, 'fleeky@ap.7rnx.net', 'without touching the name')
131})
132
133test('⛔ a fediverseBridge that is not a 64-hex key is refused', async (t) => {
134 const p = await mkPeer()
135 t.teardown(() => p.close())
136 await t.exception(() => p.setReach({ fediverseBridge: 'not-a-key' }), /fediverseBridge/)
137 t.is((await p.reach()).fediverseBridge, null)
138})