| … | … | @@ -239,3 +239,17 @@ export function forgetObserver (storage, acct) { |
| 239 | 239 | delete all[acct] |
| 240 | 240 | writeObservers(storage, all) |
| 241 | 241 | } |
| 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 | */ | |
| 247 | export 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 | } |
| … | … | @@ -30,7 +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 | import { connectBridge, buildClaim, observers, acceptObserver, claimTargets } from './bridge.js' | |
| 34 | 34 | |
| 35 | 35 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| 36 | 36 | |
| … | … | @@ -300,9 +300,11 @@ export class FavelaNode { |
| 300 | 300 | */ |
| 301 | 301 | async _refreshObserverGrants () { |
| 302 | 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 | |
| 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)) { | |
| 306 | 308 | const name = String(acct).split('@')[0] |
| 307 | 309 | if (!name) continue |
| 308 | 310 | try { |
| … | … | @@ -307,7 +307,11 @@ export class Peer { |
| 307 | 307 | // withheld rather than published under a guess. |
| 308 | 308 | credential: fm.credential || null, |
| 309 | 309 | // 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 | |
| 311 | 315 | } |
| 312 | 316 | } |
| 313 | 317 | |
| … | … | @@ -350,10 +354,16 @@ export class Peer { |
| 350 | 354 | } |
| 351 | 355 | |
| 352 | 356 | /** 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 } = {}) { | |
| 354 | 358 | await this.feed.update() |
| 355 | 359 | 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 | } | |
| 357 | 367 | |
| 358 | 368 | if (networks !== undefined) { |
| 359 | 369 | if (!Array.isArray(networks)) throw new Error('setReach: networks must be an array') |
| … | … | @@ -383,6 +393,7 @@ export class Peer { |
| 383 | 393 | } |
| 384 | 394 | if (!fm.credential) delete fm.credential |
| 385 | 395 | if (!fm.fediverse) delete fm.fediverse |
| 396 | if (!fm.fediverseBridge) delete fm.fediverseBridge | |
| 386 | 397 | await this.feed.put(REACH, SELF, { frontmatter: fm, body: '' }) |
| 387 | 398 | return this.reach() |
| 388 | 399 | } |
| … | … | @@ -449,6 +449,13 @@ export async function dispatch (node, cmd, a) { |
| 449 | 449 | // claimed with signed it, then held right away so outside interactions start arriving without a restart. |
| 450 | 450 | const o = acceptObserver(node.storage, a.bridge, r.body) |
| 451 | 451 | 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 | } | |
| 452 | 459 | return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false } |
| 453 | 460 | } |
| 454 | 461 | // ⛔ 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) { |
| 477 | 484 | const gone = (await import('./bridge.js')).observers(node.storage).find((o) => o.acct === a.acct) |
| 478 | 485 | forgetObserver(node.storage, a.acct) |
| 479 | 486 | 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 {} | |
| 480 | 492 | return r.body |
| 481 | 493 | } |
| 482 | 494 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.64", | |
| 3 | "version": "0.0.65", | |
| 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": { |
| … | … | @@ -50,3 +50,24 @@ test('forgetting a bridge forgets its observation feed', (t) => { |
| 50 | 50 | forgetObserver(storage, 'fleeky@ap.7rnx.net') |
| 51 | 51 | t.alike(observers(storage), []) |
| 52 | 52 | }) |
| 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. | |
| 57 | test('★ 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 | }) |
| … | … | @@ -109,3 +109,30 @@ test('⛔ set-reach requires an ATTESTED device, like posting does', async (t) = |
| 109 | 109 | t.absent(set.includes("'set-profile'"), 'set-profile is not — a self-label asserts nothing') |
| 110 | 110 | t.ok(typeof dispatch === 'function') |
| 111 | 111 | }) |
| 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. | |
| 117 | test('★ 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 | ||
| 133 | test('⛔ 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 | }) |