OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 2b206a3

atproto-base: governance slice — proof-gated multi-writer admission (typed ops)
Ported clean from atproto-room/StateBaseV3 as TYPED ops (admit / set-members), not set-entry overloaded by magic keys. A joiner proves membership (sign localKey‖base.key with its identity key) in a CONTENT-FREE optimistic doorbell block; apply verifies the proof resolves to a member role (__members__/__devices__) and addWriters it as a plain writer (N=1 posture — no auto-indexer). The joiner BLOCKS until genuinely admitted (golden rule); a non-member's write THROWS rather than being silently rolled back. Admitted co-owners converge to the IDENTICAL projected head (deterministic, no signer election). set-members is admin/bootstrap-gated. Indexer topology (start-indexing/move-indexer), revocation (ban/set-epoch), and the multi-writer indexed-view checkout are next. test/governance.test.mjs (2 tests / 5 asserts). Suite now 11 tests / 50 asserts, green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2b206a38d343359e75abf391c6c48ba5f52e7df8
by plankatron <fleeky@gmail.com> · 2026-07-23T16:39:55.000Z

Changed files (4) +207 -45

lib/base.js +127 -20
……@@ -3,24 +3,36 @@
33// NO signer election: signing is a pure function of the indexed view + the shared #atproto key, so it
44// is indexer-agnostic (see DESIGN.md, proven by the offline suite).
55//
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.
1018
1119import Corestore from 'corestore'
1220import Autobase from 'autobase'
1321import Hyperbee from 'hyperbee'
1422import b4a from 'b4a'
23import crypto from 'hypercore-crypto'
1524import { projectCommit, nextRev } from './project.js'
1625
17// Typed op names (contract-router style, like keet — NOT one op overloaded by magic keys).
18const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record' }
19const RKEY = (collection, rkey) => `${collection}/${rkey}` // the atproto MST key
26const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record', ADMIT: 'admit', SET_MEMBERS: 'set-members' }
27const MEMBERS = '__members__' // reserved view key: { identityHex: role }
28const DEVICES = '__devices__' // reserved view key: { writerKeyHex: identityHex }
29const RKEY = (collection, rkey) => `${collection}/${rkey}`
30const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex'))
2031
2132export 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).
2436 constructor (opts = {}) {
2537 if (!opts.did) throw new Error('AtprotoBase requires opts.did')
2638 if (!opts.privateKey) throw new Error('AtprotoBase requires opts.privateKey (the #atproto signing key)')
……@@ -31,6 +43,9 @@ export class AtprotoBase {
3143 this._bootstrap = opts.bootstrap || null
3244 this._keyPair = opts.keyPair || null
3345 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()
3449 this.store = null
3550 this.base = null
3651 this._ready = false
……@@ -54,16 +69,45 @@ export class AtprotoBase {
5469 this.base = new Autobase(this.store, this._bootstrap, baseOpts)
5570 await this.base.ready()
5671 this._ready = true
72 if (this._members) this.projectMembers(this._members).catch(() => {}) // creator best-effort projection
5773 }
5874
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.
6176 async _apply (nodes, view, host) {
62 const tx = view // hyperbee batch is implicit via put/del; keep simple for slice 1
6377 for (const node of nodes) {
6478 if (!node.value) continue
6579 let op
6680 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
67111 if (op.t === OP.SET_RECORD) {
68112 const existing = await view.get(op.key)
69113 if (existing && existing.value && typeof existing.value.w === 'number' && existing.value.w > op.w) continue // LWW
……@@ -78,35 +122,96 @@ export class AtprotoBase {
78122
79123 get writable () { return this.base ? this.base.writable : false }
80124
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) ────────
83189 async putRecord (collection, rkey, record) {
190 await this.ensureWritable()
84191 const w = this.base.local ? this.base.local.length : 0
85192 await this.base.append(b4a.from(JSON.stringify({ t: OP.SET_RECORD, key: RKEY(collection, rkey), record, w })))
86193 await this.base.update()
87194 }
88195
89196 async delRecord (collection, rkey) {
197 await this.ensureWritable()
90198 const w = this.base.local ? this.base.local.length : 0
91199 await this.base.append(b4a.from(JSON.stringify({ t: OP.DEL_RECORD, key: RKEY(collection, rkey), w })))
92200 await this.base.update()
93201 }
94202
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.
98205 async indexedRecords () {
99206 await this.base.update()
100207 const out = []
101208 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
103210 out.push({ key: String(key), value: value.record })
104211 }
105212 return out
106213 }
107214
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).
110215 async head ({ prevRev = null, prev = null } = {}) {
111216 const records = await this.indexedRecords()
112217 const rev = nextRev(prevRev)
……@@ -119,3 +224,5 @@ export class AtprotoBase {
119224 this._ready = false
120225 }
121226}
227
228export { crypto as _crypto } // convenience for callers minting identity keypairs in tests/tools
package-lock.json +4 -24
……@@ -11,7 +11,8 @@
1111 "@noble/curves": "1.9.7",
1212 "@noble/hashes": "1.8.0",
1313 "atproto-identity": "file:../atproto-identity",
14 "atproto-repo": "file:../atproto-repo"
14 "atproto-repo": "file:../atproto-repo",
15 "hypercore-crypto": "^3.6.0"
1516 },
1617 "devDependencies": {
1718 "autobase": "7.28.1",
……@@ -176,7 +177,6 @@
176177 "version": "1.8.1",
177178 "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
178179 "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
179 "dev": true,
180180 "license": "Apache-2.0",
181181 "peerDependencies": {
182182 "react-native-b4a": "*"
……@@ -198,7 +198,6 @@
198198 "version": "1.10.1",
199199 "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz",
200200 "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==",
201 "dev": true,
202201 "license": "Apache-2.0",
203202 "dependencies": {
204203 "bare-module-resolve": "^1.10.0",
……@@ -217,7 +216,6 @@
217216 "version": "2.2.3",
218217 "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz",
219218 "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==",
220 "dev": true,
221219 "license": "Apache-2.0",
222220 "dependencies": {
223221 "bare-stream": "^2.6.5"
……@@ -235,7 +233,6 @@
235233 "version": "1.2.0",
236234 "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz",
237235 "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==",
238 "dev": true,
239236 "license": "Apache-2.0",
240237 "dependencies": {
241238 "bare-inspect": "^3.1.2"
……@@ -345,7 +342,6 @@
345342 "version": "2.9.1",
346343 "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz",
347344 "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
348 "dev": true,
349345 "license": "Apache-2.0",
350346 "peerDependencies": {
351347 "bare-abort-controller": "*"
……@@ -446,7 +442,6 @@
446442 "version": "3.1.4",
447443 "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz",
448444 "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==",
449 "dev": true,
450445 "license": "Apache-2.0",
451446 "dependencies": {
452447 "bare-ansi-escapes": "^2.1.0",
……@@ -529,7 +524,6 @@
529524 "version": "1.12.4",
530525 "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz",
531526 "integrity": "sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==",
532 "dev": true,
533527 "license": "Apache-2.0",
534528 "dependencies": {
535529 "bare-semver": "^1.0.0"
……@@ -570,7 +564,7 @@
570564 "version": "3.1.1",
571565 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz",
572566 "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==",
573 "dev": true,
567 "devOptional": true,
574568 "license": "Apache-2.0"
575569 },
576570 "node_modules/bare-pipe": {
……@@ -616,7 +610,6 @@
616610 "version": "1.1.0",
617611 "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz",
618612 "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==",
619 "dev": true,
620613 "license": "Apache-2.0"
621614 },
622615 "node_modules/bare-signals": {
……@@ -648,7 +641,6 @@
648641 "version": "2.13.3",
649642 "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz",
650643 "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==",
651 "dev": true,
652644 "license": "Apache-2.0",
653645 "dependencies": {
654646 "b4a": "^1.8.1",
……@@ -764,7 +756,6 @@
764756 "version": "1.1.0",
765757 "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz",
766758 "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==",
767 "dev": true,
768759 "license": "Apache-2.0",
769760 "engines": {
770761 "bare": ">=1.2.0"
……@@ -792,7 +783,7 @@
792783 "version": "2.4.6",
793784 "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz",
794785 "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==",
795 "dev": true,
786 "devOptional": true,
796787 "license": "Apache-2.0",
797788 "peer": true,
798789 "dependencies": {
……@@ -909,7 +900,6 @@
909900 "version": "3.3.0",
910901 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
911902 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
912 "dev": true,
913903 "license": "Apache-2.0",
914904 "dependencies": {
915905 "b4a": "^1.3.0"
……@@ -997,7 +987,6 @@
997987 "version": "1.0.1",
998988 "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
999989 "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
1000 "dev": true,
1001990 "license": "Apache-2.0",
1002991 "dependencies": {
1003992 "bare-events": "^2.7.0"
……@@ -1007,7 +996,6 @@
1007996 "version": "1.3.2",
1008997 "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
1009998 "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
1010 "dev": true,
1011999 "license": "MIT"
10121000 },
10131001 "node_modules/fd-lock": {
……@@ -1126,7 +1114,6 @@
11261114 "version": "3.7.0",
11271115 "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz",
11281116 "integrity": "sha512-SWxobptQf2V/+ZLCCDRTXqFzGfTPIkNIuDyLKXpEMfcpJ1/ec94N9aWgylHcWOHmRt14ng/KR7z0BugebWHK9Q==",
1129 "dev": true,
11301117 "license": "MIT",
11311118 "dependencies": {
11321119 "b4a": "^1.6.6",
……@@ -1384,7 +1371,6 @@
13841371 "version": "1.2.0",
13851372 "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
13861373 "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==",
1387 "dev": true,
13881374 "license": "Apache-2.0",
13891375 "dependencies": {
13901376 "bare-addon-resolve": "^1.3.0"
……@@ -1493,7 +1479,6 @@
14931479 "version": "5.1.0",
14941480 "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
14951481 "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==",
1496 "dev": true,
14971482 "license": "MIT",
14981483 "dependencies": {
14991484 "bare-assert": "^1.2.0",
……@@ -1519,7 +1504,6 @@
15191504 "version": "5.0.1",
15201505 "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz",
15211506 "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==",
1522 "dev": true,
15231507 "license": "MIT",
15241508 "dependencies": {
15251509 "sodium-native": "^5.0.1"
……@@ -1544,7 +1528,6 @@
15441528 "version": "2.28.0",
15451529 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz",
15461530 "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==",
1547 "dev": true,
15481531 "license": "MIT",
15491532 "dependencies": {
15501533 "events-universal": "^1.0.0",
……@@ -1567,7 +1550,6 @@
15671550 "version": "1.0.1",
15681551 "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
15691552 "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
1570 "dev": true,
15711553 "license": "MIT",
15721554 "dependencies": {
15731555 "streamx": "^2.12.5"
……@@ -1589,7 +1571,6 @@
15891571 "version": "1.2.7",
15901572 "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
15911573 "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
1592 "dev": true,
15931574 "license": "Apache-2.0",
15941575 "dependencies": {
15951576 "b4a": "^1.6.4"
……@@ -1658,7 +1639,6 @@
16581639 "version": "1.4.0",
16591640 "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.4.0.tgz",
16601641 "integrity": "sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==",
1661 "dev": true,
16621642 "license": "Apache-2.0"
16631643 },
16641644 "node_modules/xache": {
package.json +2 -1
……@@ -11,7 +11,8 @@
1111 "atproto-repo": "file:../atproto-repo",
1212 "atproto-identity": "file:../atproto-identity",
1313 "@noble/curves": "1.9.7",
14 "@noble/hashes": "1.8.0"
14 "@noble/hashes": "1.8.0",
15 "hypercore-crypto": "^3.6.0"
1516 },
1617 "devDependencies": {
1718 "brittle": "^3.0.0",
test/governance.test.mjs +74 -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
6import test from 'brittle'
7import { mkdtempSync, rmSync } from 'node:fs'
8import { tmpdir } from 'node:os'
9import { join } from 'node:path'
10import crypto from 'hypercore-crypto'
11import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
12import { AtprotoBase } from '../lib/base.js'
13
14const MNEMONIC = 'test test test test test test test test test test test junk'
15const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
16const KEY = deriveAtprotoKey(MNEMONIC)
17const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
18const hex = (b) => b.toString('hex')
19const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
20
21function 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}
26const 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 {} } }
27async 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
29test('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
57test('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})