···11-// Package oci provides HTTP helpers for OCI registry endpoints in the hold service.
22-// It includes utilities for JSON encoding/decoding of request/response bodies
33-// and standardized error responses for XRPC endpoints.
44-package oci
55-66-import (
77- "encoding/json"
88- "fmt"
99- "log/slog"
1010- "net/http"
1111-)
1212-1313-// DecodeJSON decodes JSON request body into the provided value
1414-// Returns an error if decoding fails
1515-func DecodeJSON(r *http.Request, v any) error {
1616- if err := json.NewDecoder(r.Body).Decode(v); err != nil {
1717- return fmt.Errorf("invalid JSON body: %w", err)
1818- }
1919- return nil
2020-}
2121-2222-// RespondJSON writes a JSON response with the given status code
2323-func RespondJSON(w http.ResponseWriter, status int, v any) {
2424- w.Header().Set("Content-Type", "application/json")
2525- w.WriteHeader(status)
2626- if err := json.NewEncoder(w).Encode(v); err != nil {
2727- // If encoding fails, we can't do much since headers are already sent
2828- // Log the error but don't try to send another response
2929- slog.Error("Failed to encode JSON response", "error", err)
3030- }
3131-}
3232-3333-// RespondError writes a JSON error response with the given status code and message
3434-func RespondError(w http.ResponseWriter, status int, message string) {
3535- RespondJSON(w, status, map[string]string{
3636- "error": message,
3737- })
3838-}
···4141CREATE INDEX IF NOT EXISTS idx_records_collection_did ON records(collection, did);
4242`
43434444-// Schema version for migration detection
4545-const recordsSchemaVersion = 2
4646-4744// NewRecordsIndex creates or opens a records index
4845// If the schema is outdated (missing did column), drops and rebuilds the table
4946func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
+43-175
pkg/hold/pds/xrpc.go
···1414 "github.com/bluesky-social/indigo/repo"
1515 "github.com/distribution/distribution/v3/registry/storage/driver"
1616 "github.com/go-chi/chi/v5"
1717+ "github.com/go-chi/render"
1718 "github.com/gorilla/websocket"
1819 "github.com/ipfs/go-cid"
1920 "github.com/ipld/go-car"
···202203203204// HandleHealth returns health check information
204205func (h *XRPCHandler) HandleHealth(w http.ResponseWriter, r *http.Request) {
205205- response := map[string]any{
206206+ render.JSON(w, r, map[string]any{
206207 "version": "0.4.999",
207207- }
208208-209209- w.Header().Set("Content-Type", "application/json")
210210- if err := json.NewEncoder(w).Encode(response); err != nil {
211211- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
212212- w.WriteHeader(http.StatusInternalServerError)
213213- }
208208+ })
214209}
215210216211// HandleDescribeServer returns server metadata
···223218 hostname, _, _ = strings.Cut(hostname, "/") // Remove path
224219 hostname, _, _ = strings.Cut(hostname, ":") // Remove port
225220226226- response := map[string]any{
221221+ render.JSON(w, r, map[string]any{
227222 "did": h.pds.DID(),
228223 "availableUserDomains": []string{"." + hostname},
229224 "inviteCodeRequired": true, // Single-user PDS, no account creation
230230- }
231231-232232- w.Header().Set("Content-Type", "application/json")
233233- if err := json.NewEncoder(w).Encode(response); err != nil {
234234- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
235235- w.WriteHeader(http.StatusInternalServerError)
236236- }
225225+ })
237226}
238227239228// HandleResolveHandle resolves a handle to a DID
···256245 }
257246258247 // Return the DID
259259- response := map[string]string{
248248+ render.JSON(w, r, map[string]string{
260249 "did": h.pds.DID(),
261261- }
262262-263263- w.Header().Set("Content-Type", "application/json")
264264- if err := json.NewEncoder(w).Encode(response); err != nil {
265265- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
266266- w.WriteHeader(http.StatusInternalServerError)
267267- }
250250+ })
268251}
269252270253// HandleGetProfile returns aggregated profile information
···296279 }
297280298281 // Build profile response using shared function
299299- response := h.buildProfileResponse(r.Context())
300300-301301- w.Header().Set("Content-Type", "application/json")
302302- if err := json.NewEncoder(w).Encode(response); err != nil {
303303- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
304304- w.WriteHeader(http.StatusInternalServerError)
305305- }
282282+ render.JSON(w, r, h.buildProfileResponse(r.Context()))
306283}
307284308285// HandleGetProfiles returns aggregated profile information for multiple actors
···348325 }
349326350327 // Return profiles array
351351- response := map[string]any{
328328+ render.JSON(w, r, map[string]any{
352329 "profiles": profiles,
353353- }
354354-355355- w.Header().Set("Content-Type", "application/json")
356356- if err := json.NewEncoder(w).Encode(response); err != nil {
357357- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
358358- w.WriteHeader(http.StatusInternalServerError)
359359- }
330330+ })
360331}
361332362333// buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles)
···441412 }
442413443414 // Note: For did:web, the handle IS the DID (not just hostname)
444444- response := map[string]any{
415415+ render.JSON(w, r, map[string]any{
445416 "did": h.pds.DID(),
446417 "handle": h.pds.DID(),
447418 "didDoc": didDoc,
448419 "collections": collections,
449420 "handleIsCorrect": true,
450450- }
451451-452452- w.Header().Set("Content-Type", "application/json")
453453- if err := json.NewEncoder(w).Encode(response); err != nil {
454454- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
455455- w.WriteHeader(http.StatusInternalServerError)
456456- }
421421+ })
457422}
458423459424// HandleGetRecord retrieves a record from the repository
···490455 return
491456 }
492457493493- response := map[string]any{
458458+ render.JSON(w, r, map[string]any{
494459 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
495460 "cid": recordCID.String(),
496461 "value": recordValue,
497497- }
498498-499499- w.Header().Set("Content-Type", "application/json")
500500- if err := json.NewEncoder(w).Encode(response); err != nil {
501501- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
502502- w.WriteHeader(http.StatusInternalServerError)
503503- }
462462+ })
504463}
505464506465// HandleListRecords lists records in a collection
···570529571530 if !head.Defined() {
572531 // Empty repo, return empty list
573573- response := map[string]any{"records": []any{}}
574574- w.Header().Set("Content-Type", "application/json")
575575- if err := json.NewEncoder(w).Encode(response); err != nil {
576576- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
577577- w.WriteHeader(http.StatusInternalServerError)
578578- }
532532+ render.JSON(w, r, map[string]any{"records": []any{}})
579533 return
580534 }
581535···621575 response["cursor"] = nextCursor
622576 }
623577624624- w.Header().Set("Content-Type", "application/json")
625625- if err := json.NewEncoder(w).Encode(response); err != nil {
626626- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
627627- w.WriteHeader(http.StatusInternalServerError)
628628- }
578578+ render.JSON(w, r, response)
629579}
630580631581// handleListRecordsMST uses the legacy MST-based listing (fallback for tests)
···645595646596 if !head.Defined() {
647597 // Empty repo, return empty list
648648- response := map[string]any{"records": []any{}}
649649- w.Header().Set("Content-Type", "application/json")
650650- if err := json.NewEncoder(w).Encode(response); err != nil {
651651- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
652652- w.WriteHeader(http.StatusInternalServerError)
653653- }
598598+ render.JSON(w, r, map[string]any{"records": []any{}})
654599 return
655600 }
656601···758703 response["cursor"] = nextCursor
759704 }
760705761761- w.Header().Set("Content-Type", "application/json")
762762- if err := json.NewEncoder(w).Encode(response); err != nil {
763763- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
764764- w.WriteHeader(http.StatusInternalServerError)
765765- }
706706+ render.JSON(w, r, response)
766707}
767708768709// HandleDeleteRecord deletes a record from the repository
···831772832773 if !currentCID.Equals(swapRecordCID) {
833774 // Swap failed - record CID doesn't match
834834- w.WriteHeader(http.StatusBadRequest)
835835- response := map[string]any{
775775+ render.Status(r, http.StatusBadRequest)
776776+ render.JSON(w, r, map[string]any{
836777 "error": "InvalidSwap",
837778 "message": "record CID does not match swapRecord",
838838- }
839839- if err := json.NewEncoder(w).Encode(response); err != nil {
840840- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
841841- w.WriteHeader(http.StatusInternalServerError)
842842- }
779779+ })
843780 return
844781 }
845782 }
···875812 }
876813877814 // Return commit response (per spec)
878878- response := map[string]any{
815815+ render.JSON(w, r, map[string]any{
879816 "commit": map[string]any{
880817 "cid": head.String(),
881818 "rev": rev,
882819 },
883883- }
884884-885885- w.Header().Set("Content-Type", "application/json")
886886- if err := json.NewEncoder(w).Encode(response); err != nil {
887887- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
888888- w.WriteHeader(http.StatusInternalServerError)
889889- }
820820+ })
890821}
891822892823// HandleSyncGetRecord returns a single record as a CAR file for sync
···941872942873 // Write the CAR data to the response
943874 if _, err := w.Write(buf.Bytes()); err != nil {
944944- slog.Error("failed to write car to http response", "error", err, "path", r.URL.Path)
945945- w.WriteHeader(http.StatusInternalServerError)
875875+ slog.Error("failed to write CAR to http response", "error", err, "path", r.URL.Path)
946876 }
947877}
948878···10941024 }
1095102510961026 // Return ATProto-compliant blob response
10971097- response := map[string]any{
10271027+ render.JSON(w, r, map[string]any{
10981028 "blob": map[string]any{
10991029 "$type": "blob",
11001030 "ref": map[string]any{
···11031033 "mimeType": "application/octet-stream",
11041034 "size": size,
11051035 },
11061106- }
11071107-11081108- w.Header().Set("Content-Type", "application/json")
11091109- if err := json.NewEncoder(w).Encode(response); err != nil {
11101110- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
11111111- w.WriteHeader(http.StatusInternalServerError)
11121112- }
10361036+ })
11131037}
1114103811151039// HandleGetBlob routes blob requests to appropriate handlers based on blob type
···11811105 "url", presignedURL)
1182110611831107 // Return JSON response with presigned URL (AppView expects this format)
11841184- response := map[string]string{
11081108+ render.JSON(w, r, map[string]string{
11851109 "url": presignedURL,
11861186- }
11871187- w.Header().Set("Content-Type", "application/json")
11881188- if err := json.NewEncoder(w).Encode(response); err != nil {
11891189- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
11901190- w.WriteHeader(http.StatusInternalServerError)
11911191- }
11101110+ })
11921111}
1193111211941113// handleGetATProtoBlob handles standard ATProto blob requests
···12381157 head, err := h.pds.repomgr.GetRepoRoot(r.Context(), h.pds.uid)
12391158 if err != nil {
12401159 // If no repo exists yet, return empty list
12411241- response := map[string]any{
12421242- "repos": []any{},
12431243- }
12441244- w.Header().Set("Content-Type", "application/json")
12451245- if err := json.NewEncoder(w).Encode(response); err != nil {
12461246- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
12471247- w.WriteHeader(http.StatusInternalServerError)
12481248- }
11601160+ render.JSON(w, r, map[string]any{"repos": []any{}})
12491161 return
12501162 }
12511163···12531165 if err != nil || rev == "" {
12541166 // No commits yet, return empty list
12551167 // Don't expose repos with no revision (empty/uninitialized)
12561256- response := map[string]any{
12571257- "repos": []any{},
12581258- }
12591259- w.Header().Set("Content-Type", "application/json")
12601260- if err := json.NewEncoder(w).Encode(response); err != nil {
12611261- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
12621262- w.WriteHeader(http.StatusInternalServerError)
12631263- }
11681168+ render.JSON(w, r, map[string]any{"repos": []any{}})
12641169 return
12651170 }
12661171···12731178 },
12741179 }
1275118012761276- response := map[string]any{
11811181+ render.JSON(w, r, map[string]any{
12771182 "repos": repos,
12781278- }
12791279-12801280- w.Header().Set("Content-Type", "application/json")
12811281- if err := json.NewEncoder(w).Encode(response); err != nil {
12821282- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
12831283- w.WriteHeader(http.StatusInternalServerError)
12841284- }
11831183+ })
12851184}
1286118512871186// HandleGetRepoStatus returns the hosting status for a repository
···13051204 if err != nil || rev == "" {
13061205 // Repo exists (DID matches) but no commits yet
13071206 // Per ATProto spec, return active=true even if empty
13081308- response := map[string]any{
12071207+ render.JSON(w, r, map[string]any{
13091208 "did": did,
13101209 "active": true,
13111311- }
13121312- w.Header().Set("Content-Type", "application/json")
13131313- if err := json.NewEncoder(w).Encode(response); err != nil {
13141314- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
13151315- w.WriteHeader(http.StatusInternalServerError)
13161316- }
12101210+ })
13171211 return
13181212 }
1319121313201214 // Return status with revision
13211321- response := map[string]any{
12151215+ render.JSON(w, r, map[string]any{
13221216 "did": did,
13231217 "active": true,
13241218 "rev": rev,
13251325- }
13261326-13271327- w.Header().Set("Content-Type", "application/json")
13281328- if err := json.NewEncoder(w).Encode(response); err != nil {
13291329- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
13301330- w.WriteHeader(http.StatusInternalServerError)
13311331- }
12191219+ })
13321220}
1333122113341222// HandleDIDDocument returns the DID document
···13391227 return
13401228 }
1341122913421342- w.Header().Set("Content-Type", "application/json")
13431343- if err := json.NewEncoder(w).Encode(doc); err != nil {
13441344- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
13451345- w.WriteHeader(http.StatusInternalServerError)
13461346- }
12301230+ render.JSON(w, r, doc)
13471231}
1348123213491233// HandleAtprotoDID returns the DID for handle resolution
···14341318 slog.Debug("User is already a crew member",
14351319 "did", user.DID,
14361320 "rkey", member.Rkey)
14371437- response := map[string]any{
13211321+ render.JSON(w, r, map[string]any{
14381322 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
14391323 "cid": member.Cid.String(),
14401324 "status": "already_member",
14411325 "message": "User is already a crew member",
14421442- }
14431443- w.Header().Set("Content-Type", "application/json")
14441444- w.WriteHeader(http.StatusOK)
14451445- if err := json.NewEncoder(w).Encode(response); err != nil {
14461446- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
14471447- w.WriteHeader(http.StatusInternalServerError)
14481448- }
13261326+ })
14491327 return
14501328 }
14511329 }
···14701348 // Return success response
14711349 // Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it
14721350 // For now, return just the CID. In production, AddCrewMember should return both CID and rkey
14731473- response := map[string]any{
13511351+ render.Status(r, http.StatusCreated)
13521352+ render.JSON(w, r, map[string]any{
14741353 "cid": recordCID.String(),
14751354 "status": "created",
14761355 "message": "Successfully added to crew",
14771477- }
14781478-14791479- w.Header().Set("Content-Type", "application/json")
14801480- w.WriteHeader(http.StatusCreated)
14811481- if err := json.NewEncoder(w).Encode(response); err != nil {
14821482- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
14831483- w.WriteHeader(http.StatusInternalServerError)
14841484- }
13561356+ })
14851357}
1486135814871359// GetPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
···16181490 return
16191491 }
1620149216211621- w.Header().Set("Content-Type", "application/json")
16221622- if err := json.NewEncoder(w).Encode(stats); err != nil {
16231623- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
16241624- w.WriteHeader(http.StatusInternalServerError)
16251625- }
14931493+ render.JSON(w, r, stats)
16261494}