OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway commits / eaa77b8

0.3.0: the lens role — loser-club's bsky custom feed, keyless, in-process (G2)
Replaces favela-web's lens (stopped since 2026-10-11; the feed has been down). - lib/lens.js: roster = the community room's members minus bans, read-only via the membrane's communityRooms() and watched; posts = each member's app.bsky.feed.post records from the repos the membrane fronts, indexed per member and re-read only when its served head moves (checked on relay-firehose appends, debounced, serialized) — never on bsky's request. favela-web's rules: newest createdAt first, TID rkeys only, cursor string-or-absent, limit 50 (max 100); opaque hex cursor; UnsupportedAlgorithm for unknown feeds; describeFeedGenerator names the community DID; queryLabels empty, subscribeLabels an empty stream. Served as membrane XRPC extensions, so did.json advertises #bsky_fg. - gateway: role 'lens' (after the membrane, which it serves through); env LENS_FEEDS=id=roomKey (default loser-club); metrics + CLI show feeds, members, unfronted members, posts, newest, per-member index errors. - Drops FAVELA_WEB_UPSTREAM from the defaults (atproto-bridge 0.9.55 deleted the proxy). Tests: lens 10/10 red first; gateway suite 32/32. Live, read-only: a fresh keyless reader of room 91ce4dbc got members [e2jev, wj62], community DID wqnwul, curation roster in 2 s. Parity on the LIVE records (304; 5 non-TID): first page identical to favela-web's FeedGenerator; favela-web's full list (200) is an ordered subsequence of the lens's (299) — it skips posts after a page boundary when a filtered record forces an over-fetch (it slices to 50 and returns the over-fetch cursor); the lens returns every TID post once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eaa77b8a181dfa73bd5398cf084f7fd0a754a4cb
by plankatron <fleeky@gmail.com> · 2026-10-14T00:17:48.000Z

Changed files (8) +440 -8

