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 {