OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer / test / evict.test.mjs

165 lines · javascript ref:
// Front EVICTION — the loser-club shape (hyper-social #16 P0).
//
// A cached front used to live for the life of the PROCESS: _onConnClose deliberately keeps it and
// _ensureFront returns the existing entry on every later announce. So when a peer announced a FORKED line
// whose rev sorted higher, the only way to stop serving that wrong head was to RESTART the bridge. This
// asserts the two surgical alternatives:
//
//   A. dropFront(did, { firehoseKey, forget: true }) — evict one co-owner and KEEP it out. Reads fall back
//      to the authoritative co-owner immediately, with no restart, and the offender's continued announces
//      (peers re-announce on every append) do not resurrect it. allowFront() puts it back.
//   B. age-out — a front whose peer stopped announcing is dropped by the refresh timer, but ONLY while a
//      live verified sibling remains. The LAST front is never dropped, so age-out can't cause an outage.
//
// Owns-swarm posture, exactly as atproto-bridge/pds/bin/pds-serve.js constructs the router.
// Run: node test/evict.test.mjs
import { mkdtempSync, rmSync } from 'node:fs'
import { join as pjoin } from 'node:path'
import { tmpdir } from 'node:os'
import createTestnet from '@hyperswarm/testnet'
import Hyperswarm from 'hyperswarm'
import { RepoStore } from 'atproto-store/lib/repo-store.js'
import { RepoManager } from 'atproto-store/lib/repo-manager.js'
import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
import { openCorestore } from 'atproto-store/lib/corestore.js'
import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js'
import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
import { DidPeer, DidPeerRouter } from '../index.js'

const assert = (c, m) => { if (!c) { console.error('FAIL:', m); cleanupSync(); process.exit(1) } console.log('  ok -', m) }
const iso = '2026-07-21T00:00:00.000Z'
const dirs = []
const toClose = []
function newDir (p) { const d = mkdtempSync(pjoin(tmpdir(), p)); dirs.push(d); return d }
function cleanupSync () { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }) } catch {} } }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: iso, langs: ['en'] } })

const id = createAtprotoIdentityFromKey(new Uint8Array(32).fill(9), { handle: 'evict.test', pdsUrl: 'https://pds.local' })
const DID = id.did

const testnet = await createTestnet(3)
const bootstrap = testnet.bootstrap

