// XRPC extensions: the membrane serves methods another role registers (the gateway's lens), in place of
// proxying them to favela-web over HTTP (hyper-social #80 G2).
//
// ⛔ ORDER MATTERS. getFeedSkeleton and describeFeedGenerator start with `app.bsky.`, so without the
// extension check FIRST they fall into the AppView proxy and bsky's AppView answers for our feed (wrong) —
// the same reason favela-proxy had to precede isAppViewMethod.
import '../../lib/bare-globals.js'
import test from 'brittle'
import http from 'bare-http1'
import { createPdsServer } from '../../lib/server-bare.js'
import { buildMembraneDidDoc } from '../../lib/did-doc.js'
const SERVER_DID = 'did:web:pds.test'
function get (port, p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET' }, (res) => {
let body = ''
res.on('data', (c) => { body += c })
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
})
req.on('error', reject)
req.end()
})
}
async function listening (opts) {
const server = createPdsServer({ serverDid: SERVER_DID, availableUserDomains: ['.test'], ...opts })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return server
}
test('★ a registered XRPC extension answers — before the AppView proxy would', async (t) => {
const server = await listening({})
t.teardown(() => server.close())
const seen = []
server.extensions.xrpc.set('app.bsky.feed.getFeedSkeleton', async ({ query }) => {
seen.push(query.get('feed'))
return { status: 200, body: { feed: [{ post: 'at://did:plc:a/app.bsky.feed.post/3mrhxnkbhv622' }] } }
})
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')
t.is(r.status, 200)
t.alike(JSON.parse(r.body).feed.length, 1, 'the extension\'s answer')
t.alike(seen, ['at://did:plc:lc/app.bsky.feed.generator/loser-club'], 'with the query passed through')
t.ok(/no-store/.test(r.headers['cache-control'] || ''), 'dynamic: never cached by bsky or a proxy')
})
test('an extension can answer an error status, with an atproto error body', async (t) => {
const server = await listening({})
t.teardown(() => server.close())
server.extensions.xrpc.set('app.bsky.feed.getFeedSkeleton', async () => ({ status: 400, body: { error: 'UnsupportedAlgorithm', message: 'no such feed' } }))
const r = await get(server.address().port, '/xrpc/app.bsky.feed.getFeedSkeleton?feed=x')
t.is(r.status, 400)
t.is(JSON.parse(r.body).error, 'UnsupportedAlgorithm')
})
test('without an extension, a lens method is not proxied anywhere: 404 MethodNotImplemented', async (t) => {
const server = await listening({})
t.teardown(() => server.close())
const r = await get(server.address().port, '/xrpc/com.atproto.label.queryLabels?uriPatterns=*')
t.is(r.status, 404, 'no favela-web upstream exists any more')
})
test('did.json advertises #bsky_fg exactly when a feed-skeleton extension is registered', async (t) => {
const server = await listening({})
t.teardown(() => server.close())
const before = JSON.parse((await get(server.address().port, '/.well-known/did.json')).body)
t.alike(before.service, [], 'no feed generator served, none advertised')
server.extensions.xrpc.set('app.bsky.feed.getFeedSkeleton', async () => ({ status: 200, body: { feed: [] } }))
const after = JSON.parse((await get(server.address().port, '/.well-known/did.json')).body)
t.alike(after.service, [{ id: '#bsky_fg', type: 'BskyFeedGenerator', serviceEndpoint: 'https://pds.test' }])
})
test('buildMembraneDidDoc: did:web only', (t) => {
t.is(buildMembraneDidDoc('did:plc:x', {}), null)
t.is(buildMembraneDidDoc(null, {}), null)
t.alike(buildMembraneDidDoc(SERVER_DID, { feedGenerator: false }).service, [])
})
test('★ an HTTP path extension serves a plain page, and is told the request host', async (t) => {
const server = await listening({})
t.teardown(() => server.close())
const hosts = []
server.extensions.http.set('/status', async ({ host }) => {
hosts.push(host)
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: '<h1>ok</h1>' }
})
const r = await get(server.address().port, '/status')
t.is(r.status, 200)
t.is(r.body, '<h1>ok</h1>')
t.ok(/text\/html/.test(r.headers['content-type']))
t.is(hosts[0], '127.0.0.1', 'the Host header, without the port')
})
test('an HTTP extension may decline (null) and the request falls through as before', async (t) => {
const server = await listening({})
t.teardown(() => server.close())
server.extensions.http.set('/', async () => null)
const r = await get(server.address().port, '/')
t.is(r.status, 404, 'unchanged behaviour for a declined path')
})