OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / lib / appview.js

59 lines · javascript ref:
// AppView proxy (S0 of the proxy→P2P-AppView roadmap; see docs/ARCHITECTURE-AUDIT.md).
//
// A PDS hosts repos; it does NOT compute the social-graph read views (getProfile, feeds,
// timeline, notifications, thread hydration) — those are an AppView's job. The bsky app,
// pointed at a custom PDS, sends its app.bsky.*/chat.bsky.* reads TO the PDS with an
// `atproto-proxy` header, expecting the PDS to forward them to the AppView it names. This
// module is that forwarder.
//
// On the KEYLESS membrane it forwards PUBLIC views only (no inter-service auth minting — the
// membrane holds no key). Per-user authenticated proxying (minting an ES256K service JWT with
// the account key) is the fronted PEER's job and is deferred (write/session routing).

// Resolve the AppView from the client's `atproto-proxy` header (`<did>#<service>`), e.g.
// `did:web:api.bsky.app#bsky_appview` → { did: 'did:web:api.bsky.app', base: 'https://api.bsky.app' }.
export function resolveAppView (proxyHeader) {
  const ref = String(proxyHeader || 'did:web:api.bsky.app#bsky_appview').split('#')[0]
  const host = ref.startsWith('did:web:') ? decodeURIComponent(ref.slice('did:web:'.length)) : 'api.bsky.app'
  return { did: ref, base: `https://${host}` }
}

// Is this a read/write we should proxy to the AppView (vs. serve locally / 404)?
export function isAppViewMethod (nsid) {
  return nsid.startsWith('app.bsky.') || nsid.startsWith('chat.bsky.')
}

// Proxy one request to the AppView. `doFetch(url,{method,headers,body})` is runtime-injected
// (node global fetch / bare-https) and resolves to { status, contentType, body:Buffer }.
// `serviceAuth(aud, lxm)` (optional) mints an ES256K service-auth token via the fronted peer so
// USER-SCOPED reads (timeline/notifications/follows) authenticate AS the account. When absent (or
// it returns null — no bearer / unroutable) we fall back to the public, unauthenticated proxy.
export async function proxyToAppView (req, res, { nsid, method, search, body, cors, doFetch, serviceAuth }) {
  const { base, did: appviewRef } = resolveAppView(req.headers['atproto-proxy'])
  const headers = {}
  if (serviceAuth) {
    try {
      const aud = String(appviewRef).split('#')[0] // the AppView's DID (drop the #service fragment)
      const token = await serviceAuth(aud, nsid)
      if (token) headers.authorization = 'Bearer ' + token
    } catch { /* fall through to public proxy */ }
  }
  let bodyBuf
  if (method === 'POST') { headers['content-type'] = 'application/json'; bodyBuf = JSON.stringify(body || {}) }

  let upstream
  try {
    upstream = await doFetch(`${base}/xrpc/${nsid}${search || ''}`, { method, headers, body: bodyBuf })
  } catch (e) {
    const errBody = Buffer.from(JSON.stringify({ error: 'UpstreamFailure', message: `appview proxy failed: ${e.message}` }))
    res.writeHead(502, { 'content-type': 'application/json', 'content-length': errBody.length, ...cors })
    return res.end(errBody)
  }
  res.writeHead(upstream.status, {
    'content-type': upstream.contentType || 'application/json',
    'content-length': upstream.body.length,
    ...cors
  })
  res.end(upstream.body)
}