// REVOCATION (keet remove-member). An admin bans a member: its writer is removeWritered from the
// autobase, it's dropped from the roster, and it CANNOT re-admit (the __removed__ gate) even with a
// valid proof. An admin cannot ban another admin (keet SELF_ONLY).
import test from 'brittle'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import crypto from 'hypercore-crypto'
import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
import { AtprotoBase } from '../lib/base.js'
const MNEMONIC = 'test test test test test test test test test test test junk'
const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
const KEY = deriveAtprotoKey(MNEMONIC)
const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
const hex = (b) => b.toString('hex')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
function mk (opts) { const dir = mkdtempSync(join(tmpdir(), 'rev-')); const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100, ...opts }); return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } }
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 {} } }
async function settle (...ws) { for (let i = 0; i < 20; i++) { for (const w of ws) { try { await w.base.base.update() } catch {} } await sleep(60) } }
const view = async (bs, key) => { const r = await bs.base.base.view.get(key); return r && r.value }
test('ban: an admin removes a member — writer removed, roster dropped, re-admission blocked', async (t) => {
const cId = crypto.keyPair(); const mId = crypto.keyPair()
const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(mId.publicKey), role: 3 }]
const C = mk({ identity: cId, members: roster }); await C.base.ready()
const M = mk({ bootstrap: C.base.base.key, identity: mId }); await M.base.ready()
const unpipe = pipe(C, M)
t.teardown(async () => { unpipe(); await C.close(); await M.close() })
await C.base.projectMembers(roster); await settle(C, M)
await M.base.putRecord('app.bsky.feed.post', '3m', post('from M')); await settle(C, M)
t.ok(M.base.writable, 'M was an admitted writer before the ban')
await C.base.ban(hex(mId.publicKey)); await settle(C, M)
const members = await view(C, '__members__'); const removed = await view(C, '__removed__')
t.absent(members[hex(mId.publicKey)], 'M dropped from the roster')
t.ok(removed && removed[hex(mId.publicKey)], 'M recorded in the __removed__ set')
t.absent(M.base.writable, 'M is no longer a writer (removeWritered)')
// M cannot re-admit even with a valid proof — the __removed__ gate rejects it.
await t.exception(() => M.base.ensureWritable({ timeoutMs: 2500 }), 'banned member cannot re-admit (throws)')
})
test('ban: an admin cannot ban another admin (SELF_ONLY)', async (t) => {
const cId = crypto.keyPair(); const aId = crypto.keyPair()
const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(aId.publicKey), role: 1 }] // both admins
const C = mk({ identity: cId, members: roster }); await C.base.ready()
const A = mk({ bootstrap: C.base.base.key, identity: aId }); await A.base.ready()
const unpipe = pipe(C, A)
t.teardown(async () => { unpipe(); await C.close(); await A.close() })
await C.base.projectMembers(roster); await settle(C, A)
await A.base.putRecord('app.bsky.feed.post', '3a', post('from A')); await settle(C, A)
await C.base.ban(hex(aId.publicKey)); await settle(C, A)
const members = await view(C, '__members__'); const removed = await view(C, '__removed__')
t.ok(members[hex(aId.publicKey)], 'the other admin is STILL in the roster (SELF_ONLY blocked the ban)')
t.absent(removed && removed[hex(aId.publicKey)], 'the other admin was NOT added to __removed__')
})