OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 5bb795a

0.3.59: an empty roster must not subscribe to the entire network
a1007b0 (0.3.58) made the roster empty-by-default, deliberately, so a stranger's install would stop silently indexing this fleet's timeline. It inverted this path at the same time. spacedust filters SERVER-SIDE. buildSubscribeUrl appends nothing for an empty array (spacedust.js:80-83), and a subscribe with no subject axis is UNFILTERED. So "unconfigured" stopped meaning "subscribe to our roster" and started meaning "subscribe to EVERYONE'S". The URL builder's own test documents that contract -- "the caller must mean deliberately" -- and the caller never checked. Measured on ashatron 2026-08-17, which had no FAVELA_ROSTER: 98 retained posts from a single unrelated DID, zero from the roster, filling a 256MB retention budget with material it was never meant to hold. Its /api/fronts was healthy the whole time, so nothing looked wrong. Fail closed at BOTH doors: _connect() refuses when the filter has no subject axis. Guarded in _connect rather than start() so a RECONNECT cannot quietly restore an unfiltered subscription after a runtime change. updateFilter() refuses an update that would remove every subject -- the server REPLACES the whole query, so an empty one is the same unfiltered subscription reached by a different door. Refuses WITHOUT mutating this.filter, so the previous narrower query stays in force: stale but bounded, never wider than what was already asked for. `sources` deliberately does NOT count as a subject axis: it defaults to every source, so a sources-only filter looks configured and narrows nothing. allowUnfiltered:true preserves the URL builder's contract for a caller that really does want the firehose -- it just has to say so rather than arrive there via an empty array. Refusals are counted (stats.refusedUnfiltered) and logged with the fix, because an operator seeing nothing arrive needs to know why. Test written FIRST and confirmed red for the right reason: an empty roster DID open a socket. Existing spacedust test updated: one client constructed with {} now passes a subject axis. That case tests queue/drain mechanics, not filtering, so its intent is unchanged. The buildSubscribeUrl({}) assertion is left exactly as it was -- the pure builder's contract is legitimate and still holds. Full suite green: SPACEDUST 41, SPACEDUST FAIL-CLOSED 12, INBOUND 41, RETENTION 48, LINK-DRAIN 21, and the rest; npm test exit 0. Refs: hyper-social #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5bb795af688508bf0e220689005161261450ab8c
by plankatron <fleeky@gmail.com> · 2026-08-17T20:10:55.000Z

Changed files (4) +147 -5

