| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-bridge", |
| 3 | "version": "0.9.54", | |
| 3 | "version": "0.9.55", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -92,7 +92,6 @@ |
| 92 | 92 | "PDS_PORT": "3399", |
| 93 | 93 | "PDS_STORAGE": "${HOME}/.config/atproto-bridge/data", |
| 94 | 94 | "PDS_SOCK": "${HOME}/.config/atproto-bridge/svc.sock", |
| 95 | "FAVELA_WEB_UPSTREAM": "http://10.22.0.1:4041", | |
| 96 | 95 | "PDS_CRAWL_RELAY": "https://bsky.network" |
| 97 | 96 | } |
| 98 | 97 | }, |
| … | … | @@ -38,9 +38,6 @@ export function loadConfig () { |
| 38 | 38 | return { |
| 39 | 39 | serverDid, |
| 40 | 40 | 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), | |
| 44 | 41 | port: parseInt(pick('port', 'PDS_PORT', '3399'), 10), |
| 45 | 42 | bind: pick('bind', 'PDS_BIND', '127.0.0.1'), |
| 46 | 43 | storage, // holds the front-store (aggregate firehose + replicated peer cores) — NOT accounts |
| … | … | @@ -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".) | |
| 9 | export 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 | } |
| … | … | @@ -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 | ||
| 9 | const 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? | |
| 16 | export 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. | |
| 23 | export 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. | |
| 38 | export const FAVELA_TIMEOUT_MS = 2000 | |
| 39 | ||
| 40 | export 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 | } |
| … | … | @@ -42,7 +42,7 @@ const defaultLog = (o) => console.log(JSON.stringify(o)) |
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | 44 | * @param {object} cfg loadConfig()'s shape: serverDid, userDomain, frontedDids, feedRegistries, |
| 45 | * favelaWebUpstream, crawlRelay, crawlHostname, port, bind, storage | |
| 45 | * crawlRelay, crawlHostname, port, bind, storage | |
| 46 | 46 | * @param {object} [opts] |
| 47 | 47 | * @param {Function} [opts.log] one object per line |
| 48 | 48 | * @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 } |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | 149 | // 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 }) | |
| 151 | 155 | await new Promise((resolve, reject) => { |
| 152 | 156 | server.once('error', reject) |
| 153 | 157 | server.listen(cfg.port, cfg.bind, resolve) |
| … | … | @@ -271,6 +275,17 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null } |
| 271 | 275 | relay, |
| 272 | 276 | handlers: membraneHandlers({ peers }), |
| 273 | 277 | 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 | }, | |
| 274 | 289 | // The relay guard's last pass and heals, for `metrics`. Read-only. |
| 275 | 290 | guard: () => ({ ...guard }), |
| 276 | 291 | // 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 } |
| 298 | 313 | // here), plus the seq-guard timer. |
| 299 | 314 | steps: [ |
| 300 | 315 | ['seq-guard', () => { if (seqGuardTimer) clearInterval(seqGuardTimer); if (seqGuardSoon) clearTimeout(seqGuardSoon) }], |
| 316 | ['community-rooms', async () => { if (rooms && typeof rooms.close === 'function') await rooms.close() }], | |
| 301 | 317 | ['peers', () => peers.close()], |
| 302 | 318 | ['relay', () => relay.close()], |
| 303 | 319 | ['swarm', () => swarm.destroy()], |
| … | … | @@ -12,7 +12,7 @@ import bareWs from 'bare-ws' |
| 12 | 12 | import { tidNow } from 'atproto-store/lib/tid.js' |
| 13 | 13 | import { buildRecordProofCar } from './sync-get-record.js' |
| 14 | 14 | import { proxyToAppView, isAppViewMethod } from './appview.js' |
| 15 | import { proxyToFavela, isFavelaMethod, buildMembraneDidDoc } from './favela-proxy.js' | |
| 15 | import { buildMembraneDidDoc } from './did-doc.js' | |
| 16 | 16 | import { withDeadline } from './deadline.js' |
| 17 | 17 | |
| 18 | 18 | // 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, |
| 25 | 25 | const 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 } } |
| 26 | 26 | |
| 27 | 27 | // 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). | |
| 30 | 30 | // ⛔ The deadline is NOT optional. Without it this rejects only on the request's 'error' event, which |
| 31 | 31 | // an unreachable HOST never produces: the agent times the socket out and destroys it, and |
| 32 | 32 | // 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 |
| 108 | 108 | |
| 109 | 109 | // The membrane serves ONLY fronted peers. `peers` (PeerRouter) owns the fronted set + live |
| 110 | 110 | // PeerFronts. serverDid/availableUserDomains answer describeServer (there is no local account). |
| 111 | export 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 | |
| 116 | export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], extensions = null } = {}) { | |
| 117 | const ext = extensions || { xrpc: new Map(), ws: new Map() } | |
| 112 | 118 | const resolveHandle = (h) => (peers ? peers.resolveHandle(h) : null) |
| 113 | 119 | const resolveDid = async (idOrHandle) => { |
| 114 | 120 | if (!idOrHandle) return null |
| … | … | @@ -166,11 +172,10 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 166 | 172 | return res.end(body) |
| 167 | 173 | } |
| 168 | 174 | |
| 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). | |
| 172 | 177 | 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') }) | |
| 174 | 179 | if (!doc) return sendErr(res, 404, 'NotFound', 'no did:web served here') |
| 175 | 180 | return sendJson(res, 200, doc) |
| 176 | 181 | } |
| … | … | @@ -328,13 +333,21 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 328 | 333 | if (!blob || !blob.bytes) return sendErr(res, 404, 'BlobNotFound', 'blob not found') |
| 329 | 334 | return sendBytes(res, 200, blob.mimeType, Buffer.from(blob.bytes, 'hex')) |
| 330 | 335 | } |
| 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 | } | |
| 336 | 348 | if (isAppViewMethod(nsid)) return proxyToAppView(req, res, { nsid, method: 'GET', search: url.search, cors: CORS, doFetch, serviceAuth: appViewServiceAuth(req) }) |
| 337 | 349 | return sendErr(res, 404, 'MethodNotImplemented', nsid) |
| 350 | } | |
| 338 | 351 | } |
| 339 | 352 | } |
| 340 | 353 | |
| … | … | @@ -428,21 +441,9 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 428 | 441 | // bare-ws sockets are streamx streams: .write(Buffer) emits a BINARY frame. |
| 429 | 442 | const wss = new WebSocketServer({ server }, (ws, req) => { |
| 430 | 443 | 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 } | |
| 446 | 447 | if (url.pathname !== '/xrpc/com.atproto.sync.subscribeRepos') { try { ws.destroy() } catch {} ; return } |
| 447 | 448 | const cursor = parseInt(url.searchParams.get('cursor') || '0', 10) || 0 |
| 448 | 449 | const ac = new AbortController() |
| … | … | @@ -458,5 +459,6 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 458 | 459 | })() |
| 459 | 460 | }) |
| 460 | 461 | |
| 462 | server.extensions = ext | |
| 461 | 463 | return server |
| 462 | 464 | } |
| … | … | @@ -8,7 +8,7 @@ |
| 8 | 8 | import { createServer } from 'node:http' |
| 9 | 9 | import { WebSocketServer } from 'ws' |
| 10 | 10 | import { proxyToAppView, isAppViewMethod } from './appview.js' |
| 11 | import { buildMembraneDidDoc } from './favela-proxy.js' | |
| 11 | import { buildMembraneDidDoc } from './did-doc.js' | |
| 12 | 12 | import { withDeadline } from './deadline.js' |
| 13 | 13 | |
| 14 | 14 | // 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 |
| 74 | 74 | |
| 75 | 75 | // The membrane serves ONLY fronted peers. `peers` (PeerRouter) owns the fronted set + live |
| 76 | 76 | // PeerFronts. serverDid/availableUserDomains answer describeServer (there is no local account). |
| 77 | export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], favelaWebUpstream = null } = {}) { | |
| 77 | export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [] } = {}) { | |
| 78 | 78 | const resolveHandle = (h) => (peers ? peers.resolveHandle(h) : null) |
| 79 | 79 | const resolveDid = async (idOrHandle) => { |
| 80 | 80 | if (!idOrHandle) return null |
| … | … | @@ -115,10 +115,9 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 115 | 115 | } |
| 116 | 116 | |
| 117 | 117 | // 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. | |
| 120 | 119 | 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 | |
| 122 | 121 | if (!doc) return sendErr(res, 404, 'NotFound', 'no did:web served here') |
| 123 | 122 | return sendJson(res, 200, doc) |
| 124 | 123 | } |
| … | … | @@ -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 | ||
| 8 | import '../../lib/bare-globals.js' | |
| 9 | import test from 'brittle' | |
| 10 | import http from 'bare-http1' | |
| 11 | import { createPdsServer } from '../../lib/server-bare.js' | |
| 12 | import { buildMembraneDidDoc } from '../../lib/did-doc.js' | |
| 13 | ||
| 14 | const SERVER_DID = 'did:web:pds.test' | |
| 15 | ||
| 16 | function 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 | ||
| 28 | async 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 | ||
| 34 | test('★ 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 | ||
| 49 | test('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 | ||
| 58 | test('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 | ||
| 65 | test('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 | ||
| 75 | test('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 | }) |
| … | … | @@ -26,7 +26,6 @@ function cfgFor (storage, port = 0) { |
| 26 | 26 | userDomain: '.test', |
| 27 | 27 | frontedDids: [], |
| 28 | 28 | feedRegistries: [], |
| 29 | favelaWebUpstream: null, | |
| 30 | 29 | crawlRelay: null, |
| 31 | 30 | crawlHostname: null, |
| 32 | 31 | port, |
| … | … | @@ -1,67 +0,0 @@ |
| 1 | // Favela lens-export proxy — the membrane forwards feed-gen + labeler service reads to favela-web. | |
| 2 | import test from 'brittle' | |
| 3 | import { isFavelaMethod, proxyToFavela, FAVELA_TIMEOUT_MS } from '../lib/favela-proxy.js' | |
| 4 | import { UPSTREAM_TIMEOUT } from '../lib/deadline.js' | |
| 5 | ||
| 6 | function 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 | } | |
| 10 | const CORS = { 'access-control-allow-origin': '*' } | |
| 11 | ||
| 12 | test('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 | ||
| 20 | test('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 | ||
| 35 | test('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 | ||
| 42 | test('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 | ||
| 50 | test('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 | ||
| 62 | test('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 | }) |
| … | … | @@ -1,19 +1,19 @@ |
| 1 | 1 | // 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. | |
| 4 | 4 | import test from 'brittle' |
| 5 | import { buildMembraneDidDoc } from '../lib/favela-proxy.js' | |
| 5 | import { buildMembraneDidDoc } from '../lib/did-doc.js' | |
| 6 | 6 | import { createPdsServer } from '../lib/server.js' |
| 7 | 7 | |
| 8 | 8 | const SERVER_DID = 'did:web:pds.7rnx.net' |
| 9 | 9 | |
| 10 | test('buildMembraneDidDoc: #bsky_fg present iff the lens-export is on', (t) => { | |
| 10 | test('buildMembraneDidDoc: #bsky_fg present iff a feed generator is served', (t) => { | |
| 11 | 11 | const off = buildMembraneDidDoc(SERVER_DID, {}) |
| 12 | 12 | 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') | |
| 14 | 14 | |
| 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') | |
| 17 | 17 | t.is(on.service[0].id, '#bsky_fg', 'service id = #bsky_fg') |
| 18 | 18 | t.is(on.service[0].type, 'BskyFeedGenerator', 'service type = BskyFeedGenerator') |
| 19 | 19 | 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) => { |
| 22 | 22 | t.is(buildMembraneDidDoc(null, {}), null, 'no serverDid → null') |
| 23 | 23 | }) |
| 24 | 24 | |
| 25 | test('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' }) | |
| 25 | test('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'] }) | |
| 27 | 27 | await new Promise((r) => server.listen(0, '127.0.0.1', r)) |
| 28 | 28 | 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) | |
| 33 | 31 | 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, []) | |
| 37 | 34 | }) |