OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / ea1552a

0.9.55: XRPC extensions replace the favela-web proxy
The gateway's lens serves getFeedSkeleton / describeFeedGenerator / queryLabels / subscribeLabels in the same process, so the membrane no longer proxies them to favela-web over HTTP (favela-web is stopped and retiring). - createPdsServer({ extensions: { xrpc: Map, ws: Map } }), exposed as server.extensions: a registered XRPC GET handler answers BEFORE the AppView proxy (getFeedSkeleton starts with app.bsky.), always no-store; a registered WS path takes the upgrade. - lib/did-doc.js: #bsky_fg is advertised exactly when a feed skeleton is served (was: when a favela-web URL was configured). - membrane: owns the extension maps; communityRooms() opens community rooms READ-ONLY on its own store and swarm with its own favela-community import (as the feed-registry reactor does); a stop step closes them. - Deleted: favela-proxy.js and its tests, FAVELA_WEB_UPSTREAM (config and pgh defaults), the subscribeLabels upstream pipe. Tests first: test/bare/extensions.test.js (5, red on the missing module); membrane-did-web ported to the new API. Bare 23/23, Node 73/73. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ea1552a3bc10a0f064cfe3dbae4ccb251f3f4939
by plankatron <fleeky@gmail.com> · 2026-10-14T00:08:07.000Z

Changed files (11) +160 -184

