From b5a72237cd6a30605555c8f5ca6941535e7c8a99 Mon Sep 17 00:00:00 2001 From: "eric.wien" Date: Sun, 29 Mar 2026 14:00:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20cross-PDS=20repo=20resolution=20v?= =?UTF-8?q?ia=20handle=E2=86=92DID=E2=86=92PDS=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveRepo now falls back to querying the owner's actual PDS when the authenticated user's PDS doesn't host the target. This enables operations against repos owned by users on different PDS instances (e.g. creating issues on tangled.org/core from a different PDS). Co-Authored-By: Claude Opus 4.6 (1M context) --- api/client.go | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/api/client.go b/api/client.go index ad210e6..18b297d 100644 --- a/api/client.go +++ b/api/client.go @@ -207,6 +207,89 @@ func (c *Client) ListRecords(ctx context.Context, did, collection, cursor string return &out, nil } +// ListPublicRecords lists AT Protocol records from another user's PDS. +// It resolves the handle to a DID, then the DID to a PDS endpoint, and +// queries that PDS directly. Used for cross-PDS lookups. +func (c *Client) ListPublicRecords(ctx context.Context, repo, collection, cursor string, limit int) (*RecordList, error) { + pdsURL, did, err := c.resolvePDS(ctx, repo) + if err != nil { + return nil, fmt.Errorf("failed to resolve PDS for %s: %w", repo, err) + } + + u := pdsURL + "/xrpc/com.atproto.repo.listRecords" + params := url.Values{ + "repo": {did}, + "collection": {collection}, + } + if cursor != "" { + params.Set("cursor", cursor) + } + if limit > 0 { + params.Set("limit", strconv.Itoa(limit)) + } + u += "?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + var out RecordList + if err := c.doPlainRequest(req, "com.atproto.repo.listRecords", &out); err != nil { + return nil, err + } + return &out, nil +} + +// resolvePDS resolves a handle or DID to the PDS service endpoint URL and DID. +func (c *Client) resolvePDS(ctx context.Context, handleOrDID string) (string, string, error) { + did := handleOrDID + + // If it's a handle, resolve to DID first. + if !strings.HasPrefix(handleOrDID, "did:") { + u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handleOrDID) + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Accept", "application/json") + + var out struct { + DID string `json:"did"` + } + if err := c.doPlainRequest(req, "com.atproto.identity.resolveHandle", &out); err != nil { + return "", "", fmt.Errorf("failed to resolve handle %s: %w", handleOrDID, err) + } + did = out.DID + } + + // Resolve DID document to find PDS endpoint. + u := "https://plc.directory/" + did + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Accept", "application/json") + + var doc struct { + Service []struct { + ID string `json:"id"` + ServiceEndpoint string `json:"serviceEndpoint"` + } `json:"service"` + } + if err := c.doPlainRequest(req, "plc.directory", &doc); err != nil { + return "", "", fmt.Errorf("failed to resolve DID document for %s: %w", did, err) + } + + for _, svc := range doc.Service { + if svc.ID == "#atproto_pds" { + return svc.ServiceEndpoint, did, nil + } + } + return "", "", fmt.Errorf("no PDS service found in DID document for %s", did) +} + // RecordList is the response from com.atproto.repo.listRecords. type RecordList struct { Records []RecordEntry `json:"records"` @@ -270,10 +353,16 @@ type ResolvedRepo struct { } // ResolveRepo looks up a repository by owner and name from AT Protocol records. +// It first tries the authenticated user's PDS, then falls back to the public API +// for cross-PDS lookups (e.g. resolving repos owned by users on a different PDS). func (c *Client) ResolveRepo(ctx context.Context, owner, name string) (*ResolvedRepo, error) { records, err := c.ListRecords(ctx, owner, RepoRecordNSID, "", 100) if err != nil { - return nil, fmt.Errorf("failed to look up repos for %s: %w", owner, err) + // The user's PDS may not host this owner — try the public API. + records, err = c.ListPublicRecords(ctx, owner, RepoRecordNSID, "", 100) + if err != nil { + return nil, fmt.Errorf("failed to look up repos for %s: %w", owner, err) + } } for _, rec := range records.Records { -- 2.51.2