diff --git a/README.md b/README.md index f825743..bb4761c 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,9 @@ it as a `subscribeRepos` upstream: reversible and deliberately emits no `#account` frame. - `getRepo` (full CAR), `getLatestCommit`, `getRecord` (proof CAR), `listRepos` (paginated), `getRepoStatus` +- `com.atproto.repo.getRecord` — the JSON read surface AppView-side + reconcilers expect (e.g. the Coves profile backfill); `repo` accepts a + DID or a bridged handle - `com.atproto.server.describeServer`, `/xrpc/_health` - `com.atproto.identity.resolveHandle` + `/.well-known/atproto-did` (task 03) - `/.well-known/did.json` — the DID document for the bridge's own derived diff --git a/cmd/tidepool/main.go b/cmd/tidepool/main.go index c7162e5..37e6521 100644 --- a/cmd/tidepool/main.go +++ b/cmd/tidepool/main.go @@ -175,6 +175,7 @@ func run(logger *slog.Logger) error { Logger: logger, Hostname: cfg.BridgeHostname, ServiceDID: cfg.BridgeServiceDID, + HandleResolver: resolver, RatePerSecond: float64(cfg.SyncRatePerSecond), RateBurst: cfg.SyncRateBurst, MaxSubscribers: cfg.SyncMaxSubscribers, diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 7f0df67..2f3e684 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -233,7 +233,12 @@ func (m *Manager) DeleteRecord(ctx context.Context, did, collection, rkey string } // GetRecord reads the current version of a record. Missing repo or record -// is an error satisfying errors.IsNotFound. +// is an error satisfying errors.IsNotFound. The lookup walks the MST +// root-to-leaf (mstPathToRecord, shared with GetRecordProof) instead of +// materializing the whole tree: GetRecord backs the public repo.getRecord +// endpoint, where collection/rkey are attacker-chosen — an eager full-tree +// load would hand an unauthenticated caller a full-repo read (and a +// whole-tree allocation) per request. func (m *Manager) GetRecord(ctx context.Context, did, collection, rkey string) (record map[string]any, recordCID string, err error) { path, _, err := validatePath(did, collection, rkey) if err != nil { @@ -259,19 +264,16 @@ func (m *Manager) GetRecord(ctx context.Context, did, collection, rkey string) ( if err != nil { return nil, "", err } - tree, _, err := loadTree(ctx, tx, did, state.headCID) + src := &txBlockSource{tx: tx, did: did} + _, commit, err := readHeadCommit(ctx, src, did, state.headCID) if err != nil { return nil, "", err } - valCID, err := tree.Get([]byte(path)) + _, valCID, err := mstPathToRecord(ctx, src, commit.Data, did, path) if err != nil { - return nil, "", fmt.Errorf("repo: MST get %s: %w", path, err) - } - if valCID == nil { - return nil, "", errors.NewNotFoundError("record", fmt.Sprintf("at://%s/%s", did, path)) + return nil, "", err } - src := &txBlockSource{tx: tx, did: did} - blk, err := src.Get(ctx, *valCID) + blk, err := src.Get(ctx, valCID) if err != nil { return nil, "", fmt.Errorf("repo: read record block %s: %w", valCID, err) } @@ -614,21 +616,32 @@ func readRepoState(ctx context.Context, tx *sql.Tx, did string, forUpdate bool) return &st, nil } -// loadTree loads the full MST behind a head commit from the DID's blocks. -// It returns the tree and the commit's data (MST root) CID. -func loadTree(ctx context.Context, tx *sql.Tx, did, headCID string) (*mst.Tree, cid.Cid, error) { +// readHeadCommit reads and decodes a repo's signed head commit block. +func readHeadCommit(ctx context.Context, src *txBlockSource, did, headCID string) (blockformat.Block, *indigorepo.Commit, error) { head, err := cid.Parse(headCID) if err != nil { - return nil, cid.Undef, fmt.Errorf("repo: parse head cid %q for %s: %w", headCID, did, err) + return nil, nil, fmt.Errorf("repo: parse head cid %q for %s: %w", headCID, did, err) } - src := &txBlockSource{tx: tx, did: did} blk, err := src.Get(ctx, head) if err != nil { - return nil, cid.Undef, fmt.Errorf("repo: read head commit %s for %s: %w", headCID, did, err) + return nil, nil, fmt.Errorf("repo: read head commit %s for %s: %w", headCID, did, err) } var commit indigorepo.Commit if err := commit.UnmarshalCBOR(bytes.NewReader(blk.RawData())); err != nil { - return nil, cid.Undef, fmt.Errorf("repo: decode head commit %s for %s: %w", headCID, did, err) + return nil, nil, fmt.Errorf("repo: decode head commit %s for %s: %w", headCID, did, err) + } + return blk, &commit, nil +} + +// loadTree loads the full MST behind a head commit from the DID's blocks. +// It returns the tree and the commit's data (MST root) CID. Write-path only: +// point lookups use mstPathToRecord instead, which stays proportional to +// tree depth. +func loadTree(ctx context.Context, tx *sql.Tx, did, headCID string) (*mst.Tree, cid.Cid, error) { + src := &txBlockSource{tx: tx, did: did} + _, commit, err := readHeadCommit(ctx, src, did, headCID) + if err != nil { + return nil, cid.Undef, err } tree, err := mst.LoadTreeFromStore(ctx, src, commit.Data) if err != nil { diff --git a/internal/repo/sync.go b/internal/repo/sync.go index 57ea26f..feee3be 100644 --- a/internal/repo/sync.go +++ b/internal/repo/sync.go @@ -17,7 +17,6 @@ import ( "fmt" "time" - indigorepo "github.com/bluesky-social/indigo/atproto/repo" "github.com/bluesky-social/indigo/atproto/repo/mst" blockformat "github.com/ipfs/go-block-format" @@ -262,46 +261,57 @@ func (m *Manager) GetRecordProof(ctx context.Context, did, collection, rkey stri if err != nil { return nil, err } - head, err := cid.Parse(state.headCID) + src := &txBlockSource{tx: tx, did: did} + headBlk, commit, err := readHeadCommit(ctx, src, did, state.headCID) if err != nil { - return nil, fmt.Errorf("repo: parse head cid %q for %s: %w", state.headCID, did, err) + return nil, err } - src := &txBlockSource{tx: tx, did: did} - headBlk, err := src.Get(ctx, head) + + blocks := []blockformat.Block{headBlk} + pathBlocks, recordCID, err := mstPathToRecord(ctx, src, commit.Data, did, path) if err != nil { - return nil, fmt.Errorf("repo: read head commit %s for %s: %w", state.headCID, did, err) + return nil, err } - var commit indigorepo.Commit - if err := commit.UnmarshalCBOR(bytes.NewReader(headBlk.RawData())); err != nil { - return nil, fmt.Errorf("repo: decode head commit %s for %s: %w", state.headCID, did, err) + blocks = append(blocks, pathBlocks...) + recBlk, err := src.Get(ctx, recordCID) + if err != nil { + return nil, fmt.Errorf("repo: read record block %s for %s: %w", recordCID, did, err) } + blocks = append(blocks, recBlk) - blocks := []blockformat.Block{headBlk} + return writeCARSlice(headBlk.Cid(), blocks) +} + +// mstPathToRecord walks the MST from dataRoot toward the leaf holding path's +// key, returning the node blocks visited (root-to-leaf order) and the +// record's CID. The walk decodes stored node bytes directly rather than +// materializing the whole tree, so cost stays proportional to tree depth — +// load-bearing on the public read surface, where collection/rkey are +// attacker-chosen. A missing record satisfies errors.IsNotFound. +func mstPathToRecord(ctx context.Context, src *txBlockSource, dataRoot cid.Cid, did, path string) ([]blockformat.Block, cid.Cid, error) { key := []byte(path) notFound := func() error { return errors.NewNotFoundError("record", fmt.Sprintf("at://%s/%s", did, path)) } - // Walk from the MST root toward the leaf, collecting each node block on - // the path. The walk decodes stored node bytes directly (rather than - // loading the whole tree) so the proof stays proportional to tree depth. - cur := commit.Data + var blocks []blockformat.Block + cur := dataRoot var recordCID *cid.Cid for depth := 0; ; depth++ { // An MST over a repo path (max ~1KB keys) can never legitimately be // hundreds of levels deep; this bounds the walk against a corrupt // (cyclic) tree. if depth > 128 { - return nil, fmt.Errorf("repo: MST walk for %s/%s exceeded max depth (corrupt tree?)", did, path) + return nil, cid.Undef, fmt.Errorf("repo: MST walk for %s/%s exceeded max depth (corrupt tree?)", did, path) } blk, err := src.Get(ctx, cur) if err != nil { - return nil, fmt.Errorf("repo: read MST node %s for %s: %w", cur, did, err) + return nil, cid.Undef, fmt.Errorf("repo: read MST node %s for %s: %w", cur, did, err) } blocks = append(blocks, blk) nd, err := mst.NodeDataFromCBOR(bytes.NewReader(blk.RawData())) if err != nil { - return nil, fmt.Errorf("repo: decode MST node %s for %s: %w", cur, did, err) + return nil, cid.Undef, fmt.Errorf("repo: decode MST node %s for %s: %w", cur, did, err) } node := nd.Node(&cur) @@ -319,20 +329,14 @@ func (m *Manager) GetRecordProof(ctx context.Context, did, collection, rkey stri } childCID := coveringChild(&node, key) if childCID == nil { - return nil, notFound() + return nil, cid.Undef, notFound() } cur = *childCID } if recordCID == nil { - return nil, notFound() - } - recBlk, err := src.Get(ctx, *recordCID) - if err != nil { - return nil, fmt.Errorf("repo: read record block %s for %s: %w", recordCID, did, err) + return nil, cid.Undef, notFound() } - blocks = append(blocks, recBlk) - - return writeCARSlice(head, blocks) + return blocks, *recordCID, nil } // coveringChild returns the CID of the child subtree a key would live under diff --git a/internal/sync/hardening_test.go b/internal/sync/hardening_test.go index 88e33ba..f768cbd 100644 --- a/internal/sync/hardening_test.go +++ b/internal/sync/hardening_test.go @@ -124,6 +124,8 @@ func TestSyncSurface_PerIPRateLimit(t *testing.T) { // The limiter guards the whole surface, not just listRepos… assert.Equal(t, http.StatusTooManyRequests, status(h.http.URL+fmt.Sprintf("/xrpc/com.atproto.sync.getRepoStatus?did=%s", testDID))) + assert.Equal(t, http.StatusTooManyRequests, + status(h.http.URL+fmt.Sprintf("/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=x", testDID, testCollection))) // …except the healthcheck probe. assert.Equal(t, http.StatusOK, status(h.http.URL+"/xrpc/_health")) } diff --git a/internal/sync/server.go b/internal/sync/server.go index 48260e2..2381d18 100644 --- a/internal/sync/server.go +++ b/internal/sync/server.go @@ -8,12 +8,14 @@ import ( "log/slog" "net/http" "strconv" + "strings" "sync/atomic" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" + "github.com/ipfs/go-cid" "tidepool/internal/errors" "tidepool/internal/ratelimit" @@ -63,12 +65,25 @@ const ( defaultMaxSubscribers = 100 ) +// HandleResolver resolves a bridged handle to its DID. Satisfied by +// identity.Resolver implementations; unknown handles are errors satisfying +// errors.IsNotFound, and syntactically degenerate handles may instead +// satisfy errors.IsValidation — the repo.getRecord handler folds both into +// repo-not-found (see handleRepoGetRecord). +type HandleResolver interface { + ResolveHandle(ctx context.Context, handle string) (string, error) +} + // Server implements the sync XRPC surface over a repo.Manager. Zero-valued // tunables in Options fall back to production defaults; tests shrink them. type Server struct { repo *repo.Manager broadcaster *Broadcaster logger *slog.Logger + // handles resolves the repo parameter of com.atproto.repo.getRecord when + // a consumer passes a handle instead of a DID. Optional: nil restricts + // that parameter to DIDs. + handles HandleResolver hostname string serviceDID string @@ -109,6 +124,9 @@ type Options struct { // until service-identity bootstrap (task 06) provisions one; a did:web // derived from Hostname is served in the meantime. ServiceDID string + // HandleResolver lets com.atproto.repo.getRecord accept bridged handles + // in its repo parameter. Optional; nil means DIDs only. + HandleResolver HandleResolver WriteTimeout time.Duration PingInterval time.Duration @@ -146,6 +164,7 @@ func NewServer(opts Options) (*Server, error) { repo: opts.Repo, broadcaster: opts.Broadcaster, logger: logger, + handles: opts.HandleResolver, hostname: opts.Hostname, serviceDID: serviceDID, writeTimeout: opts.WriteTimeout, @@ -193,6 +212,7 @@ func (s *Server) Routes(r chi.Router) { r.Get("/xrpc/com.atproto.sync.getBlob", s.limited(s.handleGetBlob)) r.Get("/xrpc/com.atproto.sync.listRepos", s.limited(s.handleListRepos)) r.Get("/xrpc/com.atproto.sync.getRepoStatus", s.limited(s.handleGetRepoStatus)) + r.Get("/xrpc/com.atproto.repo.getRecord", s.limited(s.handleRepoGetRecord)) r.Get("/xrpc/com.atproto.server.describeServer", s.limited(s.handleDescribeServer)) r.Get("/xrpc/_health", s.handleHealth) } @@ -224,6 +244,13 @@ func (s *Server) loadActiveRepo(w http.ResponseWriter, r *http.Request) *repo.Re writeXRPCError(w, http.StatusBadRequest, "InvalidRequest", "missing required parameter: did") return nil } + return s.loadActiveRepoByDID(w, r, did) +} + +// loadActiveRepoByDID is loadActiveRepo for handlers that obtain the DID some +// other way than the did query parameter (repo.getRecord's repo parameter, +// possibly via handle resolution). +func (s *Server) loadActiveRepoByDID(w http.ResponseWriter, r *http.Request, did string) *repo.RepoInfo { info, err := s.repo.GetRepoInfo(r.Context(), did) switch { case err == nil: @@ -358,6 +385,98 @@ func (s *Server) handleGetRecord(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(proof) } +// repoGetRecordOutput mirrors com.atproto.repo.getRecord's output schema. +// indigo's RepoGetRecord_Output wraps Value in a LexiconTypeDecoder, which +// round-trips through registered lexicon structs; the bridge stores records +// as plain maps, so a local shape serves them unmodified. +type repoGetRecordOutput struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value map[string]any `json:"value"` +} + +// handleRepoGetRecord serves com.atproto.repo.getRecord: the JSON form of a +// single record. The sync.* CAR endpoints cover relays, but AppView-side +// reconcilers (the Coves profile backfill) speak the repo.* JSON surface — +// without this endpoint a missed firehose event cannot be backfilled by +// consumers that speak only repo.* reads. +// +// The repo parameter is an at-identifier: a DID, or a bridged handle when a +// HandleResolver is configured (a leading @ is tolerated, matching +// resolveHandle). Handle-resolution failures — unknown and malformed alike +// — are folded into RepoNotFound so a prober learns nothing the DID path +// would not also reveal. The optional cid parameter pins a specific +// version; the bridge retains only the current one, so any other CID is a +// RecordNotFound — the same answer a reference PDS gives, since its +// repo.getRecord also serves only the current version. +func (s *Server) handleRepoGetRecord(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + repoID := strings.TrimPrefix(q.Get("repo"), "@") + collection, rkey := q.Get("collection"), q.Get("rkey") + if repoID == "" || collection == "" || rkey == "" { + writeXRPCError(w, http.StatusBadRequest, "InvalidRequest", "missing required parameter: repo, collection, and rkey") + return + } + did := repoID + if !strings.HasPrefix(did, "did:") { + if s.handles == nil { + writeXRPCError(w, http.StatusBadRequest, "InvalidRequest", "repo must be a DID") + return + } + resolved, err := s.handles.ResolveHandle(r.Context(), repoID) + switch { + case err == nil: + did = resolved + case errors.IsNotFound(err), errors.IsValidation(err): + writeXRPCError(w, http.StatusNotFound, "RepoNotFound", "repo not found: "+repoID) + return + default: + s.logger.Error("sync: resolve repo handle", "handle", repoID, "error", err) + writeXRPCError(w, http.StatusInternalServerError, "InternalServerError", "internal error") + return + } + } + info := s.loadActiveRepoByDID(w, r, did) + if info == nil { + return + } + record, recordCID, err := s.repo.GetRecord(r.Context(), info.DID, collection, rkey) + switch { + case err == nil: + case errors.IsNotFound(err): + writeXRPCError(w, http.StatusNotFound, "RecordNotFound", "record not found") + return + case errors.IsValidation(err): + writeXRPCError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) + return + default: + s.logger.Error("sync: get record", + "did", info.DID, "collection", collection, "rkey", rkey, "error", err) + writeXRPCError(w, http.StatusInternalServerError, "InternalServerError", "internal error") + return + } + if want := q.Get("cid"); want != "" { + // Parse rather than string-compare: a valid CID in a non-canonical + // multibase encoding must still match, and a malformed pin is the + // caller's bug (400), not data absence (404). + wantCID, err := cid.Parse(want) + if err != nil { + writeXRPCError(w, http.StatusBadRequest, "InvalidRequest", "invalid cid parameter") + return + } + if wantCID.String() != recordCID { + writeXRPCError(w, http.StatusNotFound, "RecordNotFound", + "record not found at cid: only the current version is retained") + return + } + } + writeJSON(w, http.StatusOK, repoGetRecordOutput{ + URI: "at://" + info.DID + "/" + collection + "/" + rkey, + CID: recordCID, + Value: record, + }) +} + // handleGetBlob serves com.atproto.sync.getBlob: the raw bytes of a stored // blob (avatars, banners, post images the materializer fetched). Like the // other content endpoints it refuses deactivated repos. diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go index 6975f80..494ff6c 100644 --- a/internal/sync/sync_test.go +++ b/internal/sync/sync_test.go @@ -678,6 +678,92 @@ func TestHTTPEndpoints(t *testing.T) { assert.Equal(t, "RecordNotFound", body["error"]) }) + t.Run("repo.getRecord JSON", func(t *testing.T) { + var body struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value map[string]any `json:"value"` + } + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection="+testCollection+"&rkey=h01", &body) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "at://"+testDID+"/"+testCollection+"/h01", body.URI) + assert.Equal(t, first.RecordCID, body.CID) + assert.Equal(t, testCollection, body.Value["$type"]) + assert.Equal(t, "first", body.Value["text"]) + }) + + t.Run("repo.getRecord pinned cid", func(t *testing.T) { + var body map[string]any + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection="+testCollection+"&rkey=h01&cid="+first.RecordCID, &body) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // The pin is parsed, not string-compared: the same CID in a + // non-canonical multibase encoding must still match. + alt, err := cid.MustParse(first.RecordCID).StringOfBase('f') // base16 + require.NoError(t, err) + require.NotEqual(t, first.RecordCID, alt) + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection="+testCollection+"&rkey=h01&cid="+alt, &body) + assert.Equal(t, http.StatusOK, resp.StatusCode, "alternate encoding of the current CID must match") + + // Only the current version is retained; any other CID is a miss. + var errBody map[string]string + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection="+testCollection+"&rkey=h01&cid="+head.CommitCID, &errBody) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "RecordNotFound", errBody["error"]) + + // A malformed pin is the caller's bug, not data absence. + errBody = map[string]string{} + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection="+testCollection+"&rkey=h01&cid=notacid", &errBody) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "InvalidRequest", errBody["error"]) + }) + + t.Run("repo.getRecord invalid collection", func(t *testing.T) { + var body map[string]string + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection=not-an-nsid&rkey=h01", &body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "InvalidRequest", body["error"]) + }) + + t.Run("repo.getRecord missing record", func(t *testing.T) { + var body map[string]string + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID+ + "&collection="+testCollection+"&rkey=nope", &body) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "RecordNotFound", body["error"]) + }) + + t.Run("repo.getRecord unknown repo", func(t *testing.T) { + var body map[string]string + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=did:plc:doesnotexistatall"+ + "&collection="+testCollection+"&rkey=h01", &body) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "RepoNotFound", body["error"]) + }) + + t.Run("repo.getRecord missing params", func(t *testing.T) { + var body map[string]string + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo="+testDID, &body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "InvalidRequest", body["error"]) + }) + + t.Run("repo.getRecord handle without resolver", func(t *testing.T) { + // This harness configures no HandleResolver, so a non-DID repo + // parameter is refused rather than silently treated as a repo miss. + var body map[string]string + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=alice.bridge.test"+ + "&collection="+testCollection+"&rkey=h01", &body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "InvalidRequest", body["error"]) + }) + t.Run("getRepo full CAR", func(t *testing.T) { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(base + "/xrpc/com.atproto.sync.getRepo?did=" + testDID) @@ -711,6 +797,80 @@ func TestHTTPEndpoints(t *testing.T) { }) } +// fakeHandleResolver satisfies HandleResolver for the repo.getRecord handle +// path without needing bridged_actors rows. Two sentinel handles inject the +// non-NotFound error classes StoreResolver can produce. +type fakeHandleResolver struct{ dids map[string]string } + +func (f *fakeHandleResolver) ResolveHandle(_ context.Context, handle string) (string, error) { + switch handle { + case "degenerate.bridge.test": + return "", errors.NewValidationError("handle", "must not be empty") + case "broken.bridge.test": + return "", fmt.Errorf("resolver store down") + } + if did, ok := f.dids[handle]; ok { + return did, nil + } + return "", errors.NewNotFoundError("handle", handle) +} + +// TestRepoGetRecord_HandleResolution pins the repo-parameter contract: a +// bridged handle resolves to its DID (with a leading @ tolerated, matching +// resolveHandle), and an unknown handle is a RepoNotFound — indistinguishable +// from an unknown DID, so probing the handle space leaks nothing new. +func TestRepoGetRecord_HandleResolution(t *testing.T) { + h := newHarnessWithPoll(t, 500*time.Millisecond, func(o *Options) { + o.HandleResolver = &fakeHandleResolver{dids: map[string]string{ + "alice.bridge.test": testDID, + }} + }) + res := putRecord(t, h, "hr1", "resolved") + base := h.http.URL + + var body struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + resp := getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=alice.bridge.test"+ + "&collection="+testCollection+"&rkey=hr1", &body) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "at://"+testDID+"/"+testCollection+"/hr1", body.URI, + "uri must carry the resolved DID, not the handle") + assert.Equal(t, res.RecordCID, body.CID) + + var prefixed struct { + URI string `json:"uri"` + } + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=%40alice.bridge.test"+ + "&collection="+testCollection+"&rkey=hr1", &prefixed) + assert.Equal(t, http.StatusOK, resp.StatusCode, "@-prefixed handle must resolve") + assert.Equal(t, body.URI, prefixed.URI, "@-prefix must not leak into the response") + + var errBody map[string]string + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=nobody.bridge.test"+ + "&collection="+testCollection+"&rkey=hr1", &errBody) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "RepoNotFound", errBody["error"]) + + // A validation error from the resolver folds into the same RepoNotFound — + // the anti-enumeration contract the handler documents. Pinned so a future + // "harmonization" with resolveHandle's 400 InvalidRequest mapping cannot + // slip through silently. + errBody = map[string]string{} + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=degenerate.bridge.test"+ + "&collection="+testCollection+"&rkey=hr1", &errBody) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "RepoNotFound", errBody["error"]) + + // An unexpected resolver failure is an internal error, not a repo miss. + errBody = map[string]string{} + resp = getJSON(t, base+"/xrpc/com.atproto.repo.getRecord?repo=broken.bridge.test"+ + "&collection="+testCollection+"&rkey=hr1", &errBody) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + assert.Equal(t, "InternalServerError", errBody["error"]) +} + // TestHTTPEndpoints_DeactivatedRepo pins the consent surface: content // endpoints refuse a tombstoned actor's repo; getRepoStatus reports it. func TestHTTPEndpoints_DeactivatedRepo(t *testing.T) { @@ -732,6 +892,7 @@ func TestHTTPEndpoints_DeactivatedRepo(t *testing.T) { "/xrpc/com.atproto.sync.getRepo?did=" + testDID, "/xrpc/com.atproto.sync.getLatestCommit?did=" + testDID, "/xrpc/com.atproto.sync.getRecord?did=" + testDID + "&collection=" + testCollection + "&rkey=d01", + "/xrpc/com.atproto.repo.getRecord?repo=" + testDID + "&collection=" + testCollection + "&rkey=d01", } { var body map[string]string resp := getJSON(t, base+endpoint, &body)