xrpc cleanup

This commit is contained in:
Evan Jarrett
2025-10-16 18:03:05 -05:00
parent 0dc2294c87
commit 29ccb15e54
5 changed files with 274 additions and 40 deletions
+1
View File
@@ -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 ./...
+43
View File
@@ -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)")
}
+20 -5
View File
@@ -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
+50
View File
@@ -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
+160 -35
View File
@@ -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
}