| … | … | @@ -75,6 +75,11 @@ export class DidPeer { |
| 75 | 75 | this._rpcs = [] |
| 76 | 76 | this._repoKey = null |
| 77 | 77 | this._firehoseKey = null |
| 78 | // A LABELER DID's signed label core (hpp 0.3.128 membrane-labels:<did>), when its holder announces one. | |
| 79 | // Plain hypercore of encoded com.atproto.label blocks; verifying each against the DID key is the | |
| 80 | // consumer's job (the gateway lens), exactly as it verifies commits. | |
| 81 | this._labelKey = null | |
| 82 | this.labels = null | |
| 78 | 83 | this._channels = new Set() |
| 79 | 84 | this.head = null |
| 80 | 85 | this._opening = null |
| … | … | @@ -134,7 +139,28 @@ export class DidPeer { |
| 134 | 139 | |
| 135 | 140 | // This node HOLDS the cores (a signing device, a relay that already seeded, or a bootstrap). Open |
| 136 | 141 | // them so we can serve + announce. Idempotent. |
| 137 | async provide ({ repoKey, firehoseKey } = {}) { return this._openCores(repoKey, firehoseKey) } | |
| 142 | async provide ({ repoKey, firehoseKey, labelKey = null } = {}) { | |
| 143 | await this._openCores(repoKey, firehoseKey) | |
| 144 | if (labelKey) await this.adoptLabelKey(labelKey) | |
| 145 | } | |
| 146 | ||
| 147 | // Open + fully seed a labeler's label core, follow its appends, and re-announce on each (a new label is | |
| 148 | // news to downstream readers just like a new commit). Like adoptRepoKey: only on an established front, | |
| 149 | // once. → true when it opened the core. | |
| 150 | async adoptLabelKey (labelKey) { | |
| 151 | if (this.labels || !labelKey || !this.seq || this._opening || this._labelOpening) return false | |
| 152 | this._labelOpening = true | |
| 153 | try { | |
| 154 | this._labelKey = toKey(labelKey) | |
| 155 | const core = this.corestore.get({ key: this._labelKey }) | |
| 156 | await core.ready() | |
| 157 | try { core.download({ start: 0, end: -1 }) } catch {} // ONE live range: all + future appends | |
| 158 | core.on('append', () => this._announceAll()) | |
| 159 | this.labels = core | |
| 160 | this._announceAll() | |
| 161 | return true | |
| 162 | } finally { this._labelOpening = false } | |
| 163 | } | |
| 138 | 164 | |
| 139 | 165 | // Re-open a repo core for a front that went back to PROJECTING after a stateless spell. The router |
| 140 | 166 | // drops the replica when a peer announces repoKey:null; without this the drop is ONE-WAY, so a peer |
| … | … | @@ -217,29 +243,32 @@ export class DidPeer { |
| 217 | 243 | announceVia (message) { |
| 218 | 244 | if (!this.seq || !message) return |
| 219 | 245 | try { |
| 220 | message.send({ | |
| 221 | did: this.did, | |
| 222 | repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null, | |
| 223 | firehoseKey: b4a.toString(this._firehoseKey, 'hex'), | |
| 224 | repoLength: this.repo ? this.repo.core.length : null, | |
| 225 | firehoseLength: this.seq.core.length | |
| 226 | }) | |
| 246 | message.send(this._announcement()) | |
| 227 | 247 | } catch (e) { this._fail(e) } |
| 228 | 248 | } |
| 229 | 249 | |
| 230 | 250 | _announceTo (chan) { |
| 231 | 251 | if (!this.seq || !chan._announce) return |
| 232 | 252 | try { |
| 233 | chan._announce.send({ | |
| 234 | did: this.did, | |
| 235 | repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null, | |
| 236 | firehoseKey: b4a.toString(this._firehoseKey, 'hex'), | |
| 237 | repoLength: this.repo ? this.repo.core.length : null, | |
| 238 | firehoseLength: this.seq.core.length | |
| 239 | }) | |
| 253 | chan._announce.send(this._announcement()) | |
| 240 | 254 | } catch (e) { this._fail(e) } |
| 241 | 255 | } |
| 242 | 256 | |
| 257 | // One payload for both announce paths. labelKey/labelLength are carried by EVERY announcer that holds | |
| 258 | // the core — a re-announcing seeder that dropped them would hide the labels from everyone fronting | |
| 259 | // through it. null when there is no label core (the field exists, so a consumer can branch). | |
| 260 | _announcement () { | |
| 261 | return { | |
| 262 | did: this.did, | |
| 263 | repoKey: this._repoKey ? b4a.toString(this._repoKey, 'hex') : null, | |
| 264 | firehoseKey: b4a.toString(this._firehoseKey, 'hex'), | |
| 265 | repoLength: this.repo ? this.repo.core.length : null, | |
| 266 | firehoseLength: this.seq.core.length, | |
| 267 | labelKey: this.labels ? b4a.toString(this._labelKey, 'hex') : null, | |
| 268 | labelLength: this.labels ? this.labels.length : null | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 243 | 272 | _announceAll () { for (const chan of this._channels) this._announceTo(chan) } |
| 244 | 273 | |
| 245 | 274 | async onAnnounce (a) { |
| … | … | @@ -251,6 +280,7 @@ export class DidPeer { |
| 251 | 280 | await this._openCores(a.repoKey, a.firehoseKey) |
| 252 | 281 | this._announceAll() // we can help seed now — tell peers we have them too (symmetric) |
| 253 | 282 | } |
| 283 | if (a.labelKey && !this.labels) await this.adoptLabelKey(a.labelKey) | |
| 254 | 284 | // The announce is a HINT (peer says its firehose is at length N). Replication pushes the blocks; |
| 255 | 285 | // we react to the 'append' event to reconcile. One-shot update() nudges the fetch (event-triggered |
| 256 | 286 | // on announce, NOT a poll loop). |
| … | … | @@ -528,6 +558,7 @@ export class DidPeer { |
| 528 | 558 | this._channels.clear() |
| 529 | 559 | if (this.seq) { try { await this.seq.close() } catch {} } |
| 530 | 560 | if (this.repo) { try { await this.repo.close() } catch {} } |
| 561 | if (this.labels) { try { await this.labels.close() } catch {} } | |
| 531 | 562 | } |
| 532 | 563 | } |
| 533 | 564 | |
| … | … | @@ -719,6 +750,7 @@ export class DidPeerRouter { |
| 719 | 750 | // above is one-way and a rolled-back flip never heals. |
| 720 | 751 | existing.adoptRepoKey(a.repoKey).catch(() => {}) |
| 721 | 752 | } |
| 753 | if (a.labelKey && !existing.labels) existing.adoptLabelKey(a.labelKey).catch(() => {}) | |
| 722 | 754 | if (existing.seq) existing.seq.core.update().catch(() => {}) |
| 723 | 755 | this._wake(did) |
| 724 | 756 | return |
| … | … | @@ -765,7 +797,7 @@ export class DidPeerRouter { |
| 765 | 797 | if (entry.coords.handle) peer.handle = entry.coords.handle |
| 766 | 798 | // EVERY peer announcing this (did, firehoseKey) is a candidate; _call tries them until one answers. |
| 767 | 799 | if (this._useRpc) for (const r of entry.conns.values()) if (r) peer.addRpc(r) |
| 768 | await peer.provide({ repoKey: entry.coords.repoKey, firehoseKey: entry.coords.firehoseKey }) | |
| 800 | await peer.provide({ repoKey: entry.coords.repoKey, firehoseKey: entry.coords.firehoseKey, labelKey: entry.coords.labelKey || null }) | |
| 769 | 801 | // Re-sync: more peers may have announced this same (did, firehoseKey) during the open above. |
| 770 | 802 | if (this._useRpc) for (const r of entry.conns.values()) if (r) peer.addRpc(r) |
| 771 | 803 | if (!this._fronts.has(did)) this._fronts.set(did, new Map()) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-did-peer", |
| 3 | "version": "0.2.14", | |
| 3 | "version": "0.2.15", | |
| 4 | 4 | "description": "Keyless, direction-free P2P reader/seeder for an atproto DID's repo+firehose Hypercores: announce-push discovery + event-driven head reconciliation + a keyless read surface. The shared primitive beneath atproto-bridge, atproto-relay and favela (extracted from atproto-bridge did-peer.js).", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -8,7 +8,7 @@ |
| 8 | 8 | ".": "./index.js" |
| 9 | 9 | }, |
| 10 | 10 | "scripts": { |
| 11 | "test": "node test/commitops.test.mjs && node test/router.test.mjs && node test/co-owned.test.mjs && node test/coowner-set.test.mjs && node test/evict.test.mjs && node test/fork.test.mjs && node test/rpc-candidates.test.mjs && node test/no-repo.test.mjs && node test/get-repo-since.test.mjs && node test/rpc-timeout.test.mjs && node test/emit-sync.test.mjs", | |
| 11 | "test": "node test/commitops.test.mjs && node test/router.test.mjs && node test/co-owned.test.mjs && node test/coowner-set.test.mjs && node test/evict.test.mjs && node test/fork.test.mjs && node test/rpc-candidates.test.mjs && node test/no-repo.test.mjs && node test/get-repo-since.test.mjs && node test/rpc-timeout.test.mjs && node test/emit-sync.test.mjs && node test/labels.test.mjs", | |
| 12 | 12 | "test:live": "node test/read-live.mjs" |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| … | … | @@ -0,0 +1,88 @@ |
| 1 | // Label cores: a labeler DID's signed labels reach a keyless reader through the announce (member labels, | |
| 2 | // hyper-social #80 step 3). | |
| 3 | // | |
| 4 | // hpp (0.3.128) projects a labeler DID's signed label log into a membrane-labels core and announces | |
| 5 | // labelKey/labelLength beside the firehose. A reader — the gateway's lens, behind a DidPeerRouter — must | |
| 6 | // open and fully seed that core, follow its appends, and keep the field when IT re-announces (a seeder | |
| 7 | // that dropped labelKey would hide the labels from everyone fronting through it). A labelKey that first | |
| 8 | // appears on an already-open front is adopted, like a repoKey. | |
| 9 | // | |
| 10 | // Run: node test/labels.test.mjs | |
| 11 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 12 | import { join } from 'node:path' | |
| 13 | import { tmpdir } from 'node:os' | |
| 14 | import createTestnet from '@hyperswarm/testnet' | |
| 15 | import Hyperswarm from 'hyperswarm' | |
| 16 | import b4a from 'b4a' | |
| 17 | import { RepoStore } from 'atproto-store/lib/repo-store.js' | |
| 18 | import { RepoManager } from 'atproto-store/lib/repo-manager.js' | |
| 19 | import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js' | |
| 20 | import { openCorestore } from 'atproto-store/lib/corestore.js' | |
| 21 | import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js' | |
| 22 | import { discoveryTopic } from 'atproto-identity/lib/discovery.js' | |
| 23 | import { DidPeer, DidPeerRouter } from '../index.js' | |
| 24 | ||
| 25 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 26 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) | |
| 27 | const waitFor = async (fn, ms = 20000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100) } return false } | |
| 28 | const iso = '2026-10-13T00:00:00.000Z' | |
| 29 | const id = createAtprotoIdentityFromKey(new Uint8Array(32).fill(6), { handle: 'labeler.test', pdsUrl: 'https://pds.local' }) | |
| 30 | const testnet = await createTestnet(3) | |
| 31 | const dirs = [] | |
| 32 | const dir = (p) => { const d = mkdtempSync(join(tmpdir(), p)); dirs.push(d); return d } | |
| 33 | ||
| 34 | // The holder: a signed repo + firehose, and a label core holding two blocks. | |
| 35 | const hStore = openCorestore(dir('labels-holder-')) | |
| 36 | const repo = await new RepoStore(hStore, { name: `atproto-repo:${id.did}` }).ready() | |
| 37 | let micros = 1_700_000_000_000_000n | |
| 38 | const mgr = await new RepoManager(repo, { did: id.did, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init() | |
| 39 | const seq = await new FirehoseSequencer(hStore.get({ name: 'firehose' })).ready() | |
| 40 | await seq.sequenceCommit(await mgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: '1', record: { $type: 'app.bsky.feed.post', text: 'hi', createdAt: iso } }), { time: iso }) | |
| 41 | const labels = hStore.get({ name: 'membrane-labels' }) | |
| 42 | await labels.ready() | |
| 43 | await labels.append([b4a.from('label-0'), b4a.from('label-1')]) | |
| 44 | ||
| 45 | const holder = new DidPeer(hStore, { did: id.did, verifyKey: id.publicKey }) | |
| 46 | await holder.provide({ repoKey: repo.core.key, firehoseKey: seq.core.key, labelKey: labels.key }) | |
| 47 | assert(holder.labels && holder.labels.length === 2, 'provide opens the holder\'s label core') | |
| 48 | const hSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap }) | |
| 49 | hSwarm.on('connection', (c) => holder.attach(c)) | |
| 50 | await hSwarm.join(discoveryTopic(id.did), { server: true, client: true }).flushed() | |
| 51 | ||
| 52 | console.log('# a DidPeerRouter reader opens, seeds and follows the label core') | |
| 53 | const rStore = openCorestore(dir('labels-reader-')) | |
| 54 | const router = new DidPeerRouter(rStore) | |
| 55 | const rSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap }) | |
| 56 | rSwarm.on('connection', (c) => router.onconnection(c)) | |
| 57 | router.add(id.did, { verifyKey: id.publicKey, noRepo: true }) | |
| 58 | await rSwarm.join(discoveryTopic(id.did), { server: true, client: true }).flushed() | |
| 59 | const front = await router.front(id.did, { timeout: 20000 }) | |
| 60 | assert(!!front, 'the reader fronts the DID') | |
| 61 | assert(await waitFor(() => front.labels && front.labels.contiguousLength === 2), 'the front holds both labels (fully downloaded)') | |
| 62 | assert(b4a.equals(front.labels.key, labels.key), 'the announced core, by key') | |
| 63 | await labels.append(b4a.from('label-2')) | |
| 64 | assert(await waitFor(() => front.labels.contiguousLength === 3), 'and follows a new label') | |
| 65 | ||
| 66 | console.log('\n# a re-announcing seeder keeps the field') | |
| 67 | let sent = null | |
| 68 | front.announceVia({ send: (m) => { sent = m } }) | |
| 69 | assert(sent && sent.labelKey === b4a.toString(labels.key, 'hex'), 'the reader\'s own announce carries labelKey') | |
| 70 | assert(sent.labelLength === 3, 'and labelLength') | |
| 71 | ||
| 72 | console.log('\n# a labelKey that first appears on an open front is adopted') | |
| 73 | const late = new DidPeer(openCorestore(dir('labels-late-')), { did: id.did, verifyKey: id.publicKey, noRepo: true }) | |
| 74 | await late.provide({ firehoseKey: seq.core.key }) | |
| 75 | assert(late.labels === null, 'no labelKey, no label core') | |
| 76 | let none = null | |
| 77 | late.announceVia({ send: (m) => { none = m } }) | |
| 78 | assert(none.labelKey === null && none.labelLength === null, 'announced as null (the field exists)') | |
| 79 | assert(await late.adoptLabelKey(labels.key) === true, 'adoptLabelKey opens it') | |
| 80 | assert(late.labels && b4a.equals(late.labels.key, labels.key), 'bound to the announced key') | |
| 81 | assert(await late.adoptLabelKey(labels.key) === false, 'idempotent') | |
| 82 | await late.close() | |
| 83 | ||
| 84 | console.log('\nLABELS OK') | |
| 85 | await rSwarm.destroy(); await router.close(); await rStore.close() | |
| 86 | await hSwarm.destroy(); await holder.close(); await seq.close(); await repo.close(); await hStore.close() | |
| 87 | await testnet.destroy() | |
| 88 | for (const d of dirs) rmSync(d, { recursive: true, force: true }) |