// AN EMPTY ROSTER MUST NOT SUBSCRIBE TO THE ENTIRE NETWORK (hyper-social #47).
//
// spacedust filters SERVER-SIDE: `wantedSubjectDids` narrows the stream to links pointing at our
// roster. `buildSubscribeUrl` appends nothing for an empty array (spacedust.js:80-83) — and a
// subscribe with no subject axis is UNFILTERED. That is the documented contract of the URL builder
// ("the caller must mean deliberately"), and the caller never checked.
//
// So when 0.3.58 made the roster empty-by-default — deliberately, so a stranger's install would not
// silently index this fleet's timeline — it inverted this path at the same time: a node with no
// roster stopped subscribing to OUR subjects and started subscribing to EVERYONE'S. Measured on
// ashatron 2026-08-17: 98 retained posts from a single unrelated DID and zero from the roster,
// filling a 256MB retention budget with material it was never meant to hold.
//
// "Unconfigured means inert" has to hold on this path too. Inert, not indiscriminate.
//
// The escape hatch stays, because the URL builder's contract is legitimate — it just has to be
// asked for explicitly rather than arrived at by an empty array.
// Run: node test/spacedust-failclosed.test.mjs
import { SpacedustClient, optionsUpdateFrame, SOURCES } from '../lib/spacedust.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
function fakeSocket () {
const handlers = {}
return {
written: [],
on (ev, cb) { (handlers[ev] = handlers[ev] || []).push(cb); return this },
emit (ev, arg) { for (const cb of handlers[ev] || []) cb(arg) },
write (d) { this.written.push(d) },
end () {}
}
}
// ── an empty roster refuses to connect ───────────────────────────────────────────────────────────
{
let made = 0
const c = new SpacedustClient({ subjectDids: [] }, { createSocket: () => { made++; return fakeSocket() } })
c.start()
ok(made === 0, 'an empty roster does NOT open a socket — inert, not indiscriminate')
ok(c.stats.connects === 0, 'and does not count a connect')
ok(c.stats.refusedUnfiltered === 1, 'the refusal is COUNTED, not silent — an operator must be able to see why nothing arrives')
c.close()
}
// A filter with only `sources` is still unfiltered by subject: sources defaults to ALL of them, so it
// narrows nothing. This is the subtle case — it LOOKS configured.
{
let made = 0
const c = new SpacedustClient({ sources: [SOURCES.LIKE] }, { createSocket: () => { made++; return fakeSocket() } })
c.start()
ok(made === 0, 'a sources-only filter is still unfiltered BY SUBJECT and is refused too')
c.close()
}
// ── the deliberate case still works, but must be asked for ───────────────────────────────────────
{
let made = 0
const c = new SpacedustClient({ subjectDids: [] }, { createSocket: () => { made++; return fakeSocket() }, allowUnfiltered: true })
c.start()
ok(made === 1, 'allowUnfiltered:true still subscribes — the URL builder contract is preserved, just explicit')
c.close()
}
// ── a real roster is unaffected ──────────────────────────────────────────────────────────────────
{
let made = 0
const c = new SpacedustClient({ subjectDids: ['did:plc:a'] }, { createSocket: () => { made++; return fakeSocket() } })
c.start()
ok(made === 1, 'a configured roster connects normally')
ok(c.stats.connects === 1, 'and counts the connect')
c.close()
}
// ── emptying the roster at RUNTIME must not widen the query ──────────────────────────────────────
// updateFilter REPLACES the whole server-side query (spacedust.js:92-94). Sending an empty one is
// the same unfiltered subscription, reached by a different door.
{
let sock
const c = new SpacedustClient({ subjectDids: ['did:plc:a'] }, { createSocket: () => (sock = fakeSocket()) })
c.start()
const before = sock.written.length
const sent = c.updateFilter({ subjectDids: [] })
ok(sent === false, 'emptying the roster at runtime returns false rather than replacing the query')
ok(sock.written.length === before, 'and writes NO options frame — the widened query never reaches the server')
ok(c.stats.refusedUnfiltered === 1, 'the runtime refusal is counted too')
c.close()
}
// ── a narrowing runtime update still works ───────────────────────────────────────────────────────
{
let sock
const c = new SpacedustClient({ subjectDids: ['did:plc:a'] }, { createSocket: () => (sock = fakeSocket()) })
c.start()
const sent = c.updateFilter({ subjectDids: ['did:plc:a', 'did:plc:b'] })
ok(sent === true, 'a real roster change is still sent without reconnecting')
const frame = JSON.parse(sock.written[sock.written.length - 1])
ok((frame.payload.wantedSubjectDids || []).length === 2, 'and carries the complete new filter')
c.close()
}
console.log(`\nSPACEDUST FAIL-CLOSED OK: ${pass} assertions`)