bin/favela-gateway.js +9 -0
……@@ -81,6 +81,15 @@ if (roles.includes('fediverse')) {
8181 try { configs.fediverse = assertUsable(loadApConfig()) } catch (err) { configs.fediverse = new Error('fediverse config: ' + err.message) }
8282}
8383
84// The lens's feeds: `id=roomKey` pairs, the shape favela-web's FAVELA_COMMUNITY_ROOMS used.
85if (roles.includes('lens')) {
86 const feeds = String(process.env.LENS_FEEDS || '').split(',').map((s) => s.trim()).filter(Boolean).map((pair) => {
87 const [id, roomKey] = pair.split('=')
88 return { id: (id || '').trim(), roomKey: (roomKey || '').trim() }
89 }).filter((f) => f.id && /^[0-9a-f]{64}$/i.test(f.roomKey))
90 configs.lens = feeds.length ? { feeds } : new Error('lens config: LENS_FEEDS has no id=roomKey pair')
91}
92
8493const gateway = await startGateway({ roles, log, version, ...configs })
8594
8695// The one control socket. A stale socket file from a killed process blocks bind; we only get here once
lib/cli.js +10 -0
……@@ -77,6 +77,16 @@ export function renderMetrics (m, { now = Date.now() } = {}) {
7777 }
7878 }
7979
80 if (m.lens) {
81 L.push('')
82 L.push('lens (bsky custom feeds)')
83 for (const f of m.lens.feeds) {
84 L.push(` ${f.id.padEnd(24)} ${f.communityDid ? f.posts + ' posts from ' + f.members + ' members, newest ' + (f.newest ? f.newest.slice(0, 10) : '?') : 'WAITING for the room (community DID not replicated yet)'}${f.curation !== 'roster' ? ' curation ' + f.curation + ' NOT IMPLEMENTED — serving roster' : ''}`)
85 if (f.unfronted.length) L.push(` ${''.padEnd(24)} NOT FRONTED (their posts are missing): ${f.unfronted.join(', ')}`)
86 for (const p of f.perMember) if (p.error) L.push(` ${''.padEnd(24)} ${p.did}: ${p.error}`)
87 }
88 }
89
8090 if (m.fediverse) {
8191 const f = m.fediverse
8292 L.push('')
lib/gateway.js +13 -2
……@@ -20,10 +20,19 @@ import { startMembrane } from 'atproto-bridge/pds/lib/membrane.js'
2020import { startApBridge } from 'ap-bridge/lib/bridge.js'
2121import os from 'bare-os'
2222import { createProbes } from './probes.js'
23import { createLens } from './lens.js'
2324
2425const ROLES = {
2526 membrane: async (cfg, opts) => startMembrane(cfg, opts),
26 fediverse: async (cfg, opts) => startApBridge(cfg, opts)
27 fediverse: async (cfg, opts) => startApBridge(cfg, opts),
28 // The lens serves THROUGH the membrane (its XRPC extensions, its store, its swarm, its fronts), so it
29 // needs the membrane role up and started first.
30 lens: async (cfg, opts, running) => {
31 if (!running.membrane) throw new Error('the lens needs the membrane role (start it before the lens)')
32 const lens = createLens({ membrane: running.membrane, feeds: (cfg && cfg.feeds) || [], log: opts.log })
33 await lens.start()
34 return lens
35 }
2736}
2837
2938const defaultLog = (o) => console.log(JSON.stringify(o))
……@@ -48,7 +57,7 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null,
4857 try {
4958 // A config the entry could not load arrives as an Error: that role is down, for that reason.
5059 if (configs[name] instanceof Error) throw configs[name]
51 running[name] = await ROLES[name](configs[name], { log: roleLog, bootstrap, version })
60 running[name] = await ROLES[name](configs[name], { log: roleLog, bootstrap, version }, running)
5261 log({ msg: 'role up', role: name })
5362 } catch (err) {
5463 down[name] = (err && err.message) || String(err)
……@@ -99,11 +108,13 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null,
99108 roles: {},
100109 membrane: null,
101110 fediverse: null,
111 lens: null,
102112 bsky: null
103113 }
104114 for (const name of roles) out.roles[name] = running[name] ? { up: true } : { up: false, error: down[name] }
105115 if (running.membrane) out.membrane = running.membrane.metrics()
106116 if (running.fediverse) out.fediverse = await running.fediverse.metrics()
117 if (running.lens) out.lens = running.lens.metrics()
107118 if (probes) {
108119 const dids = out.membrane.dids.map((d) => ({ did: d.did, rev: d.rev }))
109120 out.bsky = await probes.get({ dids, emitted: out.membrane.relay.emitted }, { force: !!params.refresh })
lib/lens.js +230 -0
……@@ -0,0 +1,230 @@
1// The lens role — a favela community's bsky custom feed, served keylessly by the gateway (hyper-social #80 G2).
2//
3// Replaces favela-web's lens (stopped since 2026-10-11, and the reason the loser-club feed was down). Same
4// public contract, same selection rules (favela-web lib/feedgen.js), different sources:
5//
6// WHO the community room's members minus bans — favela-community, READ-ONLY, opened by the membrane on
7// its own store and swarm (membrane.communityRooms()). Watched: a roster change re-applies at once.
8// WHAT each member's app.bsky.feed.post records, read from the repo the membrane already FRONTS. Indexed
9// per member and re-read only when that member's served head moves — checked on each append to the
10// relay's aggregate firehose. Never on bsky's request: a front's listRecords is a full scan.
11// HOW newest record createdAt first; ONLY TID rkeys (one bad rkey — loser-club's own 'self.hello' —
12// makes bsky reject the whole response with InvalidFeedResponse); `cursor` a string or ABSENT.
13//
14// Served through the membrane's XRPC extensions, so did.json advertises #bsky_fg and bsky's AppView reaches
15// it at pds.7rnx.net through the community's PLC #bsky_fg — nothing outside this process changes.
16//
17// Labels: nothing produces labels yet (the labeler design is open). queryLabels answers a valid empty set
18// and subscribeLabels holds an empty stream, so the community's advertised labeler is valid but silent.
19
20const GENERATOR = 'app.bsky.feed.generator'
21const POST = 'app.bsky.feed.post'
22const FLOOR = '0000-01-01T00:00:00.000Z' // favela-web's createdAt for a record that has none
23const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
24
25// Opaque cursor: hex of the URI-encoded sort key — pure ASCII, so no TextEncoder (Bare has none without a
26// polyfill). favela-web returned the raw key, NUL bytes and all.
27const toCursor = (key) => [...encodeURIComponent(key)].map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join('')
28const fromCursor = (c) => {
29 if (!/^([0-9a-f]{2})+$/.test(String(c || ''))) return null
30 let s = ''
31 for (let i = 0; i < c.length; i += 2) s += String.fromCharCode(parseInt(c.slice(i, i + 2), 16))
32 try { return decodeURIComponent(s) } catch { return null }
33}
34
35const parseFeedUri = (uri) => {
36 const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(String(uri || ''))
37 return m ? { did: m[1], collection: m[2], rkey: m[3] } : null
38}
39
40// favela-web's slugRkey: the feed id → the generator record's rkey.
41export function slugRkey (id) {
42 const s = String(id || '').toLowerCase().replace(/[^a-z0-9._~-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 64)
43 return s || 'feed'
44}
45
46/**
47 * @param {object} opts
48 * @param {object} opts.membrane the membrane role (startMembrane's return): extensions, peers, relay,
49 * metrics(), communityRooms()
50 * @param {Array<{id, roomKey}>} opts.feeds e.g. [{ id: 'loser-club', roomKey: '91ce4dbc…' }]
51 */
52export function createLens ({ membrane, feeds = [], log = () => {}, debounceMs = 1000, frontTimeoutMs = 20000 } = {}) {
53 const index = new Map() // did -> { rev, rows: [{ key, did, rkey, createdAt }], at }
54 const state = feeds.map((f) => ({ ...f, rkey: slugRkey(f.id), communityDid: null, curation: 'roster', members: [], unwatch: null, error: null }))
55 let rooms = null
56 let appendTimer = null
57 let queue = Promise.resolve()
58 let stopped = false
59 const errors = new Map() // did -> last indexing error
60
61 const fronted = (did) => !!(membrane.peers && membrane.peers.isFronted && membrane.peers.isFronted(did))
62 const servedRevs = () => {
63 const out = new Map()
64 for (const d of (membrane.metrics().dids || [])) out.set(d.did, d.rev)
65 return out
66 }
67
68 // Read one member's posts from its fronted repo. Serialized: one rebuild at a time keeps the load on the
69 // membrane's fronts bounded however many appends arrive.
70 function rebuild (did, rev) {
71 queue = queue.then(async () => {
72 if (stopped) return
73 try {
74 const front = await membrane.peers.front(did, { timeout: frontTimeoutMs })
75 const rows = []
76 let cursor = null
77 do {
78 const page = await front.listRecords(POST, { limit: 100, cursor })
79 for (const r of page.records || []) {
80 const rkey = String(r.uri || '').split('/').pop()
81 const v = r.value || {}
82 if (v.$type && v.$type !== POST) continue
83 if (!TID_RE.test(rkey)) continue
84 const createdAt = typeof v.createdAt === 'string' ? v.createdAt : FLOOR
85 rows.push({ key: `${createdAt}\u0000${did}\u0000${rkey}`, did, rkey, createdAt })
86 }
87 cursor = page.cursor || null
88 } while (cursor)
89 index.set(did, { rev, rows, at: Date.now() })
90 errors.delete(did)
91 log({ msg: 'lens: indexed member', did, rev, posts: rows.length })
92 } catch (err) {
93 errors.set(did, (err && err.message) || String(err))
94 log({ msg: 'lens: could not index member', did, error: errors.get(did) })
95 }
96 })
97 return queue
98 }
99
100 // Re-read every member (of every feed) whose served head differs from the one indexed.
101 function reconcile () {
102 const revs = servedRevs()
103 const wanted = new Set()
104 for (const f of state) for (const did of f.members) wanted.add(did)
105 for (const did of wanted) {
106 if (!fronted(did)) continue
107 const rev = revs.get(did) || null
108 const have = index.get(did)
109 if (!have || have.rev !== rev) rebuild(did, rev)
110 }
111 return queue
112 }
113
114 const onAppend = () => {
115 clearTimeout(appendTimer)
116 appendTimer = setTimeout(() => { reconcile() }, debounceMs)
117 }
118
119 function rowsFor (f) {
120 const all = []
121 for (const did of f.members) {
122 const e = index.get(did)
123 if (e) all.push(...e.rows)
124 }
125 return all.sort((a, b) => (a.key < b.key ? 1 : a.key > b.key ? -1 : 0)) // newest first, as favela-web
126 }
127
128 const findFeed = (did, rkey) => state.find((f) => f.communityDid === did && f.rkey === rkey) || null
129 const feedUri = (f) => `at://${f.communityDid}/${GENERATOR}/${f.rkey}`
130
131 async function getFeedSkeleton ({ query }) {
132 const p = parseFeedUri(query.get('feed'))
133 const f = p && p.collection === GENERATOR ? findFeed(p.did, p.rkey) : null
134 if (!f) return { status: 400, body: { error: 'UnsupportedAlgorithm', message: 'Unsupported algorithm' } }
135 const limit = Math.max(1, Math.min(Number(query.get('limit')) || 50, 100))
136 const after = query.get('cursor') ? fromCursor(query.get('cursor')) : null
137 let rows = rowsFor(f)
138 if (after != null) rows = rows.filter((r) => r.key < after)
139 const page = rows.slice(0, limit)
140 const body = { feed: page.map((r) => ({ post: `at://${r.did}/${POST}/${r.rkey}` })) }
141 // atproto: a string or ABSENT — `cursor: null` fails bsky's response validation for the whole feed.
142 if (rows.length > limit) body.cursor = toCursor(page[page.length - 1].key)
143 return { status: 200, body }
144 }
145
146 async function describeFeedGenerator ({ query }) {
147 const want = query.get('did')
148 const served = state.filter((f) => f.communityDid && (!want || f.communityDid === want))
149 // The lexicon requires `did`: the asked-for community, or the first served one.
150 const did = want || (served[0] && served[0].communityDid) || null
151 return { status: 200, body: { did, feeds: served.filter((f) => f.communityDid === did).map((f) => ({ uri: feedUri(f) })) } }
152 }
153
154 async function queryLabels () { return { status: 200, body: { labels: [] } } }
155
156 // An empty label stream: hold the socket open until the client goes; nothing to send yet.
157 function subscribeLabels (ws) {
158 ws.on('error', () => {})
159 ws.on('close', () => {})
160 }
161
162 const XRPC = {
163 'app.bsky.feed.getFeedSkeleton': getFeedSkeleton,
164 'app.bsky.feed.describeFeedGenerator': describeFeedGenerator,
165 'com.atproto.label.queryLabels': queryLabels
166 }
167 const WS = { '/xrpc/com.atproto.label.subscribeLabels': subscribeLabels }
168
169 async function start () {
170 rooms = await membrane.communityRooms()
171 for (const f of state) {
172 f.unwatch = await rooms.watch(f.roomKey, async (members) => {
173 f.members = [...members]
174 // meta:did and curation replicate with the room; re-read them on every change until they land.
175 try { f.communityDid = (await rooms.communityDid(f.roomKey)) || f.communityDid } catch {}
176 try { const c = await rooms.curation(f.roomKey); f.curation = (c && c.curator) || 'roster' } catch {}
177 if (f.curation !== 'roster') log({ msg: 'lens: curation is not roster — serving roster (only roster is implemented)', feed: f.id, curation: f.curation })
178 log({ msg: 'lens: roster', feed: f.id, members: f.members.length, communityDid: f.communityDid })
179 await reconcile()
180 })
181 }
182 if (membrane.relay && membrane.relay.seq && membrane.relay.seq.core) membrane.relay.seq.core.on('append', onAppend)
183 for (const [nsid, fn] of Object.entries(XRPC)) membrane.extensions.xrpc.set(nsid, fn)
184 for (const [path, fn] of Object.entries(WS)) membrane.extensions.ws.set(path, fn)
185 await queue
186 }
187
188 async function stop () {
189 stopped = true
190 clearTimeout(appendTimer)
191 for (const nsid of Object.keys(XRPC)) membrane.extensions.xrpc.delete(nsid)
192 for (const path of Object.keys(WS)) membrane.extensions.ws.delete(path)
193 if (membrane.relay && membrane.relay.seq && membrane.relay.seq.core && membrane.relay.seq.core.off) membrane.relay.seq.core.off('append', onAppend)
194 for (const f of state) { if (f.unwatch) { try { await f.unwatch() } catch {} } }
195 }
196
197 function metrics () {
198 return {
199 feeds: state.map((f) => {
200 const rows = rowsFor(f)
201 return {
202 id: f.id,
203 roomKey: f.roomKey,
204 communityDid: f.communityDid,
205 feedUri: f.communityDid ? feedUri(f) : null,
206 curation: f.curation,
207 members: f.members.length,
208 unfronted: f.members.filter((d) => !fronted(d)),
209 posts: rows.length,
210 newest: rows.length ? rows[0].createdAt : null,
211 perMember: f.members.map((d) => {
212 const e = index.get(d)
213 return { did: d, posts: e ? e.rows.length : 0, indexedAt: e ? e.at : null, error: errors.get(d) || null }
214 })
215 }
216 })
217 }
218 }
219
220 return {
221 start,
222 stop,
223 metrics,
224 status: () => ({ ok: true, lens: true, feeds: state.map((f) => ({ id: f.id, communityDid: f.communityDid, members: f.members.length })) }),
225 handlers: {},
226 steps: [['lens', () => stop()]]
227 }
228}
229
230export default createLens
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "favela-gateway",
3 "version": "0.2.1",
3 "version": "0.3.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "favela-gateway",
9 "version": "0.2.1",
9 "version": "0.3.0",
1010 "dependencies": {
1111 "ap-bridge": "file:../ap-bridge",
1212 "atproto-bridge": "file:../atproto-bridge",
package.json +3 -3
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-gateway",
3 "version": "0.2.1",
3 "version": "0.3.0",
44 "description": "The favela gateway: the bsky membrane (atproto-bridge) and the fediverse bridge (ap-bridge) in one process, keyless towards atproto. Later roles: the bsky lens and a blind seeder (hyper-social #80).",
55 "type": "module",
66 "main": "lib/gateway.js",
……@@ -37,13 +37,13 @@
3737 ],
3838 "defaults": {
3939 "env": {
40 "GATEWAY_ROLES": "membrane,fediverse",
40 "GATEWAY_ROLES": "membrane,fediverse,lens",
41 "LENS_FEEDS": "loser-club=91ce4dbcb6b4fe0cfa489582cb9931923c05e0d14cf0a5c939d8334edefe21ed",
4142 "GATEWAY_SOCK": "${HOME}/.config/favela-gateway/svc.sock",
4243 "PDS_BIND": "0.0.0.0",
4344 "PDS_PORT": "3399",
4445 "PDS_STORAGE": "${HOME}/.config/atproto-bridge/data",
4546 "PDS_CRAWL_RELAY": "https://bsky.network",
46 "FAVELA_WEB_UPSTREAM": "http://127.0.0.1:4041",
4747 "AP_BIND": "0.0.0.0",
4848 "AP_PORT": "3400",
4949 "AP_HOST": "ap.7rnx.net",
test/gateway.test.js +8 -1
……@@ -92,7 +92,7 @@ test('⛔ if every requested role fails, the gateway refuses to start', async (t
9292})
9393
9494test('⛔ an unknown role is refused rather than ignored', async (t) => {
95 await t.exception(startGateway({ roles: ['membrane', 'lens'], membrane: membraneConfig(), ...quiet }), /unknown role: lens/)
95 await t.exception(startGateway({ roles: ['membrane', 'seeder'], membrane: membraneConfig(), ...quiet }), /unknown role: seeder/)
9696})
9797
9898test('the stop steps free everything: the same ports start again in one process', async (t) => {
……@@ -136,3 +136,10 @@ test('metrics still answers with a role down, and names it', async (t) => {
136136 t.is(m.fediverse, null, 'no section for a role that is not running')
137137 t.ok(m.membrane, 'the running role still reports')
138138})
139
140test('⛔ the lens without a running membrane is down, with the reason — it serves THROUGH the membrane', async (t) => {
141 const g = await startGateway({ roles: ['fediverse', 'lens'], fediverse: apConfig(), lens: { feeds: [{ id: 'x', roomKey: 'a'.repeat(64) }] }, ...quiet })
142 t.teardown(() => stop(g))
143 t.absent(g.roles.lens)
144 t.ok(/needs the membrane/.test(g.down.lens), g.down.lens)
145})
test/lens.test.js +165 -0
……@@ -0,0 +1,165 @@
1// The lens: the loser-club bsky custom feed, served by the gateway in place of favela-web (hyper-social #80 G2).
2//
3// favela-web's rules, kept (feedgen.js): a community's feed is its room members' app.bsky.feed.post records,
4// newest createdAt first; ONLY TID rkeys (one bad rkey — loser-club's own 'self.hello' — makes bsky reject
5// the whole response); cursor a string or ABSENT, never null. What changes: posts come from the repos the
6// membrane already fronts, indexed per member only when that member's head moves — never on bsky's request.
7
8import test from 'brittle'
9import { createLens } from '../lib/lens.js'
10
11const ROOM = '91ce4dbcb6b4fe0cfa489582cb9931923c05e0d14cf0a5c939d8334edefe21ed'
12const LC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u'
13const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
14const E2 = 'did:plc:e2jevhuurrhlm36ufk7fklpg'
15const FEED = `at://${LC}/app.bsky.feed.generator/loser-club`
16
17// TIDs in increasing time order.
18const T = ['3mr2222222222', '3mr3333333333', '3mr4444444444', '3mr5555555555', '3mr6666666666', '3mr7777777777']
19const post = (rkey, createdAt) => ({ uri: 'x/' + rkey, value: { $type: 'app.bsky.feed.post', text: rkey, createdAt } })
20
21function fakeMembrane ({ members = [WJ, E2], records, revs }) {
22 const listeners = []
23 const state = { members, records, revs, listCalls: 0, watchers: [] }
24 const membrane = {
25 state,
26 extensions: { xrpc: new Map(), ws: new Map() },
27 relay: { seq: { core: { on: (ev, fn) => { if (ev === 'append') listeners.push(fn) }, off: () => {} } } },
28 appendNow: () => { for (const fn of listeners) fn() },
29 metrics: () => ({ dids: Object.keys(state.revs).map((did) => ({ did, rev: state.revs[did] })) }),
30 peers: {
31 isFronted: (did) => did in state.revs,
32 front: async (did) => ({
33 listRecords: async (col, { limit = 100, cursor = null } = {}) => {
34 state.listCalls++
35 const all = (state.records[did] || []).filter(() => col === 'app.bsky.feed.post')
36 const start = cursor ? all.findIndex((r) => r.uri === cursor) + 1 : 0
37 const page = all.slice(start, start + limit)
38 return { records: page, cursor: start + limit < all.length ? page[page.length - 1].uri : undefined }
39 }
40 })
41 },
42 communityRooms: async () => ({
43 members: async () => state.members,
44 communityDid: async () => LC,
45 curation: async () => ({ curator: 'roster', params: {} }),
46 watch: async (roomKey, onChange) => { state.watchers.push(onChange); await onChange(state.members); return async () => {} }
47 })
48 }
49 return membrane
50}
51
52const baseRecords = () => ({
53 [WJ]: [post(T[0], '2026-09-01T00:00:00Z'), post(T[3], '2026-09-04T00:00:00Z'), post('self.hello', '2026-09-09T00:00:00Z')],
54 [E2]: [post(T[1], '2026-09-02T00:00:00Z'), post(T[4], '2026-09-05T00:00:00Z')]
55})
56
57async function started (opts = {}) {
58 const m = fakeMembrane({ records: baseRecords(), revs: { [WJ]: 'r1', [E2]: 'r1', [LC]: 'r1' }, ...opts })
59 const lens = createLens({ membrane: m, feeds: [{ id: 'loser-club', roomKey: ROOM }], log: () => {}, debounceMs: 5 })
60 await lens.start()
61 return { m, lens }
62}
63
64const skeleton = (m, q) => m.extensions.xrpc.get('app.bsky.feed.getFeedSkeleton')({ query: new URLSearchParams(q) })
65
66test('★ the skeleton: members\' posts, newest createdAt first, TID rkeys only', async (t) => {
67 const { m, lens } = await started()
68 t.teardown(() => lens.stop())
69 const r = await skeleton(m, { feed: FEED })
70 t.is(r.status, 200)
71 t.alike(r.body.feed.map((f) => f.post), [
72 `at://${E2}/app.bsky.feed.post/${T[4]}`,
73 `at://${WJ}/app.bsky.feed.post/${T[3]}`,
74 `at://${E2}/app.bsky.feed.post/${T[1]}`,
75 `at://${WJ}/app.bsky.feed.post/${T[0]}`
76 ], 'self.hello is not a TID, so it is not in the feed')
77 t.absent('cursor' in r.body, 'exhausted: cursor ABSENT, never null')
78})
79
80test('★ pagination: pages concatenate to the whole feed, and only the last lacks a cursor', async (t) => {
81 const { m, lens } = await started()
82 t.teardown(() => lens.stop())
83 const p1 = (await skeleton(m, { feed: FEED, limit: '3' })).body
84 t.is(p1.feed.length, 3)
85 t.is(typeof p1.cursor, 'string', 'a string cursor while more remain')
86 const p2 = (await skeleton(m, { feed: FEED, limit: '3', cursor: p1.cursor })).body
87 t.is(p2.feed.length, 1)
88 t.absent('cursor' in p2)
89 t.ok(!/\u0000/.test(p1.cursor), 'the cursor is opaque text (favela-web\'s carried NUL bytes)')
90})
91
92test('limit: default 50, at most 100', async (t) => {
93 const { m, lens } = await started()
94 t.teardown(() => lens.stop())
95 t.is((await skeleton(m, { feed: FEED, limit: '1000' })).body.feed.length, 4, 'clamped, never an error')
96})
97
98test('⛔ an unknown feed is UnsupportedAlgorithm (400), as the feed-generator contract says', async (t) => {
99 const { m, lens } = await started()
100 t.teardown(() => lens.stop())
101 const r = await skeleton(m, { feed: `at://${LC}/app.bsky.feed.generator/nope` })
102 t.is(r.status, 400)
103 t.is(r.body.error, 'UnsupportedAlgorithm')
104})
105
106test('describeFeedGenerator names the community DID (the lexicon requires `did`)', async (t) => {
107 const { m, lens } = await started()
108 t.teardown(() => lens.stop())
109 const r = await m.extensions.xrpc.get('app.bsky.feed.describeFeedGenerator')({ query: new URLSearchParams() })
110 t.alike(r.body, { did: LC, feeds: [{ uri: FEED }] })
111})
112
113test('⛔ requests never rebuild the index; a member whose head MOVED is re-read on the next relay append', async (t) => {
114 const { m, lens } = await started()
115 t.teardown(() => lens.stop())
116 const before = m.state.listCalls
117 await skeleton(m, { feed: FEED }); await skeleton(m, { feed: FEED })
118 t.is(m.state.listCalls, before, 'two skeleton requests, zero repo reads')
119
120 m.state.records[WJ].push(post(T[5], '2026-09-06T00:00:00Z'))
121 m.appendNow() // an append whose head did not move for anyone: nothing to re-read
122 await new Promise((resolve) => setTimeout(resolve, 40))
123 t.is(m.state.listCalls, before, 'no head moved: no re-read')
124
125 m.state.revs[WJ] = 'r2'
126 m.appendNow()
127 await new Promise((resolve) => setTimeout(resolve, 60))
128 t.ok(m.state.listCalls > before, 'wj62\'s head moved: re-read')
129 t.is((await skeleton(m, { feed: FEED })).body.feed[0].post, `at://${WJ}/app.bsky.feed.post/${T[5]}`, 'and its new post leads the feed')
130})
131
132test('a member removed from the room leaves the feed', async (t) => {
133 const { m, lens } = await started()
134 t.teardown(() => lens.stop())
135 m.state.members = [WJ]
136 for (const w of m.state.watchers) await w([WJ])
137 const r = (await skeleton(m, { feed: FEED })).body
138 t.ok(r.feed.every((f) => f.post.includes(WJ)), 'only wj62 remains')
139})
140
141test('a member the membrane does not front is reported, not silently missing', async (t) => {
142 const { m, lens } = await started({ members: [WJ, E2, 'did:plc:stranger'] })
143 t.teardown(() => lens.stop())
144 const x = lens.metrics()
145 t.alike(x.feeds[0].unfronted, ['did:plc:stranger'])
146 t.is(x.feeds[0].members, 3)
147 t.is(x.feeds[0].posts, 4, 'indexed posts for the fronted members')
148 t.is(x.feeds[0].communityDid, LC)
149 t.is(x.feeds[0].curation, 'roster')
150})
151
152test('labels: queryLabels answers an empty, valid set; subscribeLabels is served', async (t) => {
153 const { m, lens } = await started()
154 t.teardown(() => lens.stop())
155 const r = await m.extensions.xrpc.get('com.atproto.label.queryLabels')({ query: new URLSearchParams({ uriPatterns: '*' }) })
156 t.alike(r.body, { labels: [] })
157 t.ok(m.extensions.ws.has('/xrpc/com.atproto.label.subscribeLabels'))
158})
159
160test('stop removes every registered method — a stopped lens serves nothing', async (t) => {
161 const { m, lens } = await started()
162 await lens.stop()
163 t.is(m.extensions.xrpc.size, 0)
164 t.is(m.extensions.ws.size, 0)
165})