diff --git a/CLAUDE.md b/CLAUDE.md index 248a7f1..07b0978 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ ATCR (ATProto Container Registry) is an OCI-compliant container registry that us go build -o bin/atcr-appview ./cmd/appview go build -o bin/atcr-hold ./cmd/hold go build -o bin/docker-credential-atcr ./cmd/credential-helper +go build -o bin/oauth-helper ./cmd/oauth-helper # Run tests go test ./... diff --git a/pkg/hold/pds/auth.go b/pkg/hold/pds/auth.go index 2428829..bac2288 100644 --- a/pkg/hold/pds/auth.go +++ b/pkg/hold/pds/auth.go @@ -192,3 +192,46 @@ func ResolveDIDToPDS(ctx context.Context, did string) (string, error) { return pdsEndpoint, nil } + +// ValidateOwnerOrCrewAdmin validates that the request has valid DPoP + OAuth tokens +// and that the authenticated user is either the hold owner or a crew member with crew:admin permission +func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) { + // Validate DPoP + OAuth token + user, err := ValidateDPoPRequest(r) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + // Get captain record to check owner + _, captain, err := pds.GetCaptainRecord(r.Context()) + if err != nil { + return nil, fmt.Errorf("failed to get captain record: %w", err) + } + + // Check if user is the owner + if user.DID == captain.Owner { + return user, nil + } + + // Check if user is crew with admin permission + crew, err := pds.ListCrewMembers(r.Context()) + if err != nil { + return nil, fmt.Errorf("failed to check crew membership: %w", err) + } + + for _, member := range crew { + if member.Record.Member == user.DID { + // Check if this crew member has crew:admin permission + for _, perm := range member.Record.Permissions { + if perm == "crew:admin" { + return user, nil + } + } + // User is crew but doesn't have admin permission + return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission") + } + } + + // User is neither owner nor authorized crew + return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)") +} diff --git a/pkg/hold/pds/crew.go b/pkg/hold/pds/crew.go index c961561..d7da520 100644 --- a/pkg/hold/pds/crew.go +++ b/pkg/hold/pds/crew.go @@ -84,10 +84,21 @@ func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, er // Iterate over all crew records err = r.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error { - // Extract rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22") + // Extract collection and rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22") parts := strings.Split(k, "/") + if len(parts) < 2 { + return nil // Skip invalid keys + } + + // Extract actual collection and rkey + actualCollection := strings.Join(parts[:len(parts)-1], "/") rkey := parts[len(parts)-1] + // MST keys are sorted, so once we hit a different collection, stop walking + if actualCollection != atproto.CrewCollection { + return repo.ErrDoneIterating + } + // Get the record directly from the repo we already have open // (calling GetCrewMember would open a new session unnecessarily) recordCID, recBytes, err := r.GetRecordBytes(ctx, k) @@ -110,12 +121,16 @@ func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, er }) if err != nil { - // If the collection doesn't exist yet (empty repo or no records created), - // return empty list instead of error - if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") { + // ErrDoneIterating is expected when we stop walking early + if err == repo.ErrDoneIterating { + // Successfully stopped at collection boundary + } else if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") { + // If the collection doesn't exist yet (empty repo or no records created), + // return empty list instead of error return []*CrewMemberWithKey{}, nil + } else { + return nil, fmt.Errorf("failed to list crew members: %w", err) } - return nil, fmt.Errorf("failed to list crew members: %w", err) } return crew, nil diff --git a/pkg/hold/pds/server.go b/pkg/hold/pds/server.go index 5dc2f1a..92f134f 100644 --- a/pkg/hold/pds/server.go +++ b/pkg/hold/pds/server.go @@ -5,12 +5,15 @@ import ( "fmt" "os" "path/filepath" + "strings" "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/carstore" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/models" + "github.com/bluesky-social/indigo/repo" + "github.com/ipfs/go-cid" ) // init registers our custom ATProto types with indigo's lexutil type registry @@ -146,6 +149,53 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, a return nil } +// ListCollections returns all collections present in the hold's repository +func (p *HoldPDS) ListCollections(ctx context.Context) ([]string, error) { + session, err := p.carstore.ReadOnlySession(p.uid) + if err != nil { + return nil, fmt.Errorf("failed to create read-only session: %w", err) + } + + head, err := p.carstore.GetUserRepoHead(ctx, p.uid) + if err != nil { + return nil, fmt.Errorf("failed to get repo head: %w", err) + } + + if !head.Defined() { + // Empty repo, no collections + return []string{}, nil + } + + r, err := repo.OpenRepo(ctx, session, head) + if err != nil { + return nil, fmt.Errorf("failed to open repo: %w", err) + } + + collections := make(map[string]bool) + + // Walk all records in the repo to discover collections + err = r.ForEach(ctx, "", func(k string, v cid.Cid) error { + // k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22" + parts := strings.Split(k, "/") + if len(parts) >= 1 { + collections[parts[0]] = true + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to enumerate collections: %w", err) + } + + // Convert map to sorted slice + result := make([]string, 0, len(collections)) + for collection := range collections { + result = append(result, collection) + } + + return result, nil +} + // Close closes the carstore func (p *HoldPDS) Close() error { // TODO: Close session properly diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index 0e98880..ae4cf27 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -8,6 +8,8 @@ import ( "strings" "atcr.io/pkg/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/bluesky-social/indigo/repo" "github.com/ipfs/go-cid" "github.com/ipld/go-car" carutil "github.com/ipld/go-car/util" @@ -79,6 +81,9 @@ func (h *XRPCHandler) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/.well-known/did.json", corsMiddleware(h.HandleDIDDocument)) mux.HandleFunc("/.well-known/atproto-did", corsMiddleware(h.HandleAtprotoDID)) + // Write endpoints + mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", corsMiddleware(h.HandleDeleteRecord)) + // Custom ATCR endpoints mux.HandleFunc("/xrpc/io.atcr.hold.requestCrew", corsMiddleware(h.HandleRequestCrew)) } @@ -131,8 +136,8 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) } // Get repo parameter - repo := r.URL.Query().Get("repo") - if repo == "" || repo != h.pds.DID() { + repoDID := r.URL.Query().Get("repo") + if repoDID == "" || repoDID != h.pds.DID() { http.Error(w, "invalid repo", http.StatusBadRequest) return } @@ -144,13 +149,19 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) return } - // TODO: Get actual repo head from carstore + // Get actual collections from repo + collections, err := h.pds.ListCollections(r.Context()) + if err != nil { + http.Error(w, fmt.Sprintf("failed to list collections: %v", err), http.StatusInternalServerError) + return + } + // Note: For did:web, the handle IS the DID (not just hostname) response := map[string]any{ "did": h.pds.DID(), "handle": h.pds.DID(), "didDoc": didDoc, - "collections": []string{atproto.CrewCollection}, + "collections": collections, "handleIsCorrect": true, } @@ -165,36 +176,42 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) { return } - repo := r.URL.Query().Get("repo") + repoDID := r.URL.Query().Get("repo") collection := r.URL.Query().Get("collection") rkey := r.URL.Query().Get("rkey") - if repo == "" || collection == "" || rkey == "" { + if repoDID == "" || collection == "" || rkey == "" { http.Error(w, "missing required parameters", http.StatusBadRequest) return } - if repo != h.pds.DID() { + if repoDID != h.pds.DID() { http.Error(w, "invalid repo", http.StatusBadRequest) return } - // Only support crew collection for now - if collection != atproto.CrewCollection { - http.Error(w, "collection not found", http.StatusNotFound) - return - } - - recordCID, crewRecord, err := h.pds.GetCrewMember(r.Context(), rkey) + // Use generic repomgr.GetRecord - works for any collection + // lexutil type registry automatically unmarshals to correct type + recordCID, recordValue, err := h.pds.repomgr.GetRecord( + r.Context(), + h.pds.uid, + collection, + rkey, + cid.Undef, + ) if err != nil { - http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusNotFound) + if strings.Contains(err.Error(), "not found") { + http.Error(w, "record not found", http.StatusNotFound) + } else { + http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusInternalServerError) + } return } response := map[string]any{ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey), "cid": recordCID.String(), - "value": crewRecord, + "value": recordValue, } w.Header().Set("Content-Type", "application/json") @@ -208,37 +225,97 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) return } - repo := r.URL.Query().Get("repo") + repoDID := r.URL.Query().Get("repo") collection := r.URL.Query().Get("collection") - if repo == "" || collection == "" { + if repoDID == "" || collection == "" { http.Error(w, "missing required parameters", http.StatusBadRequest) return } - if repo != h.pds.DID() { + if repoDID != h.pds.DID() { http.Error(w, "invalid repo", http.StatusBadRequest) return } - // Only support crew collection for now - if collection != atproto.CrewCollection { - http.Error(w, "collection not found", http.StatusNotFound) - return - } - - crew, err := h.pds.ListCrewMembers(r.Context()) + // Generic implementation using repo.ForEach + session, err := h.pds.carstore.ReadOnlySession(h.pds.uid) if err != nil { - http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError) return } - records := make([]map[string]any, len(crew)) - for i, member := range crew { - records[i] = map[string]any{ - "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, member.Rkey), - "cid": member.Cid.String(), - "value": member.Record, + head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError) + return + } + + if !head.Defined() { + // Empty repo, return empty list + response := map[string]any{"records": []any{}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + repoHandle, err := repo.OpenRepo(r.Context(), session, head) + if err != nil { + http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError) + return + } + + var records []map[string]any + + // Iterate over all records in the collection + err = repoHandle.ForEach(r.Context(), collection, func(k string, v cid.Cid) error { + // k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22" + parts := strings.Split(k, "/") + if len(parts) < 2 { + return nil // Skip invalid keys + } + + // Extract actual collection and rkey from the key path + actualCollection := strings.Join(parts[:len(parts)-1], "/") + rkey := parts[len(parts)-1] + + // Filter: only include records that match the requested collection + // MST keys are sorted lexicographically, so once we hit a different + // collection prefix, all remaining keys will also be outside our range + if actualCollection != collection { + return repo.ErrDoneIterating // Stop walking the tree + } + + // Get the record bytes + recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k) + if err != nil { + return fmt.Errorf("failed to get record: %w", err) + } + + // Decode using lexutil (type registry handles unmarshaling) + recordValue, err := lexutil.CborDecodeValue(*recBytes) + if err != nil { + return fmt.Errorf("failed to decode record: %w", err) + } + + records = append(records, map[string]any{ + "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), actualCollection, rkey), + "cid": recordCID.String(), + "value": recordValue, + }) + return nil + }) + + if err != nil { + // ErrDoneIterating is expected when we stop walking early (reached collection boundary) + if err == repo.ErrDoneIterating { + // Successfully stopped at collection boundary, continue with collected records + } else if strings.Contains(err.Error(), "not found") { + // If the collection doesn't exist yet, return empty list + records = []map[string]any{} + } else { + http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError) + return } } @@ -250,6 +327,54 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(response) } +// HandleDeleteRecord deletes a record from the repository +func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + repoDID := r.URL.Query().Get("repo") + collection := r.URL.Query().Get("collection") + rkey := r.URL.Query().Get("rkey") + + if repoDID == "" || collection == "" || rkey == "" { + http.Error(w, "missing required parameters", http.StatusBadRequest) + return + } + + if repoDID != h.pds.DID() { + http.Error(w, "invalid repo", http.StatusBadRequest) + return + } + + // Validate DPoP + OAuth and check authorization + _, err := ValidateOwnerOrCrewAdmin(r, h.pds) + if err != nil { + http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden) + return + } + + // Delete the record using repomgr + err = h.pds.repomgr.DeleteRecord(r.Context(), h.pds.uid, collection, rkey) + if err != nil { + if strings.Contains(err.Error(), "not found") { + http.Error(w, "record not found", http.StatusNotFound) + } else { + http.Error(w, fmt.Sprintf("failed to delete record: %v", err), http.StatusInternalServerError) + } + return + } + + // Return success response + response := map[string]any{ + "success": true, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // HandleSyncGetRecord returns a single record as a CAR file for sync func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -271,8 +396,8 @@ func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request return } - // Only support crew collection for now - if collection != atproto.CrewCollection { + // Support both captain and crew collections + if collection != atproto.CaptainCollection && collection != atproto.CrewCollection { http.Error(w, "collection not found", http.StatusNotFound) return }