| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-bridge", |
| 3 | "version": "0.9.55", | |
| 3 | "version": "0.9.56", | |
| 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", |
| … | … | @@ -149,7 +149,7 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null } |
| 149 | 149 | // 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers. |
| 150 | 150 | // XRPC methods and WebSocket paths another role of this process serves through the membrane (the |
| 151 | 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() } | |
| 152 | const extensions = { xrpc: new Map(), ws: new Map(), http: new Map() } | |
| 153 | 153 | let rooms = null |
| 154 | 154 | const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], extensions }) |
| 155 | 155 | await new Promise((resolve, reject) => { |
| … | … | @@ -113,8 +113,11 @@ const headerSafe = (s) => String(s).replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g |
| 113 | 113 | // Mutable Maps, exposed as `server.extensions`, so a role can register after the server is listening. |
| 114 | 114 | // xrpc: nsid -> async ({ query, req }) => ({ status, body, headers? }) |
| 115 | 115 | // ws: pathname -> (ws, url, req) => void |
| 116 | // http: pathname -> async ({ req, url, host }) => ({ status, headers, body }) | null (plain GET pages; | |
| 117 | // null declines and the request falls through unchanged — e.g. a page served on one host only) | |
| 116 | 118 | export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], extensions = null } = {}) { |
| 117 | const ext = extensions || { xrpc: new Map(), ws: new Map() } | |
| 119 | const ext = extensions || { xrpc: new Map(), ws: new Map(), http: new Map() } | |
| 120 | if (!ext.http) ext.http = new Map() | |
| 118 | 121 | const resolveHandle = (h) => (peers ? peers.resolveHandle(h) : null) |
| 119 | 122 | const resolveDid = async (idOrHandle) => { |
| 120 | 123 | if (!idOrHandle) return null |
| … | … | @@ -174,6 +177,18 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 174 | 177 | |
| 175 | 178 | // did:web resolution: GET /.well-known/did.json → the membrane's own did doc, advertising #bsky_fg |
| 176 | 179 | // exactly when a feed skeleton is served here (a registered extension). |
| 180 | // A plain page another role serves (the gateway's public status page). Checked before the XRPC | |
| 181 | // dispatch; a handler that returns null declines, and the request continues exactly as before. | |
| 182 | if (req.method === 'GET' && ext.http.has(url.pathname)) { | |
| 183 | const host = (req.headers.host || '').split(':')[0].toLowerCase() | |
| 184 | const out = await ext.http.get(url.pathname)({ req, url, host }) | |
| 185 | if (out) { | |
| 186 | const b = Buffer.from(String(out.body == null ? '' : out.body)) | |
| 187 | res.writeHead(out.status || 200, { 'content-type': 'text/html; charset=utf-8', ...(out.headers || {}), 'content-length': b.length }) | |
| 188 | return res.end(b) | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 177 | 192 | if (req.method === 'GET' && url.pathname === '/.well-known/did.json') { |
| 178 | 193 | const doc = buildMembraneDidDoc(serverDid, { feedGenerator: ext.xrpc.has('app.bsky.feed.getFeedSkeleton') }) |
| 179 | 194 | if (!doc) return sendErr(res, 404, 'NotFound', 'no did:web served here') |
| … | … | @@ -77,3 +77,26 @@ test('buildMembraneDidDoc: did:web only', (t) => { |
| 77 | 77 | t.is(buildMembraneDidDoc(null, {}), null) |
| 78 | 78 | t.alike(buildMembraneDidDoc(SERVER_DID, { feedGenerator: false }).service, []) |
| 79 | 79 | }) |
| 80 | ||
| 81 | test('★ an HTTP path extension serves a plain page, and is told the request host', async (t) => { | |
| 82 | const server = await listening({}) | |
| 83 | t.teardown(() => server.close()) | |
| 84 | const hosts = [] | |
| 85 | server.extensions.http.set('/status', async ({ host }) => { | |
| 86 | hosts.push(host) | |
| 87 | return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: '<h1>ok</h1>' } | |
| 88 | }) | |
| 89 | const r = await get(server.address().port, '/status') | |
| 90 | t.is(r.status, 200) | |
| 91 | t.is(r.body, '<h1>ok</h1>') | |
| 92 | t.ok(/text\/html/.test(r.headers['content-type'])) | |
| 93 | t.is(hosts[0], '127.0.0.1', 'the Host header, without the port') | |
| 94 | }) | |
| 95 | ||
| 96 | test('an HTTP extension may decline (null) and the request falls through as before', async (t) => { | |
| 97 | const server = await listening({}) | |
| 98 | t.teardown(() => server.close()) | |
| 99 | server.extensions.http.set('/', async () => null) | |
| 100 | const r = await get(server.address().port, '/') | |
| 101 | t.is(r.status, 404, 'unchanged behaviour for a declined path') | |
| 102 | }) |