| … | … | @@ -3,24 +3,36 @@ |
| 3 | 3 | // NO signer election: signing is a pure function of the indexed view + the shared #atproto key, so it |
| 4 | 4 | // is indexer-agnostic (see DESIGN.md, proven by the offline suite). |
| 5 | 5 | // |
| 6 | // SLICE 1 (this file): single-writer creator + TYPED record ops (set-record/del-record, LWW by rev) | |
| 7 | // + indexedRecords() + head() projection. Governance (proof-gated admission, indexer topology, | |
| 8 | // revocation) and multi-writer are the next slices, ported CLEAN from atproto-room/StateBaseV3 — | |
| 9 | // as TYPED ops, not the `set-entry`-overloaded-by-magic-keys design. | |
| 6 | // TYPED ops (contract-router style, like keet — NOT one `set-entry` overloaded by magic keys): | |
| 7 | // set-record / del-record repo records (LWW by a log-derived counter) | |
| 8 | // admit content-free admission doorbell (proof-gated, block-until-admitted) | |
| 9 | // set-members the roster projection (admin/bootstrap only) | |
| 10 | // start-indexing / move-indexer indexer topology (next slice) | |
| 11 | // ban / set-epoch revocation (next slice) | |
| 12 | // | |
| 13 | // GOVERNANCE (this slice): proof-gated multi-writer admission + roster projection. A joiner proves | |
| 14 | // membership (sign localKey‖base.key with its identity key) in a CONTENT-FREE optimistic doorbell | |
| 15 | // block; apply verifies the proof resolves to a member role and addWriters it. The joiner BLOCKS | |
| 16 | // until genuinely admitted (the golden rule — never fuse content into a pre-admission optimistic | |
| 17 | // block, which autobase silently rolls back). Indexer topology + revocation are the next slices. | |
| 10 | 18 | |
| 11 | 19 | import Corestore from 'corestore' |
| 12 | 20 | import Autobase from 'autobase' |
| 13 | 21 | import Hyperbee from 'hyperbee' |
| 14 | 22 | import b4a from 'b4a' |
| 23 | import crypto from 'hypercore-crypto' | |
| 15 | 24 | import { projectCommit, nextRev } from './project.js' |
| 16 | 25 | |
| 17 | // Typed op names (contract-router style, like keet — NOT one op overloaded by magic keys). | |
| 18 | const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record' } | |
| 19 | const RKEY = (collection, rkey) => `${collection}/${rkey}` // the atproto MST key | |
| 26 | const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record', ADMIT: 'admit', SET_MEMBERS: 'set-members' } | |
| 27 | const MEMBERS = '__members__' // reserved view key: { identityHex: role } | |
| 28 | const DEVICES = '__devices__' // reserved view key: { writerKeyHex: identityHex } | |
| 29 | const RKEY = (collection, rkey) => `${collection}/${rkey}` | |
| 30 | const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex')) | |
| 20 | 31 | |
| 21 | 32 | export class AtprotoBase { |
| 22 | // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey } | |
| 23 | // `did` + `privateKey` are the DID identity used by the projection (the shared #atproto key). | |
| 33 | // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey, identity?, members? } | |
| 34 | // identity: { publicKey, secretKey } — this co-owner's membership key (ed25519, drives admission). | |
| 35 | // members: [{ key: identityHex, role }] — the roster the creator projects (role 1=admin). | |
| 24 | 36 | constructor (opts = {}) { |
| 25 | 37 | if (!opts.did) throw new Error('AtprotoBase requires opts.did') |
| 26 | 38 | if (!opts.privateKey) throw new Error('AtprotoBase requires opts.privateKey (the #atproto signing key)') |
| … | … | @@ -31,6 +43,9 @@ export class AtprotoBase { |
| 31 | 43 | this._bootstrap = opts.bootstrap || null |
| 32 | 44 | this._keyPair = opts.keyPair || null |
| 33 | 45 | this._encryptionKey = opts.encryptionKey || null |
| 46 | this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: toBuf(opts.identity.secretKey) } : null | |
| 47 | this._members = opts.members || null | |
| 48 | this._admitted = new Set() | |
| 34 | 49 | this.store = null |
| 35 | 50 | this.base = null |
| 36 | 51 | this._ready = false |
| … | … | @@ -54,16 +69,45 @@ export class AtprotoBase { |
| 54 | 69 | this.base = new Autobase(this.store, this._bootstrap, baseOpts) |
| 55 | 70 | await this.base.ready() |
| 56 | 71 | this._ready = true |
| 72 | if (this._members) this.projectMembers(this._members).catch(() => {}) // creator best-effort projection | |
| 57 | 73 | } |
| 58 | 74 | |
| 59 | // Deterministic apply — TYPED ops fold into the record view. LWW by rev (a monotonic per-record | |
| 60 | // counter carried on the op), so concurrent writers converge deterministically. Pure: no wall-clock. | |
| 75 | // Deterministic apply — TYPED ops fold into the record view + governance. Pure: no wall-clock. | |
| 61 | 76 | async _apply (nodes, view, host) { |
| 62 | const tx = view // hyperbee batch is implicit via put/del; keep simple for slice 1 | |
| 63 | 77 | for (const node of nodes) { |
| 64 | 78 | if (!node.value) continue |
| 65 | 79 | let op |
| 66 | 80 | try { op = JSON.parse(b4a.toString(node.value)) } catch { continue } |
| 81 | ||
| 82 | if (op.t === OP.ADMIT) { | |
| 83 | const fromKey = node.from && node.from.key | |
| 84 | if (!fromKey) continue | |
| 85 | const fromHex = fromKey.toString('hex') | |
| 86 | if (this._admitted.has(fromHex)) continue // idempotent — writer already admitted | |
| 87 | const role = await this._verifyAdmission(fromKey, op.admit, view) | |
| 88 | if (role == null) continue // proof invalid / not a member → NOT acked, block is discarded | |
| 89 | try { | |
| 90 | await host.addWriter(fromKey, { indexer: false }) // plain writer (N=1 posture — no auto-indexer) | |
| 91 | this._admitted.add(fromHex) | |
| 92 | await host.ackWriter(fromKey) | |
| 93 | } catch {} | |
| 94 | const drec = await view.get(DEVICES); const dev = (drec && drec.value) || {} | |
| 95 | if (op.admit && op.admit.identity && dev[fromHex] !== op.admit.identity) { dev[fromHex] = op.admit.identity; await view.put(DEVICES, dev) } | |
| 96 | continue | |
| 97 | } | |
| 98 | ||
| 99 | if (op.t === OP.SET_MEMBERS) { | |
| 100 | const fromHex = node.from && node.from.key ? node.from.key.toString('hex') : null | |
| 101 | const mrec = await view.get(MEMBERS); const members = (mrec && mrec.value) || {} | |
| 102 | const drec = await view.get(DEVICES); const dev = (drec && drec.value) || {} | |
| 103 | const authorRole = fromHex && dev[fromHex] != null ? members[dev[fromHex]] : null | |
| 104 | const bootstrap = Object.keys(members).length === 0 | |
| 105 | if (!bootstrap && Number(authorRole) !== 1) continue // only admin (or bootstrap) may set the roster | |
| 106 | await view.put(MEMBERS, op.members || {}) | |
| 107 | if (op.devices) await view.put(DEVICES, { ...dev, ...op.devices }) // author's own device mapping | |
| 108 | continue | |
| 109 | } | |
| 110 | ||
| 67 | 111 | if (op.t === OP.SET_RECORD) { |
| 68 | 112 | const existing = await view.get(op.key) |
| 69 | 113 | if (existing && existing.value && typeof existing.value.w === 'number' && existing.value.w > op.w) continue // LWW |
| … | … | @@ -78,35 +122,96 @@ export class AtprotoBase { |
| 78 | 122 | |
| 79 | 123 | get writable () { return this.base ? this.base.writable : false } |
| 80 | 124 | |
| 81 | // Write a record. `w` is a per-write monotonic counter for deterministic LWW (log-derived, not | |
| 82 | // wall-clock). Slice 1 uses the writer's own local length as w; multi-writer LWW hardening is later. | |
| 125 | // ── Governance: admission (golden rule = admit FIRST, write AFTER) ────────────────────────────── | |
| 126 | _buildAdmissionProof () { | |
| 127 | if (!this._identity || !this.base || !this.base.local || !this.base.local.key || !this.base.key) return null | |
| 128 | const signData = Buffer.concat([this.base.local.key, this.base.key]) // bound to THIS base — not replayable | |
| 129 | const sig = crypto.sign(signData, this._identity.secretKey) | |
| 130 | return { identity: this._identity.publicKey.toString('hex'), proof: sig.toString('hex') } | |
| 131 | } | |
| 132 | ||
| 133 | async _verifyAdmission (writerKey, admit, view) { | |
| 134 | if (!admit || !admit.identity || !admit.proof) return null | |
| 135 | const signData = Buffer.concat([writerKey, this.base.key]) | |
| 136 | const identity = Buffer.from(admit.identity, 'hex') | |
| 137 | if (!crypto.verify(signData, Buffer.from(admit.proof, 'hex'), identity)) return null | |
| 138 | const mrec = await view.get(MEMBERS) | |
| 139 | const members = (mrec && mrec.value) || {} | |
| 140 | const role = members[admit.identity] | |
| 141 | return role != null ? Number(role) : null | |
| 142 | } | |
| 143 | ||
| 144 | // The creator/admin projects the roster into the shared view (memberKey→role) + its own device | |
| 145 | // mapping (writerKey→identity), so admission can resolve a joiner's role deterministically in apply. | |
| 146 | async projectMembers (members) { | |
| 147 | if (members) this._members = members | |
| 148 | if (!this.base || !this.base.writable) return false | |
| 149 | const map = {} | |
| 150 | for (const m of (this._members || [])) { const id = toBuf(m.key).toString('hex'); map[id] = Number(m.role) || 3 } | |
| 151 | const devices = {} | |
| 152 | if (this._identity && this.base.local && this.base.local.key) devices[this.base.local.key.toString('hex')] = this._identity.publicKey.toString('hex') | |
| 153 | await this.base.append(b4a.from(JSON.stringify({ t: OP.SET_MEMBERS, members: map, devices }))) | |
| 154 | await this.base.update() | |
| 155 | return true | |
| 156 | } | |
| 157 | ||
| 158 | // Block until this co-owner is a genuinely ADMITTED writer, then return. No-op if already writable. | |
| 159 | // Rings the CONTENT-FREE admission doorbell and waits; THROWS if no indexer admits us within the | |
| 160 | // timeout (never a silent content loss). This is what makes putRecord/delRecord durable. | |
| 161 | async ensureWritable ({ timeoutMs = 20000 } = {}) { | |
| 162 | if (this.base && this.base.writable) return | |
| 163 | await this._requestWriter({ timeoutMs }) | |
| 164 | if (!this.base.writable) throw new Error(`AtprotoBase: not admitted (no indexer online within ${timeoutMs}ms) — refusing to write content that would be silently rolled back`) | |
| 165 | } | |
| 166 | ||
| 167 | async _requestWriter ({ timeoutMs = 15000 } = {}) { | |
| 168 | const admit = this._buildAdmissionProof() | |
| 169 | if (!admit) throw new Error('AtprotoBase: no identity to build an admission proof') | |
| 170 | const ring = () => this.base.append(b4a.from(JSON.stringify({ t: OP.ADMIT, admit })), { optimistic: true }).catch(() => {}) | |
| 171 | await ring(); await this._updateBounded() | |
| 172 | const deadline = Date.now() + timeoutMs | |
| 173 | while (!this.base.writable && Date.now() < deadline) { | |
| 174 | await new Promise((r) => setTimeout(r, 200)) | |
| 175 | if (!this.base.writable) await ring() // re-ring: keep a live optimistic block until an indexer acks | |
| 176 | await this._updateBounded() | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | // base.update() awaits a full drain which can hang on an un-converged replica; bound the await so the | |
| 181 | // doorbell loop keeps re-ringing while replication proceeds in the background (autobase drains on wakeup). | |
| 182 | async _updateBounded (ms = 800) { | |
| 183 | if (!this.base) return | |
| 184 | let t = null | |
| 185 | try { await Promise.race([this.base.update().catch(() => {}), new Promise((r) => { t = setTimeout(r, ms); if (t && t.unref) t.unref() })]) } finally { if (t) clearTimeout(t) } | |
| 186 | } | |
| 187 | ||
| 188 | // ── Records (golden rule: admit FIRST via ensureWritable, then write non-optimistically) ──────── | |
| 83 | 189 | async putRecord (collection, rkey, record) { |
| 190 | await this.ensureWritable() | |
| 84 | 191 | const w = this.base.local ? this.base.local.length : 0 |
| 85 | 192 | await this.base.append(b4a.from(JSON.stringify({ t: OP.SET_RECORD, key: RKEY(collection, rkey), record, w }))) |
| 86 | 193 | await this.base.update() |
| 87 | 194 | } |
| 88 | 195 | |
| 89 | 196 | async delRecord (collection, rkey) { |
| 197 | await this.ensureWritable() | |
| 90 | 198 | const w = this.base.local ? this.base.local.length : 0 |
| 91 | 199 | await this.base.append(b4a.from(JSON.stringify({ t: OP.DEL_RECORD, key: RKEY(collection, rkey), w }))) |
| 92 | 200 | await this.base.update() |
| 93 | 201 | } |
| 94 | 202 | |
| 95 | // The INDEXED (finalized) atproto record set — the input to the projection. For a single-writer | |
| 96 | // creator (sole indexer), everything it writes finalizes, so the full view == indexed. Multi-writer | |
| 97 | // will read at base.indexedLength via a view checkout (next slice — see DESIGN reorg-safety). | |
| 203 | // The INDEXED (finalized) atproto record set — the input to the projection. Single-writer creator: | |
| 204 | // full view == indexed. Multi-writer indexed-view checkout (read at base.indexedLength) is the next slice. | |
| 98 | 205 | async indexedRecords () { |
| 99 | 206 | await this.base.update() |
| 100 | 207 | const out = [] |
| 101 | 208 | for await (const { key, value } of this.base.view.createReadStream()) { |
| 102 | if (String(key).startsWith('__')) continue // reserved governance keys (slice 2) | |
| 209 | if (String(key).startsWith('__')) continue // reserved governance keys | |
| 103 | 210 | out.push({ key: String(key), value: value.record }) |
| 104 | 211 | } |
| 105 | 212 | return out |
| 106 | 213 | } |
| 107 | 214 | |
| 108 | // Project the atproto commit for the current indexed view. `prevRev`/`prev` continue an existing | |
| 109 | // chain (null = genesis). Deterministic + relay-valid (proven by the offline suite). | |
| 110 | 215 | async head ({ prevRev = null, prev = null } = {}) { |
| 111 | 216 | const records = await this.indexedRecords() |
| 112 | 217 | const rev = nextRev(prevRev) |
| … | … | @@ -119,3 +224,5 @@ export class AtprotoBase { |
| 119 | 224 | this._ready = false |
| 120 | 225 | } |
| 121 | 226 | } |
| 227 | ||
| 228 | export { crypto as _crypto } // convenience for callers minting identity keypairs in tests/tools |
| … | … | @@ -11,7 +11,8 @@ |
| 11 | 11 | "@noble/curves": "1.9.7", |
| 12 | 12 | "@noble/hashes": "1.8.0", |
| 13 | 13 | "atproto-identity": "file:../atproto-identity", |
| 14 | "atproto-repo": "file:../atproto-repo" | |
| 14 | "atproto-repo": "file:../atproto-repo", | |
| 15 | "hypercore-crypto": "^3.6.0" | |
| 15 | 16 | }, |
| 16 | 17 | "devDependencies": { |
| 17 | 18 | "autobase": "7.28.1", |
| … | … | @@ -176,7 +177,6 @@ |
| 176 | 177 | "version": "1.8.1", |
| 177 | 178 | "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", |
| 178 | 179 | "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", |
| 179 | "dev": true, | |
| 180 | 180 | "license": "Apache-2.0", |
| 181 | 181 | "peerDependencies": { |
| 182 | 182 | "react-native-b4a": "*" |
| … | … | @@ -198,7 +198,6 @@ |
| 198 | 198 | "version": "1.10.1", |
| 199 | 199 | "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz", |
| 200 | 200 | "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==", |
| 201 | "dev": true, | |
| 202 | 201 | "license": "Apache-2.0", |
| 203 | 202 | "dependencies": { |
| 204 | 203 | "bare-module-resolve": "^1.10.0", |
| … | … | @@ -217,7 +216,6 @@ |
| 217 | 216 | "version": "2.2.3", |
| 218 | 217 | "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz", |
| 219 | 218 | "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==", |
| 220 | "dev": true, | |
| 221 | 219 | "license": "Apache-2.0", |
| 222 | 220 | "dependencies": { |
| 223 | 221 | "bare-stream": "^2.6.5" |
| … | … | @@ -235,7 +233,6 @@ |
| 235 | 233 | "version": "1.2.0", |
| 236 | 234 | "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", |
| 237 | 235 | "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", |
| 238 | "dev": true, | |
| 239 | 236 | "license": "Apache-2.0", |
| 240 | 237 | "dependencies": { |
| 241 | 238 | "bare-inspect": "^3.1.2" |
| … | … | @@ -345,7 +342,6 @@ |
| 345 | 342 | "version": "2.9.1", |
| 346 | 343 | "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", |
| 347 | 344 | "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", |
| 348 | "dev": true, | |
| 349 | 345 | "license": "Apache-2.0", |
| 350 | 346 | "peerDependencies": { |
| 351 | 347 | "bare-abort-controller": "*" |
| … | … | @@ -446,7 +442,6 @@ |
| 446 | 442 | "version": "3.1.4", |
| 447 | 443 | "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", |
| 448 | 444 | "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", |
| 449 | "dev": true, | |
| 450 | 445 | "license": "Apache-2.0", |
| 451 | 446 | "dependencies": { |
| 452 | 447 | "bare-ansi-escapes": "^2.1.0", |
| … | … | @@ -529,7 +524,6 @@ |
| 529 | 524 | "version": "1.12.4", |
| 530 | 525 | "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz", |
| 531 | 526 | "integrity": "sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==", |
| 532 | "dev": true, | |
| 533 | 527 | "license": "Apache-2.0", |
| 534 | 528 | "dependencies": { |
| 535 | 529 | "bare-semver": "^1.0.0" |
| … | … | @@ -570,7 +564,7 @@ |
| 570 | 564 | "version": "3.1.1", |
| 571 | 565 | "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", |
| 572 | 566 | "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", |
| 573 | "dev": true, | |
| 567 | "devOptional": true, | |
| 574 | 568 | "license": "Apache-2.0" |
| 575 | 569 | }, |
| 576 | 570 | "node_modules/bare-pipe": { |
| … | … | @@ -616,7 +610,6 @@ |
| 616 | 610 | "version": "1.1.0", |
| 617 | 611 | "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", |
| 618 | 612 | "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==", |
| 619 | "dev": true, | |
| 620 | 613 | "license": "Apache-2.0" |
| 621 | 614 | }, |
| 622 | 615 | "node_modules/bare-signals": { |
| … | … | @@ -648,7 +641,6 @@ |
| 648 | 641 | "version": "2.13.3", |
| 649 | 642 | "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", |
| 650 | 643 | "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", |
| 651 | "dev": true, | |
| 652 | 644 | "license": "Apache-2.0", |
| 653 | 645 | "dependencies": { |
| 654 | 646 | "b4a": "^1.8.1", |
| … | … | @@ -764,7 +756,6 @@ |
| 764 | 756 | "version": "1.1.0", |
| 765 | 757 | "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", |
| 766 | 758 | "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", |
| 767 | "dev": true, | |
| 768 | 759 | "license": "Apache-2.0", |
| 769 | 760 | "engines": { |
| 770 | 761 | "bare": ">=1.2.0" |
| … | … | @@ -792,7 +783,7 @@ |
| 792 | 783 | "version": "2.4.6", |
| 793 | 784 | "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", |
| 794 | 785 | "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", |
| 795 | "dev": true, | |
| 786 | "devOptional": true, | |
| 796 | 787 | "license": "Apache-2.0", |
| 797 | 788 | "peer": true, |
| 798 | 789 | "dependencies": { |
| … | … | @@ -909,7 +900,6 @@ |
| 909 | 900 | "version": "3.3.0", |
| 910 | 901 | "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz", |
| 911 | 902 | "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==", |
| 912 | "dev": true, | |
| 913 | 903 | "license": "Apache-2.0", |
| 914 | 904 | "dependencies": { |
| 915 | 905 | "b4a": "^1.3.0" |
| … | … | @@ -997,7 +987,6 @@ |
| 997 | 987 | "version": "1.0.1", |
| 998 | 988 | "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", |
| 999 | 989 | "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", |
| 1000 | "dev": true, | |
| 1001 | 990 | "license": "Apache-2.0", |
| 1002 | 991 | "dependencies": { |
| 1003 | 992 | "bare-events": "^2.7.0" |
| … | … | @@ -1007,7 +996,6 @@ |
| 1007 | 996 | "version": "1.3.2", |
| 1008 | 997 | "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", |
| 1009 | 998 | "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", |
| 1010 | "dev": true, | |
| 1011 | 999 | "license": "MIT" |
| 1012 | 1000 | }, |
| 1013 | 1001 | "node_modules/fd-lock": { |
| … | … | @@ -1126,7 +1114,6 @@ |
| 1126 | 1114 | "version": "3.7.0", |
| 1127 | 1115 | "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz", |
| 1128 | 1116 | "integrity": "sha512-SWxobptQf2V/+ZLCCDRTXqFzGfTPIkNIuDyLKXpEMfcpJ1/ec94N9aWgylHcWOHmRt14ng/KR7z0BugebWHK9Q==", |
| 1129 | "dev": true, | |
| 1130 | 1117 | "license": "MIT", |
| 1131 | 1118 | "dependencies": { |
| 1132 | 1119 | "b4a": "^1.6.6", |
| … | … | @@ -1384,7 +1371,6 @@ |
| 1384 | 1371 | "version": "1.2.0", |
| 1385 | 1372 | "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", |
| 1386 | 1373 | "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", |
| 1387 | "dev": true, | |
| 1388 | 1374 | "license": "Apache-2.0", |
| 1389 | 1375 | "dependencies": { |
| 1390 | 1376 | "bare-addon-resolve": "^1.3.0" |
| … | … | @@ -1493,7 +1479,6 @@ |
| 1493 | 1479 | "version": "5.1.0", |
| 1494 | 1480 | "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", |
| 1495 | 1481 | "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", |
| 1496 | "dev": true, | |
| 1497 | 1482 | "license": "MIT", |
| 1498 | 1483 | "dependencies": { |
| 1499 | 1484 | "bare-assert": "^1.2.0", |
| … | … | @@ -1519,7 +1504,6 @@ |
| 1519 | 1504 | "version": "5.0.1", |
| 1520 | 1505 | "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", |
| 1521 | 1506 | "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", |
| 1522 | "dev": true, | |
| 1523 | 1507 | "license": "MIT", |
| 1524 | 1508 | "dependencies": { |
| 1525 | 1509 | "sodium-native": "^5.0.1" |
| … | … | @@ -1544,7 +1528,6 @@ |
| 1544 | 1528 | "version": "2.28.0", |
| 1545 | 1529 | "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", |
| 1546 | 1530 | "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", |
| 1547 | "dev": true, | |
| 1548 | 1531 | "license": "MIT", |
| 1549 | 1532 | "dependencies": { |
| 1550 | 1533 | "events-universal": "^1.0.0", |
| … | … | @@ -1567,7 +1550,6 @@ |
| 1567 | 1550 | "version": "1.0.1", |
| 1568 | 1551 | "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", |
| 1569 | 1552 | "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", |
| 1570 | "dev": true, | |
| 1571 | 1553 | "license": "MIT", |
| 1572 | 1554 | "dependencies": { |
| 1573 | 1555 | "streamx": "^2.12.5" |
| … | … | @@ -1589,7 +1571,6 @@ |
| 1589 | 1571 | "version": "1.2.7", |
| 1590 | 1572 | "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", |
| 1591 | 1573 | "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", |
| 1592 | "dev": true, | |
| 1593 | 1574 | "license": "Apache-2.0", |
| 1594 | 1575 | "dependencies": { |
| 1595 | 1576 | "b4a": "^1.6.4" |
| … | … | @@ -1658,7 +1639,6 @@ |
| 1658 | 1639 | "version": "1.4.0", |
| 1659 | 1640 | "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.4.0.tgz", |
| 1660 | 1641 | "integrity": "sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==", |
| 1661 | "dev": true, | |
| 1662 | 1642 | "license": "Apache-2.0" |
| 1663 | 1643 | }, |
| 1664 | 1644 | "node_modules/xache": { |
| … | … | @@ -11,7 +11,8 @@ |
| 11 | 11 | "atproto-repo": "file:../atproto-repo", |
| 12 | 12 | "atproto-identity": "file:../atproto-identity", |
| 13 | 13 | "@noble/curves": "1.9.7", |
| 14 | "@noble/hashes": "1.8.0" | |
| 14 | "@noble/hashes": "1.8.0", | |
| 15 | "hypercore-crypto": "^3.6.0" | |
| 15 | 16 | }, |
| 16 | 17 | "devDependencies": { |
| 17 | 18 | "brittle": "^3.0.0", |
| … | … | @@ -0,0 +1,74 @@ |
| 1 | // GOVERNANCE — proof-gated multi-writer admission (block-until-admitted golden rule) + roster | |
| 2 | // projection. A joiner proves membership, is admitted as a plain writer, writes a record, and both | |
| 3 | // co-owners converge to the IDENTICAL projected head. A non-member is rejected (its write THROWS | |
| 4 | // rather than being silently rolled back). | |
| 5 | ||
| 6 | import test from 'brittle' | |
| 7 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 8 | import { tmpdir } from 'node:os' | |
| 9 | import { join } from 'node:path' | |
| 10 | import crypto from 'hypercore-crypto' | |
| 11 | import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity' | |
| 12 | import { AtprotoBase } from '../lib/base.js' | |
| 13 | ||
| 14 | const MNEMONIC = 'test test test test test test test test test test test junk' | |
| 15 | const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }) | |
| 16 | const KEY = deriveAtprotoKey(MNEMONIC) | |
| 17 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] }) | |
| 18 | const hex = (b) => b.toString('hex') | |
| 19 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 20 | ||
| 21 | function mk (opts) { | |
| 22 | const dir = mkdtempSync(join(tmpdir(), 'gov-')) | |
| 23 | const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ...opts }) | |
| 24 | return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } | |
| 25 | } | |
| 26 | const pipe = (a, b) => { const s1 = a.base.base.replicate(true); const s2 = b.base.base.replicate(false); s1.pipe(s2).pipe(s1); return () => { try { s1.destroy() } catch {} ; try { s2.destroy() } catch {} } } | |
| 27 | async function settle (...ws) { for (let i = 0; i < 15; i++) { for (const w of ws) { try { await w.base.base.update() } catch {} } await sleep(60) } } | |
| 28 | ||
| 29 | test('governance: a member joiner is admitted (block-until-admitted) → writes → identical head', async (t) => { | |
| 30 | const creatorId = crypto.keyPair() | |
| 31 | const joinerId = crypto.keyPair() | |
| 32 | const roster = [{ key: hex(creatorId.publicKey), role: 1 }, { key: hex(joinerId.publicKey), role: 3 }] | |
| 33 | ||
| 34 | const C = mk({ identity: creatorId, members: roster }) | |
| 35 | await C.base.ready() | |
| 36 | const J = mk({ bootstrap: C.base.base.key, identity: joinerId }) | |
| 37 | await J.base.ready() | |
| 38 | const unpipe = pipe(C, J) | |
| 39 | t.teardown(async () => { unpipe(); await C.close(); await J.close() }) | |
| 40 | ||
| 41 | await C.base.projectMembers(roster) // land __members__ before the joiner rings | |
| 42 | await settle(C, J) | |
| 43 | ||
| 44 | // Joiner writes — ensureWritable rings the CONTENT-FREE doorbell and blocks until admitted. | |
| 45 | await J.base.putRecord('app.bsky.feed.post', '3joiner', post('hello from the joiner')) | |
| 46 | t.ok(J.base.writable, 'joiner was admitted as a writer (block-until-admitted)') | |
| 47 | ||
| 48 | await settle(C, J) | |
| 49 | const recsC = await C.base.indexedRecords() | |
| 50 | t.ok(recsC.find((r) => r.key.endsWith('3joiner')), "joiner's record replicated into the creator's shared view") | |
| 51 | ||
| 52 | const hC = await C.base.head() | |
| 53 | const hJ = await J.base.head() | |
| 54 | t.is(hC.commitCid.toString(), hJ.commitCid.toString(), 'identical projected head across co-owners after admission (deterministic, no signer election)') | |
| 55 | }) | |
| 56 | ||
| 57 | test('governance: a NON-member is rejected — write throws, never silently rolled back', async (t) => { | |
| 58 | const creatorId = crypto.keyPair() | |
| 59 | const outsiderId = crypto.keyPair() // NOT in the roster | |
| 60 | const roster = [{ key: hex(creatorId.publicKey), role: 1 }] | |
| 61 | ||
| 62 | const C = mk({ identity: creatorId, members: roster }) | |
| 63 | await C.base.ready() | |
| 64 | const X = mk({ bootstrap: C.base.base.key, identity: outsiderId }) | |
| 65 | await X.base.ready() | |
| 66 | const unpipe = pipe(C, X) | |
| 67 | t.teardown(async () => { unpipe(); await C.close(); await X.close() }) | |
| 68 | ||
| 69 | await C.base.projectMembers(roster) | |
| 70 | await settle(C, X) | |
| 71 | ||
| 72 | await t.exception(() => X.base.ensureWritable({ timeoutMs: 3000 }), 'non-member ensureWritable THROWS (not admitted) rather than allowing a silent-loss write') | |
| 73 | t.absent(X.base.writable, 'outsider never became writable') | |
| 74 | }) |