OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 016734c

0.9.31: control-plane front eviction (drop-front / allow-front / forget-did)
Twice on 2026-07-27 the only way to stop the membrane serving a wrong head was to restart it — which drops every other fronted DID's warm replica with it. There was a `diagnose` RPC to SEE a bad front and nothing to DO about it. Wires the operator surface onto atproto-did-peer 0.2.7's eviction API: drop-front {did, firehoseKey?, forget?} evict one co-owner (forget: keep it out across its re-announces) allow-front {did, firehoseKey?} the undo forget-did {did} drop everything cached for a DID so the next request re-fronts fresh All fail closed (not-ok, never a throw) with no router or no did. `diagnose` now also reports the denied map + per-front silentMs, so the thing an operator evicts is the thing they can first see. test/control-evict.test.js: 19 asserts over the real UDS control plane. Router behaviour itself is proven over a testnet in atproto-did-peer/test/evict.test.mjs. Bridge suite 45/45, 193/193 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
016734caafde531adaedc6526c8b029b6d49d7c7
by plankatron <fleeky@gmail.com> · 2026-07-28T14:38:19.000Z

Changed files (3) +107 -1

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.30",
3 "version": "0.9.31",
44 "description": "Stateless keyless atproto MEMBRANE — fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
55 "type": "module",
66 "main": "index.js",
pds/lib/control.js +26 -0
……@@ -52,6 +52,32 @@ export async function startControl ({ uds, name = 'atproto-bridge', version = nu
5252 return { ok: false, did, error: e.message }
5353 }
5454 })
55 // Eviction: stop serving a wrong head WITHOUT bouncing the process. A cached front outlives its peer's
56 // connection by design (its cores keep replicating over the next one), so before these handlers the only
57 // way to drop a front announcing a FORKED line — one whose rev sorts higher, so freshest-by-rev keeps
58 // picking it — was a restart. `forget: true` also DENIES that firehose key, which is required whenever
59 // the reason to evict is what the front is SERVING: peers re-announce on every append, so a plain drop
60 // would be undone milliseconds later. `allow-front` is the undo.
61 svc.handle('drop-front', async (ctx, params = {}) => {
62 if (!peers) return { ok: false, error: 'no peers router' }
63 if (!params.did) return { ok: false, error: 'did required' }
64 try {
65 return { ok: true, ...(await peers.dropFront(params.did, { firehoseKey: params.firehoseKey || null, forget: !!params.forget })) }
66 } catch (e) { return { ok: false, did: params.did, error: e.message } }
67 })
68 svc.handle('allow-front', async (ctx, params = {}) => {
69 if (!peers) return { ok: false, error: 'no peers router' }
70 if (!params.did) return { ok: false, error: 'did required' }
71 try { return { ok: true, ...peers.allowFront(params.did, params.firehoseKey || null) } } catch (e) { return { ok: false, did: params.did, error: e.message } }
72 })
73 // Drop EVERY cached front + resolved key for a DID so the next request re-fronts from scratch. Nothing is
74 // denied and the DID stays fronted — this is the "re-derive, don't restart" lever.
75 svc.handle('forget-did', async (ctx, params = {}) => {
76 if (!peers) return { ok: false, error: 'no peers router' }
77 if (!params.did) return { ok: false, error: 'did required' }
78 try { return { ok: true, ...(await peers.forgetDid(params.did)) } } catch (e) { return { ok: false, did: params.did, error: e.message } }
79 })
80
5581 svc.handle('resolve-handle', async (ctx, params = {}) => ({ did: peers ? peers.resolveHandle(params.handle) : null }))
5682
5783 await svc.listen()
pds/test/control-evict.test.js +80 -0
……@@ -0,0 +1,80 @@
1// The operator MUST be able to evict a front over the UDS control plane — without restarting the process.
2//
3// Twice on 2026-07-27 the only way to stop the membrane serving a wrong head (a peer announcing a FORKED
4// line whose rev sorts higher, so freshest-by-rev kept picking it) was to bounce atproto-bridge. That is
5// not acceptable for a production read surface: a restart drops every OTHER fronted DID's warm replica too.
6// See hyper-social#16 P0. The router-level behaviour (fallback reads, denial across re-announce, age-out)
7// is proven over a real testnet in atproto-did-peer/test/evict.test.mjs; this asserts the CONTROL SURFACE
8// is wired to it, since a capability with no operator surface is one nobody can use at 3am.
9import test from 'brittle'
10import { mkdtempSync, rmSync } from 'fs'
11import { join } from 'path'
12import { tmpdir } from 'os'
13import { Client } from 'hyper-svc'
14import { startControl } from '../lib/control.js'
15import { mkDidRouter } from './mk-did-router.js'
16
17const DID = 'did:plc:evictme000000000000000'
18
19test('control plane: drop-front / allow-front / forget-did evict a cached front in place', async (t) => {
20 const fakeSwarm = { on () {}, off () {}, join () { return { flushed: async () => {} } }, flush: async () => {}, connections: new Set() }
21 const router = mkDidRouter(fakeSwarm, {}, { fronted: [DID], refreshMs: 0 })
22
23 // Two co-owners of one DID, as the bridge caches them: 'good' + 'forked' (the higher-rev fork).
24 const mkFront = (rev) => ({ handle: null, _repoKey: null, head: { rev }, seq: { core: { length: 3 } }, rpc: null, close: async () => {} })
25 router._fronts.set(DID, new Map([['good', mkFront('3aaa')], ['forked', mkFront('3zzz')]]))
26 router._peers.set(DID, router._fronts.get(DID).get('forked'))
27 router._byDid.set(DID, new Map([['good', {}], ['forked', {}]]))
28
29 const udsDir = mkdtempSync(join(tmpdir(), 'evict-uds-'))
30 const uds = join(udsDir, 'svc.sock')
31 const control = await startControl({ uds, name: 'atproto-bridge-evict-test', peers: router })
32 const cli = await Client.connectUds(uds)
33 t.teardown(async () => {
34 await cli.close(); await control.close()
35 rmSync(udsDir, { recursive: true, force: true })
36 })
37
38 t.is(router._freshestFront(DID).head.rev, '3zzz', 'the forked (higher-rev) front is what reads resolve to')
39
40 // --- drop-front with forget: evict the fork and keep it out ---
41 const dropped = await cli.call('drop-front', { did: DID, firehoseKey: 'forked', forget: true })
42 t.ok(dropped.ok, 'drop-front returns ok')
43 t.alike(dropped.dropped, ['forked'], 'drop-front reports the evicted firehose key')
44 t.is(router._freshestFront(DID).head.rev, '3aaa', 'reads now resolve to the good co-owner — no restart')
45 t.absent(router._fronts.get(DID).has('forked'), 'the evicted front is out of the co-owner set')
46 t.ok(router._isDenied(DID, 'forked'), 'the evicted firehose key is DENIED (a re-announce cannot resurrect it)')
47
48 // The primary must have been re-pointed off the closed front, not left dangling.
49 t.is(router._peers.get(DID), router._fronts.get(DID).get('good'), 'primary re-pointed at a surviving front')
50
51 const diag = await cli.call('diagnose')
52 t.alike(diag.denied[DID], ['forked'], 'diagnose SHOWS the denial (an operator can see what is evicted)')
53
54 // --- allow-front: the undo ---
55 const allowed = await cli.call('allow-front', { did: DID, firehoseKey: 'forked' })
56 t.alike(allowed.allowed, ['forked'], 'allow-front clears the denial')
57 t.absent(router._isDenied(DID, 'forked'), 'no longer denied — the next announce may re-front it')
58
59 // --- forget-did: drop everything cached for the DID, deny nothing ---
60 const forgot = await cli.call('forget-did', { did: DID })
61 t.ok(forgot.ok, 'forget-did returns ok')
62 t.alike(forgot.dropped, ['good'], 'forget-did drops the remaining front')
63 t.absent(router._fronts.has(DID), 'no cached fronts remain for the DID')
64 t.absent(router._byDid.has(DID), 'identified connections cleared — the next request re-fronts from scratch')
65 t.ok(router.isFronted(DID), 'the DID is STILL fronted (forget-did must not make it unservable)')
66 t.absent(router._denied.has(DID), 'forget-did denies nothing')
67})
68
69test('control plane: eviction handlers fail closed without a did / router', async (t) => {
70 const udsDir = mkdtempSync(join(tmpdir(), 'evict-uds2-'))
71 const uds = join(udsDir, 'svc.sock')
72 const control = await startControl({ uds, name: 'atproto-bridge-evict-test2', peers: null })
73 const cli = await Client.connectUds(uds)
74 t.teardown(async () => { await cli.close(); await control.close(); rmSync(udsDir, { recursive: true, force: true }) })
75
76 for (const m of ['drop-front', 'allow-front', 'forget-did']) {
77 const r = await cli.call(m, {})
78 t.absent(r.ok, `${m} with no peers router reports not-ok instead of throwing`)
79 }
80})