// A co-owner holding its own cores over the SHARED DID key. `append` adds a commit, which makes the holder
// re-announce (DidPeer wires _announceAll to the firehose 'append') — that is how we prove denial holds.
async function coOwner (nCommits, tag) {
  const store = openCorestore(newDir('evict-' + tag + '-'))
  const repo = await new RepoStore(store, { name: `atproto-repo:${DID}` }).ready()
  let micros = 1_700_000_000_000_000n + BigInt(nCommits) * 60_000_000n // distinct bases => unambiguous rev order
  const mgr = await new RepoManager(repo, { did: DID, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init()
  const seq = await new FirehoseSequencer(store.get({ name: 'firehose' })).ready()
  let n = 0
  const append = async () => { const w = await mgr.applyWrite(post('3ev' + tag + n++ + 'aaaaa2', 'c' + n)); await seq.sequenceCommit(w, { time: iso }) }
  for (let i = 0; i < nCommits; i++) await append()
  const peer = new DidPeer(store, { did: DID, verifyKey: id.publicKey })
  await peer.provide({ repoKey: repo.core.key, firehoseKey: seq.core.key })
  const swarm = new Hyperswarm({ bootstrap })
  swarm.on('connection', (c) => peer.attach(c))
  await swarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
  toClose.push(async () => { try { await swarm.destroy() } catch {}; try { await peer.close() } catch {}; try { await store.close() } catch {} })
  return { peer, swarm, append, fhKey: seq.core.key.toString('hex'), tag }
}

// Reader in the bridge's posture: the router owns the swarm, joins topics itself, runs the refresh timer.
function reader (opts = {}) {
  const store = openCorestore(newDir('reader-'))
  const swarm = new Hyperswarm({ bootstrap })
  const router = new DidPeerRouter(store, {
    swarm,
    join: (did) => { try { swarm.join(discoveryTopic(did), { server: false, client: true }); swarm.flush().catch(() => {}) } catch {} },
    fronted: [DID],
    resolveVerifyKey: async () => id.publicKey,
    ...opts
  })
  return { router, store, swarm, close: async () => { try { await swarm.destroy() } catch {}; try { await router.close() } catch {}; try { await store.close() } catch {} } }
}

// Served record count (which co-owner is being served — they hold different numbers of records).
async function served (router) {
  const f = await router.front(DID).catch(() => null)
  if (!f) return -1
  // listRecords returns { records, cursor } per the lexicon (0.2.10) — NOT a bare array.
  return (((await f.listRecords('app.bsky.feed.post').catch(() => null)) || {}).records || []).length
}
async function waitServed (router, want, ms = 15000) {
  const end = Date.now() + ms
  let last = -1
  while (Date.now() < end) { last = await served(router); if (last === want) return last; await sleep(250) }
  return last
}
const frontKeys = (router) => [...(router._fronts.get(DID) || new Map()).keys()]

const behind = await coOwner(1, 'b') // authoritative-but-lower-rev stand-in
const ahead = await coOwner(2, 'a')  // the "forked line that sorts higher" stand-in
console.log('behind fh', behind.fhKey.slice(0, 8), '(1 rec) | ahead fh', ahead.fhKey.slice(0, 8), '(2 rec)')

// ---------------- A. operator eviction ----------------
console.log('\nA. dropFront / allowFront (no restart)')
const A = reader({ staleFrontMs: 0 }) // age-out off — isolate operator eviction
assert(await waitServed(A.router, 2) === 2, 'the higher-rev co-owner is served (the wrong-head situation)')
assert(frontKeys(A.router).length === 2, 'both co-owners are fronted')

const drop = await A.router.dropFront(DID, { firehoseKey: ahead.fhKey, forget: true })
assert(drop.dropped.includes(ahead.fhKey), 'dropFront reports the evicted firehose key')
assert(!frontKeys(A.router).includes(ahead.fhKey), 'the evicted front is gone from the co-owner set')
assert(await waitServed(A.router, 1) === 1, 'reads fall back to the other co-owner WITHOUT a restart')

// The offender keeps announcing (a peer re-announces on every append). Denial must survive that.
await ahead.append()
await sleep(2000)
assert(!frontKeys(A.router).includes(ahead.fhKey), 're-announce does NOT resurrect a forgotten front')
assert(await served(A.router) === 1, 'still serving the surviving co-owner after the offender re-announced')

const allow = A.router.allowFront(DID, ahead.fhKey)
assert(allow.allowed.includes(ahead.fhKey), 'allowFront clears the denial')
await ahead.append() // its next announce may re-front it now
const refronted = await (async () => {
  const end = Date.now() + 15000
  while (Date.now() < end) { if (frontKeys(A.router).includes(ahead.fhKey)) return true; await sleep(250) }
  return false
})()
assert(refronted, 'after allowFront the evicted co-owner is FRONTED again on its next announce')
// NOTE: re-fronted is not the same as re-SERVED. These two holders are independent LINES (the cheapest way
// to tell them apart by record count), so the 0.2.8 continuity gate keeps reads on the incumbent while the
// incumbent is live — deliberately. Line switching is proven in test/fork.test.mjs against a fixture that
// models same-line co-owners properly.
assert(await served(A.router) === 1, 'reads stay on the incumbent line (continuity gate) after re-fronting')
await A.close()

// ---------------- B. age-out ----------------
console.log('\nB. age-out of a front whose peer stopped announcing')
const B = reader({ refreshMs: 500, refreshTimeoutMs: 500, staleFrontMs: 1500 })
const okBoth = await (async () => {
  const end = Date.now() + 20000
  while (Date.now() < end) { await served(B.router); if (frontKeys(B.router).length === 2) return true; await sleep(250) }
  return false
})()
assert(okBoth, 'both co-owners fronted before the age-out window')

await behind.swarm.destroy() // behind stops announcing (its connection closes -> no longer live)
const gone = await (async () => {
  const end = Date.now() + 20000
  while (Date.now() < end) { if (!frontKeys(B.router).includes(behind.fhKey)) return true; await sleep(250) }
  return false
})()
assert(gone, 'the silent co-owner ages out')
assert(frontKeys(B.router).includes(ahead.fhKey), 'the live co-owner is kept')

// The remaining front goes silent too. It must NOT be dropped — age-out may never make a DID unservable.
await ahead.swarm.destroy()
await sleep(5000) // >> staleFrontMs (1500) + several refresh ticks
assert(frontKeys(B.router).includes(ahead.fhKey), 'the LAST front is never aged out (no self-inflicted outage)')
// ...and the kept front is actually REACHABLE with every co-owner away: front() serves the verified cache
// instead of stalling on _waitForDid until it throws.
const t0 = Date.now()
const stillServed = await served(B.router)
assert(stillServed >= 1, `the DID is still servable from the kept front (got ${stillServed})`)
assert(Date.now() - t0 < 5000, 'serving from cache does not stall on the identified-connection wait')
await B.close()

console.log('\nEVICT OK: a wrong head is evictable in place; age-out is bounded by "never the last front"')
for (const close of toClose) await close()
await testnet.destroy()
cleanupSync()