lib/spacedust.js +42 -2
……@@ -161,9 +161,26 @@ export function backoffDelay (attempt, { min, max }, random = Math.random) {
161161 return Math.floor(random() * ceiling)
162162}
163163
164// Does this filter narrow the stream BY SUBJECT? `sources` does not count: it defaults to every
165// source, so a sources-only filter looks configured and narrows nothing. Without a subject axis the
166// server sends the whole network (see buildSubscribeUrl — an empty array appends no param at all).
167export function hasSubjectAxis (filter = {}) {
168 return (filter.subjectDids || []).length > 0 ||
169 (filter.subjects || []).length > 0 ||
170 (filter.subjectPrefixes || []).length > 0
171}
172
164173export class SpacedustClient {
174 // FAIL CLOSED on an empty roster (hyper-social #47). 0.3.58 made the roster empty-by-default so an
175 // unconfigured node would not silently index someone else's timeline — but this path turned the same
176 // condition into a subscription to EVERYONE'S. Measured on ashatron: 98 retained posts from one
177 // unrelated DID, zero from the roster. Unconfigured has to mean inert, not indiscriminate.
178 //
179 // `allowUnfiltered: true` preserves the URL builder's documented contract for a caller that really
180 // does want the firehose — it just has to say so, rather than arrive there via an empty array.
165181 constructor (filter = {}, opts = {}) {
166182 this.opts = { ...DEFAULTS, ...opts }
183 this._allowUnfiltered = !!opts.allowUnfiltered
167184 this.filter = {
168185 subjectDids: filter.subjectDids || [],
169186 subjects: filter.subjects || [],
……@@ -186,7 +203,7 @@ export class SpacedustClient {
186203 this._reconnectTimer = null
187204
188205 // Observability — §7.6 budgets are meaningless if drops are silent.
189 this.stats = { received: 0, parsed: 0, dropped: 0, unparseable: 0, connects: 0, disconnects: 0 }
206 this.stats = { received: 0, parsed: 0, dropped: 0, unparseable: 0, connects: 0, disconnects: 0, refusedUnfiltered: 0 }
190207 }
191208
192209 // Pointers waiting to be drained. The caller owns the drain loop, so it can do fetch/verify/retain at
……@@ -202,6 +219,17 @@ export class SpacedustClient {
202219 }
203220
204221 _connect () {
222 // Refuse rather than widen. Guarded here, not in start(), so a RECONNECT cannot quietly restore an
223 // unfiltered subscription after the roster was emptied at runtime.
224 if (!hasSubjectAxis(this.filter) && !this._allowUnfiltered) {
225 this.stats.refusedUnfiltered++
226 console.error(JSON.stringify({
227 msg: 'spacedust: refusing to subscribe with no subject filter',
228 reason: 'an empty roster would subscribe to the ENTIRE network, not to nothing',
229 fix: 'set FAVELA_ROSTER (or FAVELA_LIST), or pass allowUnfiltered:true to mean it'
230 }))
231 return
232 }
205233 const url = buildSubscribeUrl(this.filter, this.opts)
206234 let socket
207235 try {
……@@ -268,7 +296,19 @@ export class SpacedustClient {
268296 // Change the filter WITHOUT reconnecting. Send the complete filter — the server replaces wholesale.
269297 // Falls back to a reconnect if the socket is not writable, so a roster change is never silently lost.
270298 updateFilter (filter = {}) {
271 this.filter = { ...this.filter, ...filter }
299 // The server REPLACES the whole query, so an empty update is the same unfiltered subscription
300 // reached by a different door. Refuse WITHOUT mutating this.filter: the previous (narrower) query
301 // stays in force, which is stale but bounded — never wider than what was already asked for.
302 const next = { ...this.filter, ...filter }
303 if (!hasSubjectAxis(next) && !this._allowUnfiltered) {
304 this.stats.refusedUnfiltered++
305 console.error(JSON.stringify({
306 msg: 'spacedust: refusing a filter update that would remove every subject',
307 reason: 'the server replaces the whole query — an empty one matches the ENTIRE network'
308 }))
309 return false
310 }
311 this.filter = next
272312 if (this._socket === null) return false
273313 try {
274314 this._socket.write(optionsUpdateFrame(this.filter))
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.58",
3 "version": "0.3.59",
44 "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
……@@ -55,7 +55,7 @@
5555 "spike:seam": "node spike/atcute-seam.mjs",
5656 "spike:p2p": "node spike/p2p-read.mjs",
5757 "web": "bare bin/favela-web.js",
58 "test": "node test/render.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
58 "test": "node test/render.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
5959 "test:ws": "bare test/subscribe-labels-ws.test.mjs",
6060 "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs",
6161 "spike:pack": "bare spike/mobile-pack-probe.mjs"
test/spacedust-failclosed.test.mjs +102 -0
……@@ -0,0 +1,102 @@
1// AN EMPTY ROSTER MUST NOT SUBSCRIBE TO THE ENTIRE NETWORK (hyper-social #47).
2//
3// spacedust filters SERVER-SIDE: `wantedSubjectDids` narrows the stream to links pointing at our
4// roster. `buildSubscribeUrl` appends nothing for an empty array (spacedust.js:80-83) — and a
5// subscribe with no subject axis is UNFILTERED. That is the documented contract of the URL builder
6// ("the caller must mean deliberately"), and the caller never checked.
7//
8// So when 0.3.58 made the roster empty-by-default — deliberately, so a stranger's install would not
9// silently index this fleet's timeline — it inverted this path at the same time: a node with no
10// roster stopped subscribing to OUR subjects and started subscribing to EVERYONE'S. Measured on
11// ashatron 2026-08-17: 98 retained posts from a single unrelated DID and zero from the roster,
12// filling a 256MB retention budget with material it was never meant to hold.
13//
14// "Unconfigured means inert" has to hold on this path too. Inert, not indiscriminate.
15//
16// The escape hatch stays, because the URL builder's contract is legitimate — it just has to be
17// asked for explicitly rather than arrived at by an empty array.
18// Run: node test/spacedust-failclosed.test.mjs
19import { SpacedustClient, optionsUpdateFrame, SOURCES } from '../lib/spacedust.js'
20
21let pass = 0
22const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
23
24function fakeSocket () {
25 const handlers = {}
26 return {
27 written: [],
28 on (ev, cb) { (handlers[ev] = handlers[ev] || []).push(cb); return this },
29 emit (ev, arg) { for (const cb of handlers[ev] || []) cb(arg) },
30 write (d) { this.written.push(d) },
31 end () {}
32 }
33}
34
35// ── an empty roster refuses to connect ───────────────────────────────────────────────────────────
36{
37 let made = 0
38 const c = new SpacedustClient({ subjectDids: [] }, { createSocket: () => { made++; return fakeSocket() } })
39 c.start()
40 ok(made === 0, 'an empty roster does NOT open a socket — inert, not indiscriminate')
41 ok(c.stats.connects === 0, 'and does not count a connect')
42 ok(c.stats.refusedUnfiltered === 1, 'the refusal is COUNTED, not silent — an operator must be able to see why nothing arrives')
43 c.close()
44}
45
46// A filter with only `sources` is still unfiltered by subject: sources defaults to ALL of them, so it
47// narrows nothing. This is the subtle case — it LOOKS configured.
48{
49 let made = 0
50 const c = new SpacedustClient({ sources: [SOURCES.LIKE] }, { createSocket: () => { made++; return fakeSocket() } })
51 c.start()
52 ok(made === 0, 'a sources-only filter is still unfiltered BY SUBJECT and is refused too')
53 c.close()
54}
55
56// ── the deliberate case still works, but must be asked for ───────────────────────────────────────
57{
58 let made = 0
59 const c = new SpacedustClient({ subjectDids: [] }, { createSocket: () => { made++; return fakeSocket() }, allowUnfiltered: true })
60 c.start()
61 ok(made === 1, 'allowUnfiltered:true still subscribes — the URL builder contract is preserved, just explicit')
62 c.close()
63}
64
65// ── a real roster is unaffected ──────────────────────────────────────────────────────────────────
66{
67 let made = 0
68 const c = new SpacedustClient({ subjectDids: ['did:plc:a'] }, { createSocket: () => { made++; return fakeSocket() } })
69 c.start()
70 ok(made === 1, 'a configured roster connects normally')
71 ok(c.stats.connects === 1, 'and counts the connect')
72 c.close()
73}
74
75// ── emptying the roster at RUNTIME must not widen the query ──────────────────────────────────────
76// updateFilter REPLACES the whole server-side query (spacedust.js:92-94). Sending an empty one is
77// the same unfiltered subscription, reached by a different door.
78{
79 let sock
80 const c = new SpacedustClient({ subjectDids: ['did:plc:a'] }, { createSocket: () => (sock = fakeSocket()) })
81 c.start()
82 const before = sock.written.length
83 const sent = c.updateFilter({ subjectDids: [] })
84 ok(sent === false, 'emptying the roster at runtime returns false rather than replacing the query')
85 ok(sock.written.length === before, 'and writes NO options frame — the widened query never reaches the server')
86 ok(c.stats.refusedUnfiltered === 1, 'the runtime refusal is counted too')
87 c.close()
88}
89
90// ── a narrowing runtime update still works ───────────────────────────────────────────────────────
91{
92 let sock
93 const c = new SpacedustClient({ subjectDids: ['did:plc:a'] }, { createSocket: () => (sock = fakeSocket()) })
94 c.start()
95 const sent = c.updateFilter({ subjectDids: ['did:plc:a', 'did:plc:b'] })
96 ok(sent === true, 'a real roster change is still sent without reconnecting')
97 const frame = JSON.parse(sock.written[sock.written.length - 1])
98 ok((frame.payload.wantedSubjectDids || []).length === 2, 'and carries the complete new filter')
99 c.close()
100}
101
102console.log(`\nSPACEDUST FAIL-CLOSED OK: ${pass} assertions`)
test/spacedust.test.mjs +1 -1
……@@ -138,7 +138,7 @@ function fakeSocket () {
138138{
139139 // A drain smaller than the queue must not lose the remainder.
140140 let sock
141 const c = new SpacedustClient({}, { createSocket: () => (sock = fakeSocket()) })
141 const c = new SpacedustClient({ subjectDids: ['did:plc:sub'] }, { createSocket: () => (sock = fakeSocket()) })
142142 c.start()
143143 for (let i = 0; i < 4; i++) {
144144 const e = JSON.parse(JSON.stringify(LIKE))