diff --git a/CLAUDE.md b/CLAUDE.md index 1618745..816c8ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -405,6 +405,7 @@ Write access: Standard ATProto sync endpoints: - `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file - `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision +- `GET /xrpc/com.atproto.sync.getRepoStatus?did={did}` - Get repository hosting status and current revision - `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events - `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS) - `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL diff --git a/pkg/atproto/endpoints.go b/pkg/atproto/endpoints.go index 7c57327..3d78978 100644 --- a/pkg/atproto/endpoints.go +++ b/pkg/atproto/endpoints.go @@ -98,6 +98,12 @@ const ( // Response: Stream of #commit events SyncSubscribeRepos = "/xrpc/com.atproto.sync.subscribeRepos" + // SyncGetRepoStatus gets the hosting status for a repository. + // Method: GET + // Query: did={did} + // Response: {"did": "...", "active": true, "rev": "..."} + SyncGetRepoStatus = "/xrpc/com.atproto.sync.getRepoStatus" + // SyncRequestCrawl requests a relay to crawl a PDS. // Method: POST // Request: {"hostname": "hold01.atcr.io"} diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index bcce3c9..e3f5a11 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -160,6 +160,7 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) { r.Get(atproto.SyncListRepos, h.HandleListRepos) r.Get(atproto.SyncGetRecord, h.HandleSyncGetRecord) r.Get(atproto.SyncGetRepo, h.HandleGetRepo) + r.Get(atproto.SyncGetRepoStatus, h.HandleGetRepoStatus) r.Get(atproto.SyncSubscribeRepos, h.HandleSubscribeRepos) // DID document and handle resolution @@ -1106,6 +1107,47 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// HandleGetRepoStatus returns the hosting status for a repository +// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status +func (h *XRPCHandler) HandleGetRepoStatus(w http.ResponseWriter, r *http.Request) { + // Get required 'did' parameter + did := r.URL.Query().Get("did") + if did == "" { + http.Error(w, "missing required parameter: did", http.StatusBadRequest) + return + } + + // Validate DID matches this PDS (single-user PDS only hosts one repo) + if did != h.pds.DID() { + http.Error(w, "repo not found", http.StatusNotFound) + return + } + + // Get current repo revision to verify repo is initialized + rev, err := h.pds.repomgr.GetRepoRev(r.Context(), h.pds.uid) + if err != nil || rev == "" { + // Repo exists (DID matches) but no commits yet + // Per ATProto spec, return active=true even if empty + response := map[string]any{ + "did": did, + "active": true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + // Return status with revision + response := map[string]any{ + "did": did, + "active": true, + "rev": rev, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // HandleDIDDocument returns the DID document func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) { doc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL) diff --git a/pkg/hold/pds/xrpc_test.go b/pkg/hold/pds/xrpc_test.go index 80f8e70..a0d3cfd 100644 --- a/pkg/hold/pds/xrpc_test.go +++ b/pkg/hold/pds/xrpc_test.go @@ -981,6 +981,111 @@ func TestHandleListRepos_MethodNotAllowed(t *testing.T) { t.Skip("Method validation is now handled by chi router, not individual handlers") } +// Tests for HandleGetRepoStatus + +// TestHandleGetRepoStatus tests com.atproto.sync.getRepoStatus with a valid DID +// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status +func TestHandleGetRepoStatus(t *testing.T) { + handler, _ := setupTestXRPCHandler(t) + holdDID := "did:web:hold.example.com" + + req := makeXRPCGetRequest(atproto.SyncGetRepoStatus, map[string]string{ + "did": holdDID, + }) + w := httptest.NewRecorder() + + handler.HandleGetRepoStatus(w, req) + + result := assertJSONResponse(t, w, http.StatusOK) + + // Verify required fields per spec + if did, ok := result["did"].(string); !ok || did != holdDID { + t.Errorf("Expected did=%s, got %v", holdDID, result["did"]) + } + + if active, ok := result["active"].(bool); !ok { + t.Error("Expected active boolean") + } else if !active { + t.Error("Expected active to be true") + } + + // rev is optional but should be present for initialized repo + if rev, ok := result["rev"].(string); ok && rev == "" { + t.Error("Expected non-empty rev string when present") + } +} + +// TestHandleGetRepoStatus_EmptyRepo tests getRepoStatus with no commits +// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status +func TestHandleGetRepoStatus_EmptyRepo(t *testing.T) { + pds, ctx := setupTestPDS(t) // Don't bootstrap + mockClient := &mockPDSClient{} + mockS3 := s3.S3Service{} + handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient) + holdDID := "did:web:hold.example.com" + + // Initialize repo but don't add any records + err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "") + if err != nil { + t.Fatalf("Failed to initialize repo: %v", err) + } + + req := makeXRPCGetRequest(atproto.SyncGetRepoStatus, map[string]string{ + "did": holdDID, + }) + w := httptest.NewRecorder() + + handler.HandleGetRepoStatus(w, req) + + result := assertJSONResponse(t, w, http.StatusOK) + + // Even with no commits, repo is active + if did, ok := result["did"].(string); !ok || did != holdDID { + t.Errorf("Expected did=%s, got %v", holdDID, result["did"]) + } + + if active, ok := result["active"].(bool); !ok || !active { + t.Error("Expected active=true even for empty repo") + } + + // rev may not be present for empty repo (no commits) + if rev, ok := result["rev"].(string); ok && rev != "" { + t.Logf("Note: Empty repo has rev=%s (acceptable)", rev) + } +} + +// TestHandleGetRepoStatus_MissingDID tests missing did parameter +// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status +func TestHandleGetRepoStatus_MissingDID(t *testing.T) { + handler, _ := setupTestXRPCHandler(t) + + req := makeXRPCGetRequest(atproto.SyncGetRepoStatus, nil) + w := httptest.NewRecorder() + + handler.HandleGetRepoStatus(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestHandleGetRepoStatus_InvalidDID tests invalid DID +// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status +func TestHandleGetRepoStatus_InvalidDID(t *testing.T) { + handler, _ := setupTestXRPCHandler(t) + + req := makeXRPCGetRequest(atproto.SyncGetRepoStatus, map[string]string{ + "did": "did:plc:wrongdid", + }) + w := httptest.NewRecorder() + + handler.HandleGetRepoStatus(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + // Tests for HandleSyncGetRecord // TestHandleSyncGetRecord tests com.atproto.sync.getRecord