| … | … | @@ -19,6 +19,7 @@ |
| 19 | 19 | import Corestore from 'corestore' |
| 20 | 20 | import Autobase from 'autobase' |
| 21 | 21 | import Hyperbee from 'hyperbee' |
| 22 | import Hypercore from 'hypercore' |
| 22 | 23 | import b4a from 'b4a' |
| 23 | 24 | import crypto from 'hypercore-crypto' |
| 24 | 25 | import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js' |
| … | … | @@ -42,6 +43,15 @@ const REMOVED = '__removed__' // reserved view key: { identityHex: { ts } } — |
| 42 | 43 | const RKEY = (collection, rkey) => `${collection}/${rkey}` |
| 43 | 44 | const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex')) |
| 44 | 45 | |
| 46 | // DETERMINISTIC base key (ported byte-exact from atproto-room/StateBaseV3): every co-owner that holds |
| 47 | // the members-only `roomSeed` derives the SAME autobase base key, so they open the SAME base with NO |
| 48 | // shared bootstrap. The CREATOR (lowest-key admin, or forceCreator) opens with `gsKeyPair` as its local |
| 49 | // writer — the deterministic genesis/bootstrap indexer; other co-owners open with a fresh writer and |
| 50 | // join via admission. gsNamespace makes the key roomSeed-space-specific (override for migration). |
| 51 | const GS_NAMESPACE = Buffer.from('atproto-base/state/v1', 'utf-8') |
| 52 | function deriveGsKeyPair (roomSeed, ns = GS_NAMESPACE) { return crypto.keyPair(crypto.hash([ns, toBuf(roomSeed)])) } |
| 53 | function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) } |
| 54 | |
| 45 | 55 | export class AtprotoBase { |
| 46 | 56 | // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey, identity?, members? } |
| 47 | 57 | // identity: { publicKey, secretKey } — this co-owner's membership key (ed25519, drives admission). |
| … | … | @@ -55,6 +65,13 @@ export class AtprotoBase { |
| 55 | 65 | this._path = opts.path || null |
| 56 | 66 | this._bootstrap = opts.bootstrap || null |
| 57 | 67 | this._keyPair = opts.keyPair || null |
| 68 | // Deterministic base key from a members-only roomSeed (co-owners share the base without a bootstrap). |
| 69 | this._roomSeed = opts.roomSeed ? toBuf(opts.roomSeed) : null |
| 70 | this._gsNamespace = opts.gsNamespace ? toBuf(opts.gsNamespace) : GS_NAMESPACE |
| 71 | this._gsKeyPair = this._roomSeed ? deriveGsKeyPair(this._roomSeed, this._gsNamespace) : null |
| 72 | this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null |
| 73 | this._forceCreator = opts.forceCreator === true // assume-genesis heal (re-author on fresh storage) |
| 74 | this._forceNonCreator = opts.forceNonCreator === true // resync heal (join as a fresh non-creator writer) |
| 58 | 75 | this._encryptionKey = opts.encryptionKey || null |
| 59 | 76 | this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: toBuf(opts.identity.secretKey) } : null |
| 60 | 77 | this._members = opts.members || null |
| … | … | @@ -80,9 +97,19 @@ export class AtprotoBase { |
| 80 | 97 | ackInterval: this._ackInterval, |
| 81 | 98 | ackThreshold: 4 |
| 82 | 99 | } |
| 83 | | if (this._keyPair) baseOpts.keyPair = this._keyPair |
| 100 | // Resolve bootstrap + local writer keypair. The deterministic-base-key path (roomSeed) OVERRIDES an |
| 101 | // explicit bootstrap/keyPair: every co-owner opens the same base; the CREATOR (lowest-key admin, or |
| 102 | // forceCreator) writes as gsKeyPair (the bootstrap indexer), others open a fresh writer. |
| 103 | let bootstrap = this._bootstrap |
| 104 | let keyPair = this._keyPair |
| 105 | if (this._detBaseKey) { |
| 106 | const amCreator = this._forceCreator ? true : (this._forceNonCreator ? false : this._amCreator()) |
| 107 | bootstrap = this._detBaseKey |
| 108 | keyPair = amCreator ? this._gsKeyPair : null |
| 109 | } |
| 110 | if (keyPair) baseOpts.keyPair = keyPair |
| 84 | 111 | if (this._encryptionKey) { baseOpts.encryptionKey = this._encryptionKey; baseOpts.encrypted = true } |
| 85 | | this.base = new Autobase(this.store, this._bootstrap, baseOpts) |
| 112 | this.base = new Autobase(this.store, bootstrap, baseOpts) |
| 86 | 113 | await this.base.ready() |
| 87 | 114 | // The public firehose: a plain Hypercore of #commit/#sync frames the bridge replicates + serves as |
| 88 | 115 | // com.atproto.sync.subscribeRepos. Separate from the autobase (a projection of it), like the membrane. |
| … | … | @@ -217,6 +244,20 @@ export class AtprotoBase { |
| 217 | 244 | return role != null ? Number(role) : null |
| 218 | 245 | } |
| 219 | 246 | |
| 247 | // Deterministic creator election (ported from StateBaseV3 _amCreator): the LOWEST-key eligible |
| 248 | // (admin/moderator) member is the bootstrap creator; hex-ordered so every co-owner agrees offline. No |
| 249 | // members/identity → default to creator (single-owner DID). Used only on the deterministic-base-key path. |
| 250 | _amCreator () { |
| 251 | if (!this._identity || !Array.isArray(this._members) || this._members.length === 0) return true |
| 252 | const selfHex = this._identity.publicKey.toString('hex').toLowerCase() |
| 253 | const eligible = this._members |
| 254 | .map((m) => ({ key: toBuf(m.key).toString('hex').toLowerCase(), role: Number(m.role) })) |
| 255 | .filter((m) => m.key && (m.role === 1 || m.role === 2)) |
| 256 | .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) |
| 257 | if (eligible.length === 0) return true |
| 258 | return eligible[0].key === selfHex |
| 259 | } |
| 260 | |
| 220 | 261 | // The creator/admin projects the roster into the shared view (memberKey→role) + its own device |
| 221 | 262 | // mapping (writerKey→identity), so admission can resolve a joiner's role deterministically in apply. |
| 222 | 263 | async projectMembers (members) { |