[READ-ONLY] Mirror of https://github.com/andrioid/n8n-nodes-atproto. atproto node for n8n
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(atproto): route repo reads to the hosting PDS

Repo-hosting reads (getRecord, listRecords, getBlob, listBlobs) were sent
to the authenticated session's PDS, which only serves its own repos and
answers "Could not find repo" for any other DID. Resolve the target DID's
hosting PDS from its DID document (did:plc via plc.directory, did:web via
well-known) and dispatch the read there via an unauthenticated agent. The
session agent is reused for the user's own repo.

+224 -11
+83 -11
nodes/Atproto/operations.ts
··· 9 9 * Get/List accept an optional `repo` to read other users' public records. 10 10 */ 11 11 12 - import type { Agent } from '@atproto/api'; 12 + import { Agent } from '@atproto/api'; 13 13 import type { LexiconSchema } from './lexicon'; 14 14 15 15 // --------------------------------------------------------------------------- ··· 185 185 } 186 186 187 187 // --------------------------------------------------------------------------- 188 + // Read routing — direct repo reads to the PDS that hosts the target repo 189 + // --------------------------------------------------------------------------- 190 + 191 + interface DidDocument { 192 + service?: Array<{ id: string; type: string; serviceEndpoint: unknown }>; 193 + } 194 + 195 + /** 196 + * Map a DID to the URL of its DID document. Supports did:plc (PLC directory) 197 + * and did:web. 198 + */ 199 + function didDocumentUrl(did: string): string { 200 + if (did.startsWith('did:plc:')) { 201 + return `https://plc.directory/${did}`; 202 + } 203 + if (did.startsWith('did:web:')) { 204 + const [host, ...path] = did 205 + .slice('did:web:'.length) 206 + .split(':') 207 + .map(decodeURIComponent); 208 + return path.length === 0 209 + ? `https://${host}/.well-known/did.json` 210 + : `https://${host}/${path.join('/')}/did.json`; 211 + } 212 + throw new Error(`Unsupported DID method: ${did}`); 213 + } 214 + 215 + /** 216 + * Resolve a DID's hosting PDS endpoint from its DID document. 217 + */ 218 + async function resolvePdsEndpoint(did: string): Promise<string> { 219 + const res = await fetch(didDocumentUrl(did)); 220 + if (!res.ok) { 221 + throw new Error(`Failed to resolve DID ${did}: HTTP ${res.status}`); 222 + } 223 + const doc = (await res.json()) as DidDocument; 224 + const endpoint = doc.service?.find((s) => 225 + s.id.endsWith('#atproto_pds'), 226 + )?.serviceEndpoint; 227 + if (typeof endpoint !== 'string' || endpoint.length === 0) { 228 + throw new Error(`No PDS endpoint found in DID document for ${did}`); 229 + } 230 + return endpoint; 231 + } 232 + 233 + /** 234 + * Resolve the repo to read from into a DID plus an Agent pointed at the PDS 235 + * that hosts it. Repo-hosting reads (getRecord, listRecords, getBlob, 236 + * listBlobs) must hit that PDS — the authenticated session's PDS only serves 237 + * its own repos and answers "Could not find repo" for any other. 238 + * 239 + * The session agent is reused for the user's own repo (already on the correct 240 + * PDS); foreign repos get an unauthenticated Agent, since these reads are 241 + * public. 242 + */ 243 + async function resolveReadTarget( 244 + agent: Agent, 245 + actor?: string, 246 + ): Promise<{ did: string; agent: Agent }> { 247 + if (!actor || actor.trim() === '') { 248 + return { did: getOwnDid(agent), agent }; 249 + } 250 + const did = await resolveActorToDid(agent, actor); 251 + if (did === agent.did) { 252 + return { did, agent }; 253 + } 254 + return { did, agent: new Agent(await resolvePdsEndpoint(did)) }; 255 + } 256 + 257 + // --------------------------------------------------------------------------- 188 258 // Const injection 189 259 // --------------------------------------------------------------------------- 190 260 ··· 244 314 agent: Agent, 245 315 params: GetRecordParams, 246 316 ): Promise<GetRecordResult> { 247 - const repo = params.repo ?? getOwnDid(agent); 317 + const { did, agent: reader } = await resolveReadTarget(agent, params.repo); 248 318 249 - const response = await agent.com.atproto.repo.getRecord({ 250 - repo, 319 + const response = await reader.com.atproto.repo.getRecord({ 320 + repo: did, 251 321 collection: params.collection, 252 322 rkey: params.rkey, 253 323 }); ··· 313 383 agent: Agent, 314 384 params: ListRecordsParams, 315 385 ): Promise<ListRecordsResult> { 316 - const repo = params.repo ?? getOwnDid(agent); 386 + const { did, agent: reader } = await resolveReadTarget(agent, params.repo); 317 387 318 - const response = await agent.com.atproto.repo.listRecords({ 319 - repo, 388 + const response = await reader.com.atproto.repo.listRecords({ 389 + repo: did, 320 390 collection: params.collection, 321 391 limit: params.limit, 322 392 cursor: params.cursor, ··· 388 458 agent: Agent, 389 459 params: GetBlobParams, 390 460 ): Promise<GetBlobResult> { 391 - const response = await agent.com.atproto.sync.getBlob({ 392 - did: params.did, 461 + const { did, agent: reader } = await resolveReadTarget(agent, params.did); 462 + 463 + const response = await reader.com.atproto.sync.getBlob({ 464 + did, 393 465 cid: params.cid, 394 466 }); 395 467 ··· 417 489 agent: Agent, 418 490 params: ListBlobsParams = {}, 419 491 ): Promise<ListBlobsResult> { 420 - const did = params.did ?? getOwnDid(agent); 492 + const { did, agent: reader } = await resolveReadTarget(agent, params.did); 421 493 422 - const response = await agent.com.atproto.sync.listBlobs({ 494 + const response = await reader.com.atproto.sync.listBlobs({ 423 495 did, 424 496 limit: params.limit, 425 497 cursor: params.cursor,
+127
tests/readRouting.test.ts
··· 1 + /** 2 + * Behaviour test for the read-routing fix. 3 + * 4 + * Repo-hosting reads (listRecords/getRecord/...) must be sent to the PDS that 5 + * hosts the *target* repo, resolved from its DID document — not the 6 + * authenticated session's PDS, which only serves its own repos and answers 7 + * "Could not find repo" for any other. 8 + */ 9 + 10 + import { 11 + describe, 12 + it, 13 + expect, 14 + beforeAll, 15 + afterAll, 16 + afterEach, 17 + } from 'vitest'; 18 + import { http, HttpResponse } from 'msw'; 19 + import { Agent, CredentialSession } from '@atproto/api'; 20 + 21 + import { listRecords, getBlob } from '../nodes/Atproto/operations'; 22 + import { server, PDS_URL, CID_1 } from './setup'; 23 + 24 + const FOREIGN_PDS = 'https://foreign.host.example'; 25 + const FOREIGN_DID = 'did:plc:foreign-repo'; 26 + 27 + let agent: Agent; 28 + 29 + beforeAll(async () => { 30 + server.listen({ onUnhandledRequest: 'error' }); 31 + 32 + const session = new CredentialSession(new URL(PDS_URL)); 33 + await session.login({ 34 + identifier: 'test.bsky.social', 35 + password: 'xxxx-xxxx-xxxx-xxxx', 36 + }); 37 + agent = new Agent(session); 38 + }); 39 + 40 + afterAll(() => { 41 + server.close(); 42 + }); 43 + 44 + afterEach(() => { 45 + server.resetHandlers(); 46 + }); 47 + 48 + describe('read routing for foreign repos', () => { 49 + it('lists a foreign repo from the PDS named in its DID document', async () => { 50 + server.use( 51 + http.get(/plc\.directory/, () => 52 + HttpResponse.json({ 53 + service: [ 54 + { 55 + id: '#atproto_pds', 56 + type: 'AtprotoPersonalDataServer', 57 + serviceEndpoint: FOREIGN_PDS, 58 + }, 59 + ], 60 + }), 61 + ), 62 + http.get(`${FOREIGN_PDS}/xrpc/com.atproto.repo.listRecords`, () => 63 + HttpResponse.json({ 64 + records: [ 65 + { 66 + uri: `at://${FOREIGN_DID}/site.standard.publication/abc`, 67 + cid: CID_1, 68 + value: { $type: 'site.standard.publication', name: 'Foreign' }, 69 + }, 70 + ], 71 + }), 72 + ), 73 + ); 74 + 75 + const result = await listRecords(agent, { 76 + collection: 'site.standard.publication', 77 + repo: FOREIGN_DID, 78 + }); 79 + 80 + expect(result.records).toHaveLength(1); 81 + expect(result.records[0].uri).toContain(FOREIGN_DID); 82 + expect(result.records[0].value.name).toBe('Foreign'); 83 + }); 84 + 85 + it('downloads a foreign blob from the hosting PDS', async () => { 86 + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); 87 + server.use( 88 + http.get(/plc\.directory/, () => 89 + HttpResponse.json({ 90 + service: [ 91 + { 92 + id: '#atproto_pds', 93 + type: 'AtprotoPersonalDataServer', 94 + serviceEndpoint: FOREIGN_PDS, 95 + }, 96 + ], 97 + }), 98 + ), 99 + http.get( 100 + `${FOREIGN_PDS}/xrpc/com.atproto.sync.getBlob`, 101 + () => 102 + new HttpResponse(bytes, { 103 + status: 200, 104 + headers: { 'content-type': 'image/png' }, 105 + }), 106 + ), 107 + ); 108 + 109 + const result = await getBlob(agent, { did: FOREIGN_DID, cid: CID_1 }); 110 + 111 + expect(result.mimeType).toBe('image/png'); 112 + expect(new Uint8Array(result.data)).toEqual(bytes); 113 + }); 114 + 115 + it('surfaces a clear error when the DID document has no PDS', async () => { 116 + server.use( 117 + http.get(/plc\.directory/, () => HttpResponse.json({ service: [] })), 118 + ); 119 + 120 + await expect( 121 + listRecords(agent, { 122 + collection: 'site.standard.publication', 123 + repo: FOREIGN_DID, 124 + }), 125 + ).rejects.toThrow(/No PDS endpoint/); 126 + }); 127 + });
+14
tests/setup.ts
··· 309 309 } 310 310 }), 311 311 312 + // DID document resolution (PLC directory) — point every repo at the mock 313 + // PDS so foreign-repo reads route back to these handlers. 314 + http.get(/plc\.directory/, () => { 315 + return HttpResponse.json({ 316 + service: [ 317 + { 318 + id: '#atproto_pds', 319 + type: 'AtprotoPersonalDataServer', 320 + serviceEndpoint: PDS_URL, 321 + }, 322 + ], 323 + }); 324 + }), 325 + 312 326 // Catch-all: bypass any unhandled request (DID resolution, etc.) 313 327 // to avoid MSW errors when @atproto/api makes internal requests. 314 328 http.all('*', async () => {