From 21de031cc49fde554dd327412b4e73eb2ffaebaa Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 3 Aug 2026 17:28:11 -0500 Subject: [PATCH] serve: the sync endpoints a mirror expects, and _health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four additions, all constants, none on the resolution path: sync.getRepoStatus, sync.listRepos, sync.listBlobs and _health. Shapes copied from a live reference PDS rather than invented, and verified to match it byte for byte in structure. They exist so a mirror or crawler that already knows the hostname meets a well-formed host instead of a wall of 404s. `active` is always true, the repo list is always one entry, the blob list is always empty — an empty list being the truthful answer for a project where blobs are a non-goal, and better than a 404 a mirror would read as the host being broken. _health is the exception to the caching rule at no-store: a liveness probe answering from cache is not a liveness probe. It reports the Corpora version that built the bundle, since nothing is running that could have a version of its own. What this deliberately does not add: subscribeRepos and requestCrawl, which need a live process and a firehose; describeServer, because claiming to be an account server would mislead; and resolveHandle, because an authority has no handle. All four still answer MethodNotImplemented, and there is a test asserting subscribeRepos continues to. None of this makes the authority relay-indexable — that needs requestCrawl followed by a firehose a relay can follow. The RELAYS.md links added here are pending that research. Co-Authored-By: Claude Opus 5 (1M context) --- docs/RESOLUTION.md | 13 +++++++++ docs/SERVING.md | 15 ++++++++++ src/serve/index.js | 35 ++++++++++++++++++++++ test/serve/handler.test.js | 59 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/docs/RESOLUTION.md b/docs/RESOLUTION.md index d4f15fe..60f6480 100644 --- a/docs/RESOLUTION.md +++ b/docs/RESOLUTION.md @@ -151,12 +151,25 @@ it collides with the response size cap as a namespace grows. | `/xrpc/com.atproto.sync.getRepo` | Optional | Full repo CAR, useful for mirrors | | `/xrpc/com.atproto.repo.describeRepo` | Optional | Collection listing | | `/xrpc/com.atproto.sync.getLatestCommit` | Optional | Commit root and rev | +| `/xrpc/com.atproto.sync.getRepoStatus` | Optional | `{did, active, rev}`, for mirrors | +| `/xrpc/com.atproto.sync.listRepos` | Optional | One entry. An authority hosts one repo. | +| `/xrpc/com.atproto.sync.listBlobs` | Optional | Always empty; lexicons are JSON | +| `/xrpc/_health` | Optional | `{version}`, by convention. Not a lexicon method. | | `/xrpc/com.atproto.sync.subscribeRepos` | Out of scope | Firehose, needs a live process | +| `/xrpc/com.atproto.sync.requestCrawl` | Out of scope | Pointless without a firehose to crawl | +| `/xrpc/com.atproto.server.describeServer` | Out of scope | Not an account server; claiming to be would mislead | +| `/xrpc/com.atproto.identity.resolveHandle` | Out of scope | An authority has no handle: `alsoKnownAs: []` | | Anything that writes | Out of scope | Read-only by design | Every one of these is a GET whose response is fully determined at publish time. The only runtime logic is mapping query parameters onto pre-built objects. +The four optional rows above `subscribeRepos` are there so a mirror or a crawler that already +knows the hostname meets a well-formed host instead of a wall of 404s. Their shapes were taken +from a live reference PDS. **None of them make the authority relay-indexable** — that needs +`requestCrawl` followed by a firehose the relay can follow, and there is no firehose. See +[RELAYS](RELAYS.md). + **XRPC dispatches on query string, and static file hosts route on path.** This is why the serving layer needs a thin function in front of the bytes rather than a bare bucket, and why CDN cache keys must include the full query string. A CDN that strips query strings will collapse every diff --git a/docs/SERVING.md b/docs/SERVING.md index a1dbd8b..81c915f 100644 --- a/docs/SERVING.md +++ b/docs/SERVING.md @@ -17,10 +17,25 @@ source in `node_modules/@atproto`. | `/xrpc/com.atproto.sync.getRepo` | `did` (`since` ignored) | `data.repoCar` | `application/vnd.ipld.car` | | `/xrpc/com.atproto.repo.describeRepo` | `repo` | `data.describeRepo` | `application/json; charset=utf-8` | | `/xrpc/com.atproto.sync.getLatestCommit` | `did` | `data.commit` | `application/json; charset=utf-8` | +| `/xrpc/com.atproto.sync.getRepoStatus` | `did` | `{did, active: true, rev}` | `application/json; charset=utf-8` | +| `/xrpc/com.atproto.sync.listRepos` | — | one repo, no cursor | `application/json; charset=utf-8` | +| `/xrpc/com.atproto.sync.listBlobs` | `did` | `{cids: []}` | `application/json; charset=utf-8` | +| `/xrpc/_health` | — | `{version}` | `application/json; charset=utf-8` | Parameter names come from the lexicons, not from a guess. Note that `sync.getRecord` takes `did` and `repo.getRecord` takes `repo`; getting that backwards is a silent 400. +The last four exist for mirrors and monitoring, not for resolution, and their shapes were copied +from a live reference PDS rather than invented. Three of them are constants: `active` is always +true, the repo list is always one entry, and the blob list is always empty. `_health` reports the +Corpora version that built the bundle, since nothing is running that could have a version of its +own, and it is the one response served `no-store` rather than cached — a liveness probe answering +from cache is not a liveness probe. + +**None of this makes the authority relay-indexable.** A relay needs `requestCrawl` and then a +firehose to follow; there is no firehose, and `subscribeRepos` correctly answers +`MethodNotImplemented`. See [RELAYS](RELAYS.md) for why that is architectural rather than lazy. + `GET` and `HEAD` on all of them. `OPTIONS` is a 204 preflight. Anything else is 405 with an `allow: GET, HEAD, OPTIONS` header. `access-control-allow-origin: *` is on every response including errors. Nothing ever redirects, on any path — `@atproto/identity`'s diff --git a/src/serve/index.js b/src/serve/index.js index 8de000a..ba97185 100644 --- a/src/serve/index.js +++ b/src/serve/index.js @@ -146,6 +146,41 @@ export function createHandler(data, opts = {}) { if (did !== data.did) return repoNotFound(did, head) return json(data.commit, { head, cacheControl: staticCache }) } + + // `active` is always true and `rev` never moves between deploys. A relay reading this + // learns the repo is healthy and static, which is exactly what it is. Corpora serves no + // firehose, so nothing here implies it can be tracked — see docs/RELAYS.md. + case '/xrpc/com.atproto.sync.getRepoStatus': { + const missing = requireParams('com.atproto.sync.getRepoStatus', q, ['did'], head) + if (missing) return missing + const did = str(q, 'did') + if (did !== data.did) return repoNotFound(did, head) + return json({ did: data.did, active: true, rev: data.rev }, { head, cacheControl: staticCache }) + } + + // One authority, one repo. No cursor: a single page is the whole answer, and a reference + // PDS omits the cursor once there is nothing further to read. + case '/xrpc/com.atproto.sync.listRepos': + return json( + { repos: [{ did: data.did, head: data.commitCid, rev: data.rev, active: true }] }, + { head, cacheControl: staticCache }, + ) + + // Not a lexicon method, a convention: every reference PDS answers `{version}` here and + // monitoring expects it. Ours reports the Corpora that built the bundle, which is the + // only version this deployment has — nothing is running that could have its own. + case '/xrpc/_health': + return json({ version: data.manifest.corporaVersion }, { head, cacheControl: 'no-store' }) + + // Lexicons are JSON; blobs are a non-goal. An empty list is the truthful answer and + // stops a mirror treating a 404 here as the host being broken. + case '/xrpc/com.atproto.sync.listBlobs': { + const missing = requireParams('com.atproto.sync.listBlobs', q, ['did'], head) + if (missing) return missing + const did = str(q, 'did') + if (did !== data.did) return repoNotFound(did, head) + return json({ cids: [] }, { head, cacheControl: staticCache }) + } } if (url.pathname.startsWith('/xrpc/')) { diff --git a/test/serve/handler.test.js b/test/serve/handler.test.js index 9697833..52a824a 100644 --- a/test/serve/handler.test.js +++ b/test/serve/handler.test.js @@ -289,6 +289,65 @@ describe('sync.getRepo, describeRepo, sync.getLatestCommit', () => { }) }) +// Shapes taken from a live reference PDS (jarrett.app, 2026-08-03). These exist so a mirror or +// a crawler that already knows the hostname sees a well-formed host rather than a wall of 404s. +// None of them are on the resolution path, and none of them imply a firehose — see docs/RELAYS.md. +describe('the sync endpoints a mirror expects', () => { + it('reports the repo as active at the published rev', async () => { + const res = handle(get(xrpc('com.atproto.sync.getRepoStatus', { did: DID }))) + assert.equal(res.status, 200) + assert.deepEqual(await body(res), { did: DID, active: true, rev: data.rev }) + }) + + it('lists exactly one repo, this one', async () => { + const res = handle(get(xrpc('com.atproto.sync.listRepos', {}))) + assert.equal(res.status, 200) + assert.deepEqual(await body(res), { + repos: [{ did: DID, head: data.commitCid, rev: data.rev, active: true }], + }) + }) + + it('omits the cursor, because one page is the whole answer', async () => { + const res = handle(get(xrpc('com.atproto.sync.listRepos', {}))) + assert.ok(!('cursor' in /** @type {object} */ (await body(res)))) + }) + + it('reports no blobs rather than 404, since blobs are a non-goal not a fault', async () => { + const res = handle(get(xrpc('com.atproto.sync.listBlobs', { did: DID }))) + assert.equal(res.status, 200) + assert.deepEqual(await body(res), { cids: [] }) + }) + + it('answers _health with the Corpora that built the bundle', async () => { + const res = handle(get('/xrpc/_health')) + assert.equal(res.status, 200) + assert.deepEqual(await body(res), { version: data.manifest.corporaVersion }) + }) + + it('does not let _health be cached; it is a liveness probe', () => { + const res = handle(get('/xrpc/_health')) + assert.equal(res.headers.get('cache-control'), 'no-store') + }) + + it('reports a wrong DID as RepoNotFound on the ones that take one', async () => { + const other = 'did:web:other.example.com' + for (const path of [ + xrpc('com.atproto.sync.getRepoStatus', { did: other }), + xrpc('com.atproto.sync.listBlobs', { did: other }), + ]) { + const res = handle(get(path)) + assert.equal(res.status, 400, path) + assert.equal(/** @type {{error: string}} */ (await body(res)).error, 'RepoNotFound') + } + }) + + it('still refuses subscribeRepos, which needs a running server', async () => { + const res = handle(get(xrpc('com.atproto.sync.subscribeRepos', {}))) + assert.equal(res.status, 404) + assert.equal(/** @type {{error: string}} */ (await body(res)).error, 'MethodNotImplemented') + }) +}) + describe('method and path handling', () => { it('answers HEAD with the same headers and no body', async () => { const path = xrpc('com.atproto.sync.getRecord', { -- knotmirror 0.0.1