Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-feed-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion packages/pds/src/xrpc-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,15 +316,22 @@ export async function handleXrpcProxy(
return fetch(targetUrl.toString(), reqInit);
}

interface LocalFeedRepository {
did: string;
getRecord: (collection: string, rkey: string) => Promise<unknown>;
}

/**
* 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<string | null> {
const parsed = parseResourceUri(feed);
if (!parsed.ok) return null;
Expand All @@ -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;

Expand Down Expand Up @@ -379,13 +394,20 @@ export async function handleGetFeedProxy(
c: Context<{ Bindings: PDSEnv }>,
didResolver: DidResolver,
getKeypair: () => Promise<Secp256k1Keypair>,
getLocalRecord?: LocalFeedRepository["getRecord"],
): Promise<Response> {
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" };
}
Expand Down
90 changes: 90 additions & 0 deletions packages/pds/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
31 changes: 31 additions & 0 deletions plans/in-progress/local-feed-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
Loading