From bf98887246d8d042e6ee23d53e1a7e3f86b35be2 Mon Sep 17 00:00:00 2001 From: simnaut Date: Mon, 14 Sep 2026 02:40:39 +0000 Subject: [PATCH] fix(pds): resolve owned feed records locally --- .changeset/local-feed-resolution.md | 5 ++ packages/pds/src/index.ts | 5 +- packages/pds/src/xrpc-proxy.ts | 24 +++++- packages/pds/test/proxy.test.ts | 90 ++++++++++++++++++++++ plans/in-progress/local-feed-resolution.md | 31 ++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 .changeset/local-feed-resolution.md create mode 100644 plans/in-progress/local-feed-resolution.md diff --git a/.changeset/local-feed-resolution.md b/.changeset/local-feed-resolution.md new file mode 100644 index 0000000..e4c7e25 --- /dev/null +++ b/.changeset/local-feed-resolution.md @@ -0,0 +1,5 @@ +--- +"@getcirrus/pds": patch +--- + +Resolve feed-generator records published by the PDS account directly from its local repository. This avoids public self-fetches that can time out on same-zone Cloudflare Worker routes, while preserving remote-feed resolution and existing fallback behavior. diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index e949842..f4bf079 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -581,7 +581,10 @@ if (env.SPACES && env.SPACES_INDEX) { // getFeed is proxied to the AppView but the service-auth JWT must be addressed // to the feed generator, so it needs special handling ahead of the catch-all. app.get("/xrpc/app.bsky.feed.getFeed", (c) => - handleGetFeedProxy(c, didResolver, getKeypair), + handleGetFeedProxy(c, didResolver, getKeypair, async (collection, rkey) => { + const result = await getAccountDO(c.env).repo().getRecord(collection, rkey); + return result?.record; + }), ); // createReport routes to a moderation labeler, not the AppView. Clients can diff --git a/packages/pds/src/xrpc-proxy.ts b/packages/pds/src/xrpc-proxy.ts index 2978931..356a572 100644 --- a/packages/pds/src/xrpc-proxy.ts +++ b/packages/pds/src/xrpc-proxy.ts @@ -316,15 +316,22 @@ export async function handleXrpcProxy( return fetch(targetUrl.toString(), reqInit); } +interface LocalFeedRepository { + did: string; + getRecord: (collection: string, rkey: string) => Promise; +} + /** * Resolve the service DID a feed generator runs on, given a feed AT-URI. * The feed record lives in the creator's repo and carries a `did` field * pointing at the feedgen service (e.g. did:web:foryou.club). Returns null if * the feed cannot be resolved, so callers can fall back to default proxying. */ + async function resolveFeedGenDid( feed: string, didResolver: DidResolver, + localRepo?: LocalFeedRepository, ): Promise { const parsed = parseResourceUri(feed); if (!parsed.ok) return null; @@ -333,6 +340,14 @@ async function resolveFeedGenDid( if (collection !== "app.bsky.feed.generator" || !rkey) return null; if (!isDid(repo)) return null; + // The local repo is authoritative for its records. A same-zone Worker route + // cannot be reached via fetch, even when its public DID endpoint works. + if (localRepo && repo === localRepo.did) { + const record = await localRepo.getRecord(collection, rkey); + const feedDid = (record as { did?: unknown } | null)?.did; + return typeof feedDid === "string" && isDid(feedDid) ? feedDid : null; + } + const didDoc = await didResolver.resolve(repo); if (!didDoc) return null; @@ -379,13 +394,20 @@ export async function handleGetFeedProxy( c: Context<{ Bindings: PDSEnv }>, didResolver: DidResolver, getKeypair: () => Promise, + getLocalRecord?: LocalFeedRepository["getRecord"], ): Promise { const feed = c.req.query("feed"); let override: ServiceAuthOverride | undefined; if (feed) { try { - const feedDid = await resolveFeedGenDid(feed, didResolver); + const feedDid = await resolveFeedGenDid( + feed, + didResolver, + getLocalRecord + ? { did: c.env.DID, getRecord: getLocalRecord } + : undefined, + ); if (feedDid) { override = { aud: feedDid, lxm: "app.bsky.feed.getFeedSkeleton" }; } diff --git a/packages/pds/test/proxy.test.ts b/packages/pds/test/proxy.test.ts index 1463e2f..cde56d8 100644 --- a/packages/pds/test/proxy.test.ts +++ b/packages/pds/test/proxy.test.ts @@ -294,6 +294,96 @@ describe("XRPC Service Proxying", () => { return JSON.parse(Buffer.from(payloadB64, "base64url").toString()); } + it.each([false, true])( + "reads an owned feed locally without DID resolution (cursor=%s)", + async (cursorPage) => { + const rkey = `local-feed-${cursorPage}`; + const create = await worker.fetch( + new Request("http://pds.test/xrpc/com.atproto.repo.createRecord", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + repo: env.DID, + collection: "app.bsky.feed.generator", + rkey, + record: { + $type: "app.bsky.feed.generator", + did: "did:web:local-feed.example.com", + displayName: "Local feed", + createdAt: new Date().toISOString(), + }, + }), + }), + env, + ); + expect(create.status).toBe(200); + let capturedAuth: string | null = null; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + // Record creation can schedule a background relay poke. + if (url.hostname === "relay.invalid") return Response.json({}); + if ( + url.origin !== "https://api.bsky.app" || + url.pathname !== "/xrpc/app.bsky.feed.getFeed" + ) { + throw new Error("Unexpected network lookup for owned feed"); + } + capturedAuth = new Headers(init?.headers).get("Authorization"); + expect(url.searchParams.get("cursor")).toBe( + cursorPage ? "test-cursor" : null, + ); + return Response.json({ feed: [] }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const query = new URLSearchParams({ + feed: `at://${env.DID}/app.bsky.feed.generator/${rkey}`, + ...(cursorPage ? { cursor: "test-cursor" } : {}), + }); + const response = await worker.fetch( + new Request(`http://pds.test/xrpc/app.bsky.feed.getFeed?${query}`, { + headers: { Authorization: `Bearer ${authToken}` }, + }), + env, + ); + expect(response.status).toBe(200); + expect( + fetchMock.mock.calls.filter( + ([input]) => new URL(String(input)).hostname !== "relay.invalid", + ), + ).toHaveLength(1); + expect(decodeJwtPayload(capturedAuth)).toMatchObject({ + aud: "did:web:local-feed.example.com", + lxm: "app.bsky.feed.getFeedSkeleton", + }); + }, + ); + + it("falls back for a missing owned feed without a network lookup", async () => { + let capturedAuth: string | null = null; + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedAuth = new Headers(init?.headers).get("Authorization"); + return Response.json({ feed: [] }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const feed = `at://${env.DID}/app.bsky.feed.generator/missing-owned-feed`; + await worker.fetch( + new Request( + `http://pds.test/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(feed)}`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ), + env, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(decodeJwtPayload(capturedAuth).aud).toBe("did:web:api.bsky.app"); + }); + it("mints the service JWT with aud of the feed generator, not the appview", async () => { let capturedAuth: string | null = null; diff --git a/plans/in-progress/local-feed-resolution.md b/plans/in-progress/local-feed-resolution.md new file mode 100644 index 0000000..7ddd0a4 --- /dev/null +++ b/plans/in-progress/local-feed-resolution.md @@ -0,0 +1,31 @@ +# Resolve locally owned feeds without public self-fetches + +The getFeed proxy resolves the feed creator's DID and fetches its feed record over HTTP, +even when the creator is the local account. On Cloudflare, a same-zone fetch cannot target +an ordinary Worker route. A did:web document served through such a route can therefore +work for external clients but time out when fetched from its own PDS. + +Observed on Cirrus 0.19.0: two authenticated requests spent 3015 ms and 3010 ms in DID +resolution, returning no document. Feed-record lookup never ran. Both fell back to AppView +service-auth claims. Current upstream ce1c36e retains this resolution path. + +Fix: when the parsed feed repository equals the configured account DID, read the generator +record from the existing account DO. Validate its service DID identically to a remote +record. Keep remote resolution and missing-record fallback unchanged. Do not synthesize +DID documents (especially for did:plc identities) or introduce a stale feed-record cache. + +Validation: initial and cursor requests must use the generator service-auth audience/method +with no public DID/getRecord fetch. Missing local records must retain the existing fallback; +remote feeds remain covered by existing proxy tests. Final test results are recorded in the +accompanying PR draft after execution. + +References: + +- https://developers.cloudflare.com/workers/configuration/routing/routes/ +- https://developers.cloudflare.com/workers/configuration/routing/custom-domains/ + +Results: PDS and dependency builds passed; all 23 proxy tests passed with the patch. +All three new local-feed regressions failed against unchanged upstream. A backport to Cirrus 0.19.0 was deployed for a live trial: local lookup took +156 ms and 75 ms, with successful generator resolution. Full client initial and cursor +requests took 2542 ms and 2776 ms, returned five posts each, and had no cross-page duplicates. +These are separate live samples rather than a controlled benchmark.