package.json +1 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.54",
3 "version": "0.9.55",
44 "description": "Stateless keyless atproto MEMBRANE \u2014 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",
……@@ -92,7 +92,6 @@
9292 "PDS_PORT": "3399",
9393 "PDS_STORAGE": "${HOME}/.config/atproto-bridge/data",
9494 "PDS_SOCK": "${HOME}/.config/atproto-bridge/svc.sock",
95 "FAVELA_WEB_UPSTREAM": "http://10.22.0.1:4041",
9695 "PDS_CRAWL_RELAY": "https://bsky.network"
9796 }
9897 },
pds/lib/config.js +0 -3
……@@ -38,9 +38,6 @@ export function loadConfig () {
3838 return {
3939 serverDid,
4040 userDomain: pick('userDomain', 'PDS_USER_DOMAIN', '.7rnx.net'),
41 // favela-web gateway the membrane proxies lens-export service reads to (getFeedSkeleton / queryLabels
42 // / subscribeLabels — spec/lens-export.md §4). Null = the routes 501 (feature off). e.g. http://10.22.0.1:4041
43 favelaWebUpstream: pick('favelaWebUpstream', 'FAVELA_WEB_UPSTREAM', null),
4441 port: parseInt(pick('port', 'PDS_PORT', '3399'), 10),
4542 bind: pick('bind', 'PDS_BIND', '127.0.0.1'),
4643 storage, // holds the front-store (aggregate firehose + replicated peer cores) — NOT accounts
pds/lib/did-doc.js +15 -0
……@@ -0,0 +1,15 @@
1// The membrane's own did:web document, served at GET /.well-known/did.json — it makes did:web:<host>
2// (serverDid) RESOLVABLE. When a feed generator is served here (the gateway's lens registered
3// app.bsky.feed.getFeedSkeleton as an XRPC extension), it advertises #bsky_fg pointing back at this host,
4// so a feed record whose `did` is this did:web resolves here. Without one it is still a valid
5// (empty-service) did doc. Returns null for a non-did:web serverDid.
6//
7// (Until 0.9.55 this lived in favela-proxy.js and keyed on a favela-web upstream URL; the lens is now a
8// role of the same process, so the honest condition is "is a feed skeleton actually served here".)
9export function buildMembraneDidDoc (serverDid, { feedGenerator = false } = {}) {
10 if (!serverDid || !/^did:web:/.test(serverDid)) return null
11 const host = serverDid.slice('did:web:'.length)
12 const doc = { '@context': ['https://www.w3.org/ns/did/v1'], id: serverDid, service: [] }
13 if (feedGenerator) doc.service.push({ id: '#bsky_fg', type: 'BskyFeedGenerator', serviceEndpoint: `https://${host}` })
14 return doc
15}
pds/lib/favela-proxy.js +0 -60
……@@ -1,60 +0,0 @@
1// Favela lens-export proxy (spec/lens-export.md §4) — forward the feed-generator + labeler SERVICE XRPC to
2// the favela-web gateway over the VPN. A community DID's PLC doc points #bsky_fg / #atproto_labeler at this
3// membrane; the membrane fronts the community's identity/repo/firehose the usual way, and forwards ONLY
4// these stateless service reads to favela-web (which holds the appview index + the signed label feed).
5//
6// getFeedSkeleton / describeFeedGenerator / queryLabels are HTTP GET (proxied here). subscribeLabels is a
7// WS firehose — proxied separately in server-bare's upgrade path.
8
9const FAVELA_METHODS = new Set([
10 'app.bsky.feed.getFeedSkeleton',
11 'app.bsky.feed.describeFeedGenerator',
12 'com.atproto.label.queryLabels'
13])
14
15// Is this a stateless lens-export service read we forward to favela-web?
16export function isFavelaMethod (nsid) { return FAVELA_METHODS.has(nsid) }
17
18// The membrane's own did:web document, served at GET /.well-known/did.json — this makes did:web:<host>
19// (serverDid) RESOLVABLE. When the lens-export is on (favelaWebUpstream set), it advertises the #bsky_fg
20// BskyFeedGenerator service pointing back at the membrane, so a feed record whose `service` = this did:web
21// resolves here and bsky calls getFeedSkeleton at this host → proxyToFavela → favela-web. Without the
22// feature it is still a valid (empty-service) did doc. Returns null for a non-did:web serverDid.
23export function buildMembraneDidDoc (serverDid, { favelaWebUpstream = null } = {}) {
24 if (!serverDid || !/^did:web:/.test(serverDid)) return null
25 const host = serverDid.slice('did:web:'.length)
26 const doc = { '@context': ['https://www.w3.org/ns/did/v1'], id: serverDid, service: [] }
27 if (favelaWebUpstream) doc.service.push({ id: '#bsky_fg', type: 'BskyFeedGenerator', serviceEndpoint: `https://${host}` })
28 return doc
29}
30
31// Proxy one GET to favela-web. upstream = the gateway base URL (config.favelaWebUpstream, e.g.
32// http://10.22.0.1:4041). doFetch(url,{method,headers}) → { status, contentType, body:Buffer } (the same
33// injected fetch proxyToAppView uses). 501 when unconfigured; 502 on upstream failure.
34// The lens is co-located or one VPN hop away, and bsky imposes its own (undocumented) ceiling on a
35// feed generator, so a lens read gets a much tighter deadline than a general upstream call. A blown
36// deadline is a 502 like any other upstream failure — the difference is that it now HAPPENS instead
37// of hanging the request forever when the lens host is unreachable.
38export const FAVELA_TIMEOUT_MS = 2000
39
40export async function proxyToFavela (req, res, { nsid, search, cors, doFetch, upstream, timeoutMs = FAVELA_TIMEOUT_MS }) {
41 if (!upstream) {
42 const b = Buffer.from(JSON.stringify({ error: 'NotConfigured', message: 'favela-web upstream not configured' }))
43 res.writeHead(501, { 'content-type': 'application/json', 'content-length': b.length, ...cors })
44 return res.end(b)
45 }
46 let up
47 try {
48 up = await doFetch(`${String(upstream).replace(/\/$/, '')}/xrpc/${nsid}${search || ''}`, { method: 'GET', headers: {}, timeoutMs })
49 } catch (e) {
50 const b = Buffer.from(JSON.stringify({ error: 'UpstreamFailure', message: `favela proxy failed: ${e.message}` }))
51 res.writeHead(502, { 'content-type': 'application/json', 'content-length': b.length, ...cors })
52 return res.end(b)
53 }
54 // These favela service reads are DYNAMIC — getFeedSkeleton returns new roster posts every minute. Mark
55 // them no-store so bsky's AppView / any CDN / the Apache proxy in front NEVER cache them and serve a
56 // stale ("stuck days behind") feed. favela sets this header too, but this proxy previously forwarded only
57 // content-type/length and DROPPED it, so bsky never saw it and cached the skeleton. Set explicitly here.
58 res.writeHead(up.status, { 'content-type': up.contentType || 'application/json', 'content-length': up.body.length, ...cors, 'cache-control': 'no-store, no-cache, max-age=0, must-revalidate' })
59 res.end(up.body)
60}
pds/lib/membrane.js +18 -2
……@@ -42,7 +42,7 @@ const defaultLog = (o) => console.log(JSON.stringify(o))
4242
4343/**
4444 * @param {object} cfg loadConfig()'s shape: serverDid, userDomain, frontedDids, feedRegistries,
45 * favelaWebUpstream, crawlRelay, crawlHostname, port, bind, storage
45 * crawlRelay, crawlHostname, port, bind, storage
4646 * @param {object} [opts]
4747 * @param {Function} [opts.log] one object per line
4848 * @param {Array} [opts.bootstrap] DHT bootstrap; tests pass [] to stay off the public DHT
……@@ -147,7 +147,11 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null }
147147 }
148148
149149 // 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers.
150 const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], favelaWebUpstream: cfg.favelaWebUpstream })
150 // XRPC methods and WebSocket paths another role of this process serves through the membrane (the
151 // gateway's lens). Empty until a role registers; did.json advertises #bsky_fg once a skeleton is served.
152 const extensions = { xrpc: new Map(), ws: new Map() }
153 let rooms = null
154 const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], extensions })
151155 await new Promise((resolve, reject) => {
152156 server.once('error', reject)
153157 server.listen(cfg.port, cfg.bind, resolve)
……@@ -271,6 +275,17 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null }
271275 relay,
272276 handlers: membraneHandlers({ peers }),
273277 status: () => ({ ok: true, membrane: true, port, fronted: peers.list() }),
278 extensions,
279 // A community room, READ-ONLY, on this membrane's own store and swarm — the lens's roster (who is in
280 // the feed). Opened with the membrane's OWN favela-community import, exactly as the feed-registry
281 // reactor does, so a corestore never crosses module copies. Lazily created once; closed by a stop step.
282 communityRooms: async () => {
283 if (!rooms) {
284 const { CommunityRooms } = await import('favela-community')
285 rooms = new CommunityRooms(frontStore.namespace('comm'), swarm)
286 }
287 return rooms
288 },
274289 // The relay guard's last pass and heals, for `metrics`. Read-only.
275290 guard: () => ({ ...guard }),
276291 // What the gateway's panel and CLI show for this role, from local state only — no network call.
……@@ -298,6 +313,7 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null }
298313 // here), plus the seq-guard timer.
299314 steps: [
300315 ['seq-guard', () => { if (seqGuardTimer) clearInterval(seqGuardTimer); if (seqGuardSoon) clearTimeout(seqGuardSoon) }],
316 ['community-rooms', async () => { if (rooms && typeof rooms.close === 'function') await rooms.close() }],
301317 ['peers', () => peers.close()],
302318 ['relay', () => relay.close()],
303319 ['swarm', () => swarm.destroy()],
pds/lib/server-bare.js +30 -28
……@@ -12,7 +12,7 @@ import bareWs from 'bare-ws'
1212import { tidNow } from 'atproto-store/lib/tid.js'
1313import { buildRecordProofCar } from './sync-get-record.js'
1414import { proxyToAppView, isAppViewMethod } from './appview.js'
15import { proxyToFavela, isFavelaMethod, buildMembraneDidDoc } from './favela-proxy.js'
15import { buildMembraneDidDoc } from './did-doc.js'
1616import { withDeadline } from './deadline.js'
1717
1818// Ceiling for ANY upstream call (AppView proxy, describeRepo's PLC read). Generous on purpose — this
……@@ -25,8 +25,8 @@ const bearer = (req) => (req.headers.authorization || '').replace(/^Bearer\s+/i,
2525const jwtSub = (jwt) => { try { const p = String(jwt).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(Buffer.from(p, 'base64').toString('utf8')).sub } catch { return null } }
2626
2727// Upstream client (bare): request → { status, contentType, body:Buffer }. Picks http vs https by the URL
28// scheme — the AppView proxy targets https://api.bsky.app, but the favela lens-export proxy targets a plain
29// http:// gateway over the VPN, and https.request on an http URL hangs on a TLS handshake (the proxy-hang bug).
28// scheme — the AppView proxy targets https://api.bsky.app, but an upstream may be plain http://, and
29// https.request on an http URL hangs on a TLS handshake (the proxy-hang bug).
3030// ⛔ The deadline is NOT optional. Without it this rejects only on the request's 'error' event, which
3131// an unreachable HOST never produces: the agent times the socket out and destroys it, and
3232// client-connection turns that into a bare `req.destroy()` with no error — so the promise dangles and
……@@ -108,7 +108,13 @@ const headerSafe = (s) => String(s).replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g
108108
109109// The membrane serves ONLY fronted peers. `peers` (PeerRouter) owns the fronted set + live
110110// PeerFronts. serverDid/availableUserDomains answer describeServer (there is no local account).
111export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], favelaWebUpstream = null } = {}) {
111// `extensions`: XRPC GET methods and WebSocket paths another role of the same process serves through this
112// membrane (the gateway's lens: getFeedSkeleton, describeFeedGenerator, queryLabels, subscribeLabels).
113// Mutable Maps, exposed as `server.extensions`, so a role can register after the server is listening.
114// xrpc: nsid -> async ({ query, req }) => ({ status, body, headers? })
115// ws: pathname -> (ws, url, req) => void
116export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], extensions = null } = {}) {
117 const ext = extensions || { xrpc: new Map(), ws: new Map() }
112118 const resolveHandle = (h) => (peers ? peers.resolveHandle(h) : null)
113119 const resolveDid = async (idOrHandle) => {
114120 if (!idOrHandle) return null
……@@ -166,11 +172,10 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
166172 return res.end(body)
167173 }
168174
169 // did:web resolution: GET /.well-known/did.json → the membrane's own did doc. With the lens-export on
170 // (favelaWebUpstream), it advertises #bsky_fg so a feed record whose service = this did:web resolves
171 // here and bsky's getFeedSkeleton lands on proxyToFavela → favela-web.
175 // did:web resolution: GET /.well-known/did.json → the membrane's own did doc, advertising #bsky_fg
176 // exactly when a feed skeleton is served here (a registered extension).
172177 if (req.method === 'GET' && url.pathname === '/.well-known/did.json') {
173 const doc = buildMembraneDidDoc(serverDid, { favelaWebUpstream })
178 const doc = buildMembraneDidDoc(serverDid, { feedGenerator: ext.xrpc.has('app.bsky.feed.getFeedSkeleton') })
174179 if (!doc) return sendErr(res, 404, 'NotFound', 'no did:web served here')
175180 return sendJson(res, 200, doc)
176181 }
……@@ -328,13 +333,21 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
328333 if (!blob || !blob.bytes) return sendErr(res, 404, 'BlobNotFound', 'blob not found')
329334 return sendBytes(res, 200, blob.mimeType, Buffer.from(blob.bytes, 'hex'))
330335 }
331 default:
332 // Lens-export service reads (a community DID's #bsky_fg / #atproto_labeler) → favela-web.
333 // MUST precede isAppViewMethod: getFeedSkeleton/describeFeedGenerator start with app.bsky.
334 // and would otherwise be proxied to bsky's AppView (wrong).
335 if (isFavelaMethod(nsid)) return proxyToFavela(req, res, { nsid, search: url.search, cors: CORS, doFetch, upstream: favelaWebUpstream })
336 default: {
337 // Methods another role serves through this membrane (the gateway's lens). ⛔ MUST precede
338 // isAppViewMethod: getFeedSkeleton/describeFeedGenerator start with app.bsky. and would otherwise
339 // be proxied to bsky's AppView, which would answer for OUR feed. Always no-store: these are
340 // dynamic, and a cached skeleton is the "stuck days behind" feed.
341 const handler = ext.xrpc.get(nsid)
342 if (handler) {
343 const out = await handler({ query: q, req })
344 const b = Buffer.from(JSON.stringify(out.body))
345 res.writeHead(out.status || 200, { 'content-type': 'application/json', 'content-length': b.length, ...CORS, 'cache-control': 'no-store, no-cache, max-age=0, must-revalidate', ...(out.headers || {}) })
346 return res.end(b)
347 }
336348 if (isAppViewMethod(nsid)) return proxyToAppView(req, res, { nsid, method: 'GET', search: url.search, cors: CORS, doFetch, serviceAuth: appViewServiceAuth(req) })
337349 return sendErr(res, 404, 'MethodNotImplemented', nsid)
350 }
338351 }
339352 }
340353
……@@ -428,21 +441,9 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
428441 // bare-ws sockets are streamx streams: .write(Buffer) emits a BINARY frame.
429442 const wss = new WebSocketServer({ server }, (ws, req) => {
430443 const url = new URL(req.url, 'http://localhost')
431 // subscribeLabels — PROXY the labeler firehose to favela-web (a community DID's #atproto_labeler
432 // points here; spec/lens-export.md §4). Open a client WS to favela-web and pipe binary frames through
433 // (both directions closed on either side's close/error). Verified live at deploy (needs favela-web up).
434 if (url.pathname === '/xrpc/com.atproto.label.subscribeLabels') {
435 if (!favelaWebUpstream) { try { ws.destroy() } catch {} ; return }
436 const wsUrl = String(favelaWebUpstream).replace(/^http/, 'ws').replace(/\/$/, '') + url.pathname + url.search
437 let up
438 try { up = new WebSocket(wsUrl) } catch { try { ws.destroy() } catch {} ; return }
439 up.on('data', (buf) => { try { ws.write(Buffer.from(buf)) } catch {} })
440 up.on('error', () => { try { ws.destroy() } catch {} })
441 up.on('close', () => { try { ws.end() } catch {} })
442 ws.on('close', () => { try { up.destroy() } catch {} })
443 ws.on('error', () => { try { up.destroy() } catch {} })
444 return
445 }
444 // A WebSocket path another role serves (the gateway's lens: subscribeLabels).
445 const wsHandler = ext.ws.get(url.pathname)
446 if (wsHandler) { try { wsHandler(ws, url, req) } catch { try { ws.destroy() } catch {} } ; return }
446447 if (url.pathname !== '/xrpc/com.atproto.sync.subscribeRepos') { try { ws.destroy() } catch {} ; return }
447448 const cursor = parseInt(url.searchParams.get('cursor') || '0', 10) || 0
448449 const ac = new AbortController()
……@@ -458,5 +459,6 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
458459 })()
459460 })
460461
462 server.extensions = ext
461463 return server
462464}
pds/lib/server.js +4 -5
……@@ -8,7 +8,7 @@
88import { createServer } from 'node:http'
99import { WebSocketServer } from 'ws'
1010import { proxyToAppView, isAppViewMethod } from './appview.js'
11import { buildMembraneDidDoc } from './favela-proxy.js'
11import { buildMembraneDidDoc } from './did-doc.js'
1212import { withDeadline } from './deadline.js'
1313
1414// Twin of the bare edition's ceiling — kills hangs, not slow responses.
……@@ -74,7 +74,7 @@ const headerSafe = (s) => String(s).replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g
7474
7575// The membrane serves ONLY fronted peers. `peers` (PeerRouter) owns the fronted set + live
7676// PeerFronts. serverDid/availableUserDomains answer describeServer (there is no local account).
77export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], favelaWebUpstream = null } = {}) {
77export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [] } = {}) {
7878 const resolveHandle = (h) => (peers ? peers.resolveHandle(h) : null)
7979 const resolveDid = async (idOrHandle) => {
8080 if (!idOrHandle) return null
……@@ -115,10 +115,9 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
115115 }
116116
117117 // did:web resolution: GET /.well-known/did.json → the membrane's own did doc. With the lens-export on
118 // (favelaWebUpstream), it advertises #bsky_fg so a feed record whose service = this did:web resolves
119 // here and bsky's getFeedSkeleton lands on the favela proxy → favela-web.
118 // The Node edition serves no XRPC extensions, so it never advertises a feed generator.
120119 if (req.method === 'GET' && url.pathname === '/.well-known/did.json') {
121 const doc = buildMembraneDidDoc(serverDid, { favelaWebUpstream })
120 const doc = buildMembraneDidDoc(serverDid, {}) // the Node edition serves no extensions
122121 if (!doc) return sendErr(res, 404, 'NotFound', 'no did:web served here')
123122 return sendJson(res, 200, doc)
124123 }
pds/test/bare/extensions.test.js +79 -0
……@@ -0,0 +1,79 @@
1// XRPC extensions: the membrane serves methods another role registers (the gateway's lens), in place of
2// proxying them to favela-web over HTTP (hyper-social #80 G2).
3//
4// ⛔ ORDER MATTERS. getFeedSkeleton and describeFeedGenerator start with `app.bsky.`, so without the
5// extension check FIRST they fall into the AppView proxy and bsky's AppView answers for our feed (wrong) —
6// the same reason favela-proxy had to precede isAppViewMethod.
7
8import '../../lib/bare-globals.js'
9import test from 'brittle'
10import http from 'bare-http1'
11import { createPdsServer } from '../../lib/server-bare.js'
12import { buildMembraneDidDoc } from '../../lib/did-doc.js'
13
14const SERVER_DID = 'did:web:pds.test'
15
16function get (port, p) {
17 return new Promise((resolve, reject) => {
18 const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET' }, (res) => {
19 let body = ''
20 res.on('data', (c) => { body += c })
21 res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
22 })
23 req.on('error', reject)
24 req.end()
25 })
26}
27
28async function listening (opts) {
29 const server = createPdsServer({ serverDid: SERVER_DID, availableUserDomains: ['.test'], ...opts })
30 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
31 return server
32}
33
34test('★ a registered XRPC extension answers — before the AppView proxy would', async (t) => {
35 const server = await listening({})
36 t.teardown(() => server.close())
37 const seen = []
38 server.extensions.xrpc.set('app.bsky.feed.getFeedSkeleton', async ({ query }) => {
39 seen.push(query.get('feed'))
40 return { status: 200, body: { feed: [{ post: 'at://did:plc:a/app.bsky.feed.post/3mrhxnkbhv622' }] } }
41 })
42 const r = await get(server.address().port, '/xrpc/app.bsky.feed.getFeedSkeleton?feed=at%3A%2F%2Fdid%3Aplc%3Alc%2Fapp.bsky.feed.generator%2Floser-club')
43 t.is(r.status, 200)
44 t.alike(JSON.parse(r.body).feed.length, 1, 'the extension\'s answer')
45 t.alike(seen, ['at://did:plc:lc/app.bsky.feed.generator/loser-club'], 'with the query passed through')
46 t.ok(/no-store/.test(r.headers['cache-control'] || ''), 'dynamic: never cached by bsky or a proxy')
47})
48
49test('an extension can answer an error status, with an atproto error body', async (t) => {
50 const server = await listening({})
51 t.teardown(() => server.close())
52 server.extensions.xrpc.set('app.bsky.feed.getFeedSkeleton', async () => ({ status: 400, body: { error: 'UnsupportedAlgorithm', message: 'no such feed' } }))
53 const r = await get(server.address().port, '/xrpc/app.bsky.feed.getFeedSkeleton?feed=x')
54 t.is(r.status, 400)
55 t.is(JSON.parse(r.body).error, 'UnsupportedAlgorithm')
56})
57
58test('without an extension, a lens method is not proxied anywhere: 404 MethodNotImplemented', async (t) => {
59 const server = await listening({})
60 t.teardown(() => server.close())
61 const r = await get(server.address().port, '/xrpc/com.atproto.label.queryLabels?uriPatterns=*')
62 t.is(r.status, 404, 'no favela-web upstream exists any more')
63})
64
65test('did.json advertises #bsky_fg exactly when a feed-skeleton extension is registered', async (t) => {
66 const server = await listening({})
67 t.teardown(() => server.close())
68 const before = JSON.parse((await get(server.address().port, '/.well-known/did.json')).body)
69 t.alike(before.service, [], 'no feed generator served, none advertised')
70 server.extensions.xrpc.set('app.bsky.feed.getFeedSkeleton', async () => ({ status: 200, body: { feed: [] } }))
71 const after = JSON.parse((await get(server.address().port, '/.well-known/did.json')).body)
72 t.alike(after.service, [{ id: '#bsky_fg', type: 'BskyFeedGenerator', serviceEndpoint: 'https://pds.test' }])
73})
74
75test('buildMembraneDidDoc: did:web only', (t) => {
76 t.is(buildMembraneDidDoc('did:plc:x', {}), null)
77 t.is(buildMembraneDidDoc(null, {}), null)
78 t.alike(buildMembraneDidDoc(SERVER_DID, { feedGenerator: false }).service, [])
79})
pds/test/bare/start-membrane.test.js +0 -1
……@@ -26,7 +26,6 @@ function cfgFor (storage, port = 0) {
2626 userDomain: '.test',
2727 frontedDids: [],
2828 feedRegistries: [],
29 favelaWebUpstream: null,
3029 crawlRelay: null,
3130 crawlHostname: null,
3231 port,
pds/test/favela-proxy.test.js +0 -67
……@@ -1,67 +0,0 @@
1// Favela lens-export proxy — the membrane forwards feed-gen + labeler service reads to favela-web.
2import test from 'brittle'
3import { isFavelaMethod, proxyToFavela, FAVELA_TIMEOUT_MS } from '../lib/favela-proxy.js'
4import { UPSTREAM_TIMEOUT } from '../lib/deadline.js'
5
6function mockRes () {
7 const out = { status: 0, headers: null, body: null }
8 return { out, writeHead (s, h) { out.status = s; out.headers = h }, end (b) { out.body = b } }
9}
10const CORS = { 'access-control-allow-origin': '*' }
11
12test('isFavelaMethod recognises the lens-export service reads', (t) => {
13 t.ok(isFavelaMethod('app.bsky.feed.getFeedSkeleton'), 'getFeedSkeleton')
14 t.ok(isFavelaMethod('app.bsky.feed.describeFeedGenerator'), 'describeFeedGenerator')
15 t.ok(isFavelaMethod('com.atproto.label.queryLabels'), 'queryLabels')
16 t.absent(isFavelaMethod('app.bsky.feed.getTimeline'), 'not a favela method')
17 t.absent(isFavelaMethod('com.atproto.sync.subscribeRepos'), 'not the repo firehose')
18})
19
20test('proxyToFavela forwards a GET to the favela-web upstream', async (t) => {
21 let calledUrl = null
22 const doFetch = async (url) => { calledUrl = url; return { status: 200, contentType: 'application/json', body: Buffer.from('{"feed":[]}') } }
23 const res = mockRes()
24 await proxyToFavela({ headers: {} }, res, {
25 nsid: 'app.bsky.feed.getFeedSkeleton',
26 search: '?feed=at://did:plc:cats/app.bsky.feed.generator/cats&limit=5',
27 cors: CORS, doFetch, upstream: 'http://10.22.0.1:4041/'
28 })
29 t.is(calledUrl, 'http://10.22.0.1:4041/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:cats/app.bsky.feed.generator/cats&limit=5', 'forwarded to the exact upstream xrpc URL (trailing slash trimmed)')
30 t.is(res.out.status, 200, 'passes the upstream status through')
31 t.is(res.out.body.toString(), '{"feed":[]}', 'passes the upstream body through')
32 t.is(res.out.headers['access-control-allow-origin'], '*', 'CORS preserved')
33})
34
35test('proxyToFavela → 501 when no upstream is configured', async (t) => {
36 const res = mockRes()
37 await proxyToFavela({ headers: {} }, res, { nsid: 'com.atproto.label.queryLabels', cors: CORS, doFetch: async () => {}, upstream: null })
38 t.is(res.out.status, 501, '501 NotConfigured')
39 t.is(JSON.parse(res.out.body).error, 'NotConfigured', 'error body')
40})
41
42test('proxyToFavela passes a tight deadline down to doFetch', async (t) => {
43 let opts = null
44 const doFetch = async (url, o) => { opts = o; return { status: 200, contentType: 'application/json', body: Buffer.from('{}') } }
45 await proxyToFavela({ headers: {} }, mockRes(), { nsid: 'app.bsky.feed.getFeedSkeleton', cors: CORS, doFetch, upstream: 'http://10.22.0.1:4041' })
46 t.is(opts.timeoutMs, FAVELA_TIMEOUT_MS, 'lens reads use the lens ceiling, not the general one')
47 t.ok(FAVELA_TIMEOUT_MS > 0 && FAVELA_TIMEOUT_MS <= 5000, 'and it is tight enough to fail INSIDE bsky\'s own patience')
48})
49
50test('proxyToFavela → 502 when the lens host is unreachable (the hang case)', async (t) => {
51 // What a blown deadline looks like coming out of doFetch. Before withDeadline this call never
52 // settled at all, so this 502 is the whole point: an unreachable lens must FAIL, not hang.
53 const timeout = Object.assign(new Error('upstream timed out after 2000ms'), { code: UPSTREAM_TIMEOUT })
54 const res = mockRes()
55 await proxyToFavela({ headers: {} }, res, {
56 nsid: 'app.bsky.feed.getFeedSkeleton', cors: CORS, doFetch: async () => { throw timeout }, upstream: 'http://10.22.0.1:4041'
57 })
58 t.is(res.out.status, 502, 'answers instead of dangling')
59 t.ok(JSON.parse(res.out.body).message.includes('timed out'), 'and says WHY — host unreachable, not a bad request')
60})
61
62test('proxyToFavela → 502 on upstream failure', async (t) => {
63 const res = mockRes()
64 await proxyToFavela({ headers: {} }, res, { nsid: 'app.bsky.feed.getFeedSkeleton', cors: CORS, doFetch: async () => { throw new Error('ECONNREFUSED') }, upstream: 'http://10.22.0.1:4041' })
65 t.is(res.out.status, 502, '502 UpstreamFailure')
66 t.ok(JSON.parse(res.out.body).message.includes('ECONNREFUSED'), 'surfaces the upstream error')
67})
pds/test/membrane-did-web.test.js +13 -16
……@@ -1,19 +1,19 @@
11// The membrane's did:web document — GET /.well-known/did.json makes did:web:<host> (serverDid) resolvable,
2// and advertises #bsky_fg when the favela lens-export is on so a feed record whose service = this did:web
3// resolves here (getFeedSkeleton → favela-web). Run via brittle-node.
2// and advertises #bsky_fg when a feed skeleton is served here (Bare: the gateway's lens extension; see
3// test/bare/extensions.test.js). The Node edition serves no extensions. Run via brittle-node.
44import test from 'brittle'
5import { buildMembraneDidDoc } from '../lib/favela-proxy.js'
5import { buildMembraneDidDoc } from '../lib/did-doc.js'
66import { createPdsServer } from '../lib/server.js'
77
88const SERVER_DID = 'did:web:pds.7rnx.net'
99
10test('buildMembraneDidDoc: #bsky_fg present iff the lens-export is on', (t) => {
10test('buildMembraneDidDoc: #bsky_fg present iff a feed generator is served', (t) => {
1111 const off = buildMembraneDidDoc(SERVER_DID, {})
1212 t.is(off.id, SERVER_DID, 'doc id = serverDid')
13 t.alike(off.service, [], 'no services when favelaWebUpstream is off')
13 t.alike(off.service, [], 'no services when no feed generator is served')
1414
15 const on = buildMembraneDidDoc(SERVER_DID, { favelaWebUpstream: 'http://10.22.0.1:4041' })
16 t.is(on.service.length, 1, 'one service when the lens-export is on')
15 const on = buildMembraneDidDoc(SERVER_DID, { feedGenerator: true })
16 t.is(on.service.length, 1, 'one service when a feed generator is served')
1717 t.is(on.service[0].id, '#bsky_fg', 'service id = #bsky_fg')
1818 t.is(on.service[0].type, 'BskyFeedGenerator', 'service type = BskyFeedGenerator')
1919 t.is(on.service[0].serviceEndpoint, 'https://pds.7rnx.net', 'endpoint = https://<serverDid host>')
……@@ -22,16 +22,13 @@ test('buildMembraneDidDoc: #bsky_fg present iff the lens-export is on', (t) => {
2222 t.is(buildMembraneDidDoc(null, {}), null, 'no serverDid → null')
2323})
2424
25test('GET /.well-known/did.json serves the membrane did doc with #bsky_fg (lens-export on)', async (t) => {
26 const server = createPdsServer({ peers: null, serverDid: SERVER_DID, availableUserDomains: ['.7rnx.net'], favelaWebUpstream: 'http://10.22.0.1:4041' })
25test('GET /.well-known/did.json serves the membrane did doc (Node edition: no feed generator)', async (t) => {
26 const server = createPdsServer({ peers: null, serverDid: SERVER_DID, availableUserDomains: ['.7rnx.net'] })
2727 await new Promise((r) => server.listen(0, '127.0.0.1', r))
2828 t.teardown(() => server.close())
29 const base = `http://127.0.0.1:${server.address().port}`
30
31 const res = await fetch(`${base}/.well-known/did.json`)
32 t.is(res.status, 200, 'did.json 200s (was MethodNotImplemented before)')
29 const res = await fetch(`http://127.0.0.1:${server.address().port}/.well-known/did.json`)
30 t.is(res.status, 200)
3331 const doc = await res.json()
34 t.is(doc.id, SERVER_DID, 'did doc id = serverDid')
35 const fg = (doc.service || []).find((s) => s.id === '#bsky_fg')
36 t.ok(fg && fg.type === 'BskyFeedGenerator' && fg.serviceEndpoint === 'https://pds.7rnx.net', 'advertises #bsky_fg → https://pds.7rnx.net (bsky resolves the feed service here)')
32 t.is(doc.id, SERVER_DID)
33 t.alike(doc.service, [])
3734})