| … | … | @@ -29,9 +29,13 @@ import { projectCommit, nextRev } from './project.js' |
| 29 | 29 | // verified by reading keet-core 4.16 members/index.js in full). This is NOT StateBaseV3's move-indexer, |
| 30 | 30 | // which was a MISREAD (it demoted the creator cross-member; keet's move-indexer is a same-member |
| 31 | 31 | // device→device handoff — deliberately NOT ported). |
| 32 | | const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record', ADMIT: 'admit', SET_MEMBERS: 'set-members', START_INDEXING: 'start-indexing', STOP_INDEXING: 'stop-indexing' } |
| 32 | const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record', ADMIT: 'admit', SET_MEMBERS: 'set-members', START_INDEXING: 'start-indexing', STOP_INDEXING: 'stop-indexing', BAN: 'ban' } |
| 33 | 33 | const MEMBERS = '__members__' // reserved view key: { identityHex: role } |
| 34 | 34 | const DEVICES = '__devices__' // reserved view key: { writerKeyHex: identityHex } |
| 35 | const REMOVED = '__removed__' // reserved view key: { identityHex: { ts } } — banned; blocks re-admission |
| 36 | // NOTE: ban() removeWriters a device from THIS autobase (can't write the shared view) + blocks re-admit. |
| 37 | // It does NOT revoke the SHARED #atproto signing key at the FEDERATION layer — a lost device still holds |
| 38 | // that key until a PLC rotation (a separate, unbuilt ceremony; architecture-holes #6). Document loudly. |
| 35 | 39 | const RKEY = (collection, rkey) => `${collection}/${rkey}` |
| 36 | 40 | const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex')) |
| 37 | 41 | |
| … | … | @@ -134,6 +138,31 @@ export class AtprotoBase { |
| 134 | 138 | continue |
| 135 | 139 | } |
| 136 | 140 | |
| 141 | if (op.t === OP.BAN) { |
| 142 | // keet members/index.js `W` (remove-member): a moderator+ deactivates a member — mark it removed |
| 143 | // (blocks re-admission), drop from the roster, and removeWriter ALL its devices (removeable-gated). |
| 144 | // An admin can only be removed by ITSELF (keet SELF_ONLY). |
| 145 | const fromKey = node.from && node.from.key |
| 146 | const target = String(op.target || '') |
| 147 | if (!fromKey || !target) continue |
| 148 | const fromHex = fromKey.toString('hex') |
| 149 | const callerRole = await this._writerRole(view, fromHex) |
| 150 | if (callerRole == null || callerRole > 2) continue // must be admin(1) or moderator(2) |
| 151 | const drec = await view.get(DEVICES); const dev = (drec && drec.value) || {} |
| 152 | const mrec = await view.get(MEMBERS); const members = (mrec && mrec.value) || {} |
| 153 | const isSelf = dev[fromHex] === target |
| 154 | if (Number(members[target]) === 1 && !isSelf) continue // can't ban another admin (keet SELF_ONLY) |
| 155 | const rrec = await view.get(REMOVED); const removed = (rrec && rrec.value) || {} |
| 156 | removed[target] = { ts: op.ts || 0 }; await view.put(REMOVED, removed) |
| 157 | if (members[target] != null) { delete members[target]; await view.put(MEMBERS, members) } |
| 158 | for (const [writerHex, idHex] of Object.entries(dev)) { |
| 159 | if (idHex !== target) continue |
| 160 | try { const wk = b4a.from(writerHex, 'hex'); if (host.removeable(wk)) await host.removeWriter(wk) } catch {} |
| 161 | this._admitted.delete(writerHex) |
| 162 | } |
| 163 | continue |
| 164 | } |
| 165 | |
| 137 | 166 | if (op.t === OP.SET_RECORD) { |
| 138 | 167 | const existing = await view.get(op.key) |
| 139 | 168 | if (existing && existing.value && typeof existing.value.w === 'number' && existing.value.w > op.w) continue // LWW |
| … | … | @@ -161,6 +190,8 @@ export class AtprotoBase { |
| 161 | 190 | const signData = Buffer.concat([writerKey, this.base.key]) |
| 162 | 191 | const identity = Buffer.from(admit.identity, 'hex') |
| 163 | 192 | if (!crypto.verify(signData, Buffer.from(admit.proof, 'hex'), identity)) return null |
| 193 | const rrec = await view.get(REMOVED); const removed = (rrec && rrec.value) || {} |
| 194 | if (removed[admit.identity]) return null // banned identity cannot (re)admit even with a valid proof |
| 164 | 195 | const mrec = await view.get(MEMBERS) |
| 165 | 196 | const members = (mrec && mrec.value) || {} |
| 166 | 197 | const role = members[admit.identity] |
| … | … | @@ -218,6 +249,16 @@ export class AtprotoBase { |
| 218 | 249 | await this.base.update() |
| 219 | 250 | } |
| 220 | 251 | |
| 252 | // Ban a member by identity (keet remove-member): removeWriter its devices from THIS autobase + block |
| 253 | // re-admission. Caller must be admin/moderator (enforced in apply). ⚠ Does NOT revoke the shared |
| 254 | // #atproto key at bsky — that needs a PLC rotation (separate ceremony). ts stays fixed (op-carried, |
| 255 | // applied identically on every co-owner) so governance state converges deterministically. |
| 256 | async ban (targetIdentityHex) { |
| 257 | await this.ensureWritable() |
| 258 | await this.base.append(b4a.from(JSON.stringify({ t: OP.BAN, target: String(targetIdentityHex), ts: 0 }))) |
| 259 | await this.base.update() |
| 260 | } |
| 261 | |
| 221 | 262 | // Block until this co-owner is a genuinely ADMITTED writer, then return. No-op if already writable. |
| 222 | 263 | // Rings the CONTENT-FREE admission doorbell and waits; THROWS if no indexer admits us within the |
| 223 | 264 | // timeout (never a silent content loss). This is what makes putRecord/delRecord durable. |