···192192193193 return pdsEndpoint, nil
194194}
195195+196196+// ValidateOwnerOrCrewAdmin validates that the request has valid DPoP + OAuth tokens
197197+// and that the authenticated user is either the hold owner or a crew member with crew:admin permission
198198+func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) {
199199+ // Validate DPoP + OAuth token
200200+ user, err := ValidateDPoPRequest(r)
201201+ if err != nil {
202202+ return nil, fmt.Errorf("authentication failed: %w", err)
203203+ }
204204+205205+ // Get captain record to check owner
206206+ _, captain, err := pds.GetCaptainRecord(r.Context())
207207+ if err != nil {
208208+ return nil, fmt.Errorf("failed to get captain record: %w", err)
209209+ }
210210+211211+ // Check if user is the owner
212212+ if user.DID == captain.Owner {
213213+ return user, nil
214214+ }
215215+216216+ // Check if user is crew with admin permission
217217+ crew, err := pds.ListCrewMembers(r.Context())
218218+ if err != nil {
219219+ return nil, fmt.Errorf("failed to check crew membership: %w", err)
220220+ }
221221+222222+ for _, member := range crew {
223223+ if member.Record.Member == user.DID {
224224+ // Check if this crew member has crew:admin permission
225225+ for _, perm := range member.Record.Permissions {
226226+ if perm == "crew:admin" {
227227+ return user, nil
228228+ }
229229+ }
230230+ // User is crew but doesn't have admin permission
231231+ return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission")
232232+ }
233233+ }
234234+235235+ // User is neither owner nor authorized crew
236236+ return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)")
237237+}
+20-5
pkg/hold/pds/crew.go
···84848585 // Iterate over all crew records
8686 err = r.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error {
8787- // Extract rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
8787+ // Extract collection and rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
8888 parts := strings.Split(k, "/")
8989+ if len(parts) < 2 {
9090+ return nil // Skip invalid keys
9191+ }
9292+9393+ // Extract actual collection and rkey
9494+ actualCollection := strings.Join(parts[:len(parts)-1], "/")
8995 rkey := parts[len(parts)-1]
9696+9797+ // MST keys are sorted, so once we hit a different collection, stop walking
9898+ if actualCollection != atproto.CrewCollection {
9999+ return repo.ErrDoneIterating
100100+ }
9010191102 // Get the record directly from the repo we already have open
92103 // (calling GetCrewMember would open a new session unnecessarily)
···110121 })
111122112123 if err != nil {
113113- // If the collection doesn't exist yet (empty repo or no records created),
114114- // return empty list instead of error
115115- if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") {
124124+ // ErrDoneIterating is expected when we stop walking early
125125+ if err == repo.ErrDoneIterating {
126126+ // Successfully stopped at collection boundary
127127+ } else if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") {
128128+ // If the collection doesn't exist yet (empty repo or no records created),
129129+ // return empty list instead of error
116130 return []*CrewMemberWithKey{}, nil
131131+ } else {
132132+ return nil, fmt.Errorf("failed to list crew members: %w", err)
117133 }
118118- return nil, fmt.Errorf("failed to list crew members: %w", err)
119134 }
120135121136 return crew, nil
+50
pkg/hold/pds/server.go
···55 "fmt"
66 "os"
77 "path/filepath"
88+ "strings"
89910 "atcr.io/pkg/atproto"
1011 "github.com/bluesky-social/indigo/atproto/atcrypto"
1112 "github.com/bluesky-social/indigo/carstore"
1213 lexutil "github.com/bluesky-social/indigo/lex/util"
1314 "github.com/bluesky-social/indigo/models"
1515+ "github.com/bluesky-social/indigo/repo"
1616+ "github.com/ipfs/go-cid"
1417)
15181619// init registers our custom ATProto types with indigo's lexutil type registry
···144147145148 fmt.Printf("✅ Added %s as hold admin\n", ownerDID)
146149 return nil
150150+}
151151+152152+// ListCollections returns all collections present in the hold's repository
153153+func (p *HoldPDS) ListCollections(ctx context.Context) ([]string, error) {
154154+ session, err := p.carstore.ReadOnlySession(p.uid)
155155+ if err != nil {
156156+ return nil, fmt.Errorf("failed to create read-only session: %w", err)
157157+ }
158158+159159+ head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
160160+ if err != nil {
161161+ return nil, fmt.Errorf("failed to get repo head: %w", err)
162162+ }
163163+164164+ if !head.Defined() {
165165+ // Empty repo, no collections
166166+ return []string{}, nil
167167+ }
168168+169169+ r, err := repo.OpenRepo(ctx, session, head)
170170+ if err != nil {
171171+ return nil, fmt.Errorf("failed to open repo: %w", err)
172172+ }
173173+174174+ collections := make(map[string]bool)
175175+176176+ // Walk all records in the repo to discover collections
177177+ err = r.ForEach(ctx, "", func(k string, v cid.Cid) error {
178178+ // k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22"
179179+ parts := strings.Split(k, "/")
180180+ if len(parts) >= 1 {
181181+ collections[parts[0]] = true
182182+ }
183183+ return nil
184184+ })
185185+186186+ if err != nil {
187187+ return nil, fmt.Errorf("failed to enumerate collections: %w", err)
188188+ }
189189+190190+ // Convert map to sorted slice
191191+ result := make([]string, 0, len(collections))
192192+ for collection := range collections {
193193+ result = append(result, collection)
194194+ }
195195+196196+ return result, nil
147197}
148198149199// Close closes the carstore
+157-32
pkg/hold/pds/xrpc.go
···88 "strings"
991010 "atcr.io/pkg/atproto"
1111+ lexutil "github.com/bluesky-social/indigo/lex/util"
1212+ "github.com/bluesky-social/indigo/repo"
1113 "github.com/ipfs/go-cid"
1214 "github.com/ipld/go-car"
1315 carutil "github.com/ipld/go-car/util"
···7981 mux.HandleFunc("/.well-known/did.json", corsMiddleware(h.HandleDIDDocument))
8082 mux.HandleFunc("/.well-known/atproto-did", corsMiddleware(h.HandleAtprotoDID))
81838484+ // Write endpoints
8585+ mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", corsMiddleware(h.HandleDeleteRecord))
8686+8287 // Custom ATCR endpoints
8388 mux.HandleFunc("/xrpc/io.atcr.hold.requestCrew", corsMiddleware(h.HandleRequestCrew))
8489}
···131136 }
132137133138 // Get repo parameter
134134- repo := r.URL.Query().Get("repo")
135135- if repo == "" || repo != h.pds.DID() {
139139+ repoDID := r.URL.Query().Get("repo")
140140+ if repoDID == "" || repoDID != h.pds.DID() {
136141 http.Error(w, "invalid repo", http.StatusBadRequest)
137142 return
138143 }
···144149 return
145150 }
146151147147- // TODO: Get actual repo head from carstore
152152+ // Get actual collections from repo
153153+ collections, err := h.pds.ListCollections(r.Context())
154154+ if err != nil {
155155+ http.Error(w, fmt.Sprintf("failed to list collections: %v", err), http.StatusInternalServerError)
156156+ return
157157+ }
158158+148159 // Note: For did:web, the handle IS the DID (not just hostname)
149160 response := map[string]any{
150161 "did": h.pds.DID(),
151162 "handle": h.pds.DID(),
152163 "didDoc": didDoc,
153153- "collections": []string{atproto.CrewCollection},
164164+ "collections": collections,
154165 "handleIsCorrect": true,
155166 }
156167···165176 return
166177 }
167178168168- repo := r.URL.Query().Get("repo")
179179+ repoDID := r.URL.Query().Get("repo")
169180 collection := r.URL.Query().Get("collection")
170181 rkey := r.URL.Query().Get("rkey")
171182172172- if repo == "" || collection == "" || rkey == "" {
183183+ if repoDID == "" || collection == "" || rkey == "" {
173184 http.Error(w, "missing required parameters", http.StatusBadRequest)
174185 return
175186 }
176187177177- if repo != h.pds.DID() {
188188+ if repoDID != h.pds.DID() {
178189 http.Error(w, "invalid repo", http.StatusBadRequest)
179190 return
180191 }
181192182182- // Only support crew collection for now
183183- if collection != atproto.CrewCollection {
184184- http.Error(w, "collection not found", http.StatusNotFound)
185185- return
186186- }
187187-188188- recordCID, crewRecord, err := h.pds.GetCrewMember(r.Context(), rkey)
193193+ // Use generic repomgr.GetRecord - works for any collection
194194+ // lexutil type registry automatically unmarshals to correct type
195195+ recordCID, recordValue, err := h.pds.repomgr.GetRecord(
196196+ r.Context(),
197197+ h.pds.uid,
198198+ collection,
199199+ rkey,
200200+ cid.Undef,
201201+ )
189202 if err != nil {
190190- http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusNotFound)
203203+ if strings.Contains(err.Error(), "not found") {
204204+ http.Error(w, "record not found", http.StatusNotFound)
205205+ } else {
206206+ http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusInternalServerError)
207207+ }
191208 return
192209 }
193210194211 response := map[string]any{
195212 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
196213 "cid": recordCID.String(),
197197- "value": crewRecord,
214214+ "value": recordValue,
198215 }
199216200217 w.Header().Set("Content-Type", "application/json")
···208225 return
209226 }
210227211211- repo := r.URL.Query().Get("repo")
228228+ repoDID := r.URL.Query().Get("repo")
212229 collection := r.URL.Query().Get("collection")
213230214214- if repo == "" || collection == "" {
231231+ if repoDID == "" || collection == "" {
215232 http.Error(w, "missing required parameters", http.StatusBadRequest)
216233 return
217234 }
218235219219- if repo != h.pds.DID() {
236236+ if repoDID != h.pds.DID() {
220237 http.Error(w, "invalid repo", http.StatusBadRequest)
221238 return
222239 }
223240224224- // Only support crew collection for now
225225- if collection != atproto.CrewCollection {
226226- http.Error(w, "collection not found", http.StatusNotFound)
241241+ // Generic implementation using repo.ForEach
242242+ session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
243243+ if err != nil {
244244+ http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
245245+ return
246246+ }
247247+248248+ head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
249249+ if err != nil {
250250+ http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
251251+ return
252252+ }
253253+254254+ if !head.Defined() {
255255+ // Empty repo, return empty list
256256+ response := map[string]any{"records": []any{}}
257257+ w.Header().Set("Content-Type", "application/json")
258258+ json.NewEncoder(w).Encode(response)
227259 return
228260 }
229261230230- crew, err := h.pds.ListCrewMembers(r.Context())
262262+ repoHandle, err := repo.OpenRepo(r.Context(), session, head)
231263 if err != nil {
232232- http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
264264+ http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError)
233265 return
234266 }
235267236236- records := make([]map[string]any, len(crew))
237237- for i, member := range crew {
238238- records[i] = map[string]any{
239239- "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, member.Rkey),
240240- "cid": member.Cid.String(),
241241- "value": member.Record,
268268+ var records []map[string]any
269269+270270+ // Iterate over all records in the collection
271271+ err = repoHandle.ForEach(r.Context(), collection, func(k string, v cid.Cid) error {
272272+ // k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22"
273273+ parts := strings.Split(k, "/")
274274+ if len(parts) < 2 {
275275+ return nil // Skip invalid keys
276276+ }
277277+278278+ // Extract actual collection and rkey from the key path
279279+ actualCollection := strings.Join(parts[:len(parts)-1], "/")
280280+ rkey := parts[len(parts)-1]
281281+282282+ // Filter: only include records that match the requested collection
283283+ // MST keys are sorted lexicographically, so once we hit a different
284284+ // collection prefix, all remaining keys will also be outside our range
285285+ if actualCollection != collection {
286286+ return repo.ErrDoneIterating // Stop walking the tree
287287+ }
288288+289289+ // Get the record bytes
290290+ recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
291291+ if err != nil {
292292+ return fmt.Errorf("failed to get record: %w", err)
293293+ }
294294+295295+ // Decode using lexutil (type registry handles unmarshaling)
296296+ recordValue, err := lexutil.CborDecodeValue(*recBytes)
297297+ if err != nil {
298298+ return fmt.Errorf("failed to decode record: %w", err)
299299+ }
300300+301301+ records = append(records, map[string]any{
302302+ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), actualCollection, rkey),
303303+ "cid": recordCID.String(),
304304+ "value": recordValue,
305305+ })
306306+ return nil
307307+ })
308308+309309+ if err != nil {
310310+ // ErrDoneIterating is expected when we stop walking early (reached collection boundary)
311311+ if err == repo.ErrDoneIterating {
312312+ // Successfully stopped at collection boundary, continue with collected records
313313+ } else if strings.Contains(err.Error(), "not found") {
314314+ // If the collection doesn't exist yet, return empty list
315315+ records = []map[string]any{}
316316+ } else {
317317+ http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
318318+ return
242319 }
243320 }
244321···250327 json.NewEncoder(w).Encode(response)
251328}
252329330330+// HandleDeleteRecord deletes a record from the repository
331331+func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) {
332332+ if r.Method != http.MethodPost {
333333+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
334334+ return
335335+ }
336336+337337+ repoDID := r.URL.Query().Get("repo")
338338+ collection := r.URL.Query().Get("collection")
339339+ rkey := r.URL.Query().Get("rkey")
340340+341341+ if repoDID == "" || collection == "" || rkey == "" {
342342+ http.Error(w, "missing required parameters", http.StatusBadRequest)
343343+ return
344344+ }
345345+346346+ if repoDID != h.pds.DID() {
347347+ http.Error(w, "invalid repo", http.StatusBadRequest)
348348+ return
349349+ }
350350+351351+ // Validate DPoP + OAuth and check authorization
352352+ _, err := ValidateOwnerOrCrewAdmin(r, h.pds)
353353+ if err != nil {
354354+ http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
355355+ return
356356+ }
357357+358358+ // Delete the record using repomgr
359359+ err = h.pds.repomgr.DeleteRecord(r.Context(), h.pds.uid, collection, rkey)
360360+ if err != nil {
361361+ if strings.Contains(err.Error(), "not found") {
362362+ http.Error(w, "record not found", http.StatusNotFound)
363363+ } else {
364364+ http.Error(w, fmt.Sprintf("failed to delete record: %v", err), http.StatusInternalServerError)
365365+ }
366366+ return
367367+ }
368368+369369+ // Return success response
370370+ response := map[string]any{
371371+ "success": true,
372372+ }
373373+374374+ w.Header().Set("Content-Type", "application/json")
375375+ json.NewEncoder(w).Encode(response)
376376+}
377377+253378// HandleSyncGetRecord returns a single record as a CAR file for sync
254379func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request) {
255380 if r.Method != http.MethodGet {
···271396 return
272397 }
273398274274- // Only support crew collection for now
275275- if collection != atproto.CrewCollection {
399399+ // Support both captain and crew collections
400400+ if collection != atproto.CaptainCollection && collection != atproto.CrewCollection {
276401 http.Error(w, "collection not found", http.StatusNotFound)
277402 return
278403 }