···368368369369Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet).
370370371371-**Endpoints:**
372372-- `POST /get-presigned-url` - Get download URL for blob
373373-- `POST /put-presigned-url` - Get upload URL for blob
374374-- `GET /blobs/{digest}` - Proxy download (fallback if no presigned URL support)
375375-- `PUT /blobs/{digest}` - Proxy upload (fallback)
376376-- `POST /register` - Manual registration endpoint
377377-- `GET /health` - Health check
378378-379371**Embedded PDS Endpoints:**
380372381373Each hold service includes an embedded PDS (Personal Data Server) that stores captain + crew records:
···6060 distribution.Namespace
6161 directory identity.Directory
6262 defaultStorageEndpoint string
6363- testMode bool // If true, fallback to default hold when user's hold is unreachable
6363+ testMode bool // If true, fallback to default hold when user's hold is unreachable
6464 repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
6565}
6666
+109-144
pkg/appview/storage/proxy_blob_store.go
···202202 }, nil
203203}
204204205205-// Put stores a blob
205205+// Put stores a blob using the multipart upload flow
206206+// This ensures all uploads go through the same XRPC path
206207func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
207207- // Check write access
208208+ // Check write access (fast-fail before starting multipart upload)
208209 if err := p.checkWriteAccess(ctx); err != nil {
209210 return distribution.Descriptor{}, err
210211 }
···212213 // Calculate digest
213214 dgst := digest.FromBytes(content)
214215215215- // Get upload URL
216216- url, err := p.getUploadURL(ctx, dgst, int64(len(content)))
216216+ // Use Create() flow for all uploads (goes through multipart XRPC endpoints)
217217+ writer, err := p.Create(ctx)
217218 if err != nil {
218218- fmt.Printf("[proxy_blob_store/Put] Failed to get upload URL: digest=%s, error=%v\n", dgst, err)
219219+ fmt.Printf("[proxy_blob_store/Put] Failed to create writer: %v\n", err)
219220 return distribution.Descriptor{}, err
220221 }
221222222222- // Upload the blob
223223- req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
224224- if err != nil {
225225- fmt.Printf("[proxy_blob_store/Put] Failed to create request: %v\n", err)
223223+ // Write the content
224224+ if _, err := writer.Write(content); err != nil {
225225+ writer.Cancel(ctx)
226226+ fmt.Printf("[proxy_blob_store/Put] Failed to write content: %v\n", err)
226227 return distribution.Descriptor{}, err
227228 }
228228- req.Header.Set("Content-Type", "application/octet-stream")
229229230230- resp, err := p.httpClient.Do(req)
230230+ // Commit with the calculated digest
231231+ desc, err := writer.Commit(ctx, distribution.Descriptor{
232232+ Digest: dgst,
233233+ Size: int64(len(content)),
234234+ MediaType: mediaType,
235235+ })
231236 if err != nil {
232232- fmt.Printf("[proxy_blob_store/Put] HTTP request failed: %v\n", err)
237237+ fmt.Printf("[proxy_blob_store/Put] Failed to commit: %v\n", err)
233238 return distribution.Descriptor{}, err
234239 }
235235- defer resp.Body.Close()
236236-237237- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
238238- bodyBytes, _ := io.ReadAll(resp.Body)
239239- fmt.Printf(" Error Body: %s\n", string(bodyBytes))
240240- return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(bodyBytes))
241241- }
242240243241 fmt.Printf("[proxy_blob_store/Put] Upload successful: digest=%s, size=%d\n", dgst, len(content))
244244-245245- return distribution.Descriptor{
246246- Digest: dgst,
247247- Size: int64(len(content)),
248248- MediaType: mediaType,
249249- }, nil
242242+ return desc, nil
250243}
251244252245// Delete removes a blob
···348341 return writer, nil
349342}
350343351351-// getPresignedURL requests a presigned URL from the storage service for any operation
352352-func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation, dgst string, size int64) (string, error) {
353353- reqBody := map[string]any{
354354- "operation": operation,
355355- "did": p.did,
356356- "digest": dgst,
357357- }
358358-359359- // Only include size for PUT operations
360360- if size > 0 {
361361- reqBody["size"] = size
362362- }
363363-364364- body, err := json.Marshal(reqBody)
365365- if err != nil {
366366- return "", err
367367- }
368368-369369- url := fmt.Sprintf("%s/presigned-url", p.storageEndpoint)
370370- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
371371- if err != nil {
372372- return "", err
373373- }
374374- req.Header.Set("Content-Type", "application/json")
375375-376376- resp, err := p.httpClient.Do(req)
377377- if err != nil {
378378- return "", err
379379- }
380380- defer resp.Body.Close()
381381-382382- if resp.StatusCode != http.StatusOK {
383383- return "", fmt.Errorf("failed to get presigned URL: status %d", resp.StatusCode)
384384- }
385385-386386- var result struct {
387387- URL string `json:"url"`
388388- }
389389- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
390390- return "", err
391391- }
392392-393393- return result.URL, nil
394394-}
395395-396396-// getDownloadURL requests a presigned download URL from the storage service
344344+// getDownloadURL returns the XRPC getBlob URL for downloading a blob
345345+// The hold service will redirect to a presigned S3 URL
397346func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
398398- return p.getPresignedURL(ctx, "GET", dgst.String(), 0)
347347+ // Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid={digest}
348348+ // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
349349+ url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
350350+ p.storageEndpoint, p.holdDID, dgst.String())
351351+ return url, nil
399352}
400353401401-// getHeadURL requests a presigned HEAD URL from the storage service
354354+// getHeadURL returns the XRPC getBlob URL for HEAD requests
355355+// The hold service will redirect to a presigned S3 URL
402356func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
403403- return p.getPresignedURL(ctx, "HEAD", dgst.String(), 0)
357357+ // Same as GET - hold service handles HEAD method on getBlob endpoint
358358+ url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
359359+ p.storageEndpoint, p.holdDID, dgst.String())
360360+ return url, nil
404361}
405362406406-// getUploadURL requests a presigned upload URL from the storage service
363363+// getUploadURL is deprecated - single blob uploads should use Create() instead
364364+// XRPC migration: No direct presigned upload URL endpoint, use multipart flow for all uploads
407365func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) {
408408- fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: storageEndpoint=%s, digest=%s\n", p.storageEndpoint, dgst)
409409- url, err := p.getPresignedURL(ctx, "PUT", dgst.String(), size)
410410- if err == nil {
411411- fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Got presigned URL=%s\n", url)
412412- }
413413- return url, err
366366+ return "", fmt.Errorf("single blob upload via Put() not supported with XRPC endpoints - use Create() instead")
414367}
415368416416-// startMultipartUpload initiates a multipart upload via hold service
369369+// startMultipartUpload initiates a multipart upload via XRPC uploadBlob endpoint
417370func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
418371 reqBody := map[string]any{
419419- "did": p.did,
372372+ "action": "start",
420373 "digest": digest,
421374 }
422375···425378 return "", err
426379 }
427380428428- url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
381381+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
429382 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
430383 if err != nil {
431384 return "", err
···444397 }
445398446399 var result struct {
447447- UploadID string `json:"upload_id"`
400400+ UploadID string `json:"uploadId"`
401401+ Mode string `json:"mode"`
448402 }
449403 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
450404 return "", err
···453407 return result.UploadID, nil
454408}
455409456456-// getPartPresignedURL gets a presigned URL for uploading a specific part
457457-func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
410410+// PartUploadInfo contains structured information for uploading a part
411411+type PartUploadInfo struct {
412412+ URL string `json:"url"`
413413+ Method string `json:"method,omitempty"`
414414+ Headers map[string]string `json:"headers,omitempty"`
415415+}
416416+417417+// getPartUploadInfo gets structured upload info for uploading a specific part via XRPC
418418+func (p *ProxyBlobStore) getPartUploadInfo(ctx context.Context, digest, uploadID string, partNumber int) (*PartUploadInfo, error) {
458419 reqBody := map[string]any{
459459- "did": p.did,
460460- "digest": digest,
461461- "upload_id": uploadID,
462462- "part_number": partNumber,
420420+ "action": "part",
421421+ "uploadId": uploadID,
422422+ "partNumber": partNumber,
423423+ "digest": digest,
463424 }
464425465426 body, err := json.Marshal(reqBody)
466427 if err != nil {
467467- return "", err
428428+ return nil, err
468429 }
469430470470- url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
431431+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
471432 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
472433 if err != nil {
473473- return "", err
434434+ return nil, err
474435 }
475436 req.Header.Set("Content-Type", "application/json")
476437477438 resp, err := p.httpClient.Do(req)
478439 if err != nil {
479479- return "", err
440440+ return nil, err
480441 }
481442 defer resp.Body.Close()
482443483444 if resp.StatusCode != http.StatusOK {
484445 bodyBytes, _ := io.ReadAll(resp.Body)
485485- return "", fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
446446+ return nil, fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
486447 }
487448488488- var result struct {
489489- URL string `json:"url"`
490490- }
491491- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
492492- return "", err
449449+ var uploadInfo PartUploadInfo
450450+ if err := json.NewDecoder(resp.Body).Decode(&uploadInfo); err != nil {
451451+ return nil, err
493452 }
494453495495- return result.URL, nil
454454+ return &uploadInfo, nil
496455}
497456498498-// completeMultipartUpload completes a multipart upload via hold service
457457+// completeMultipartUpload completes a multipart upload via XRPC uploadBlob endpoint
458458+// The XRPC complete action handles the move from temp to final location internally
499459func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
460460+ // Convert parts to XRPC format (partNumber instead of part_number)
461461+ xrpcParts := make([]map[string]any, len(parts))
462462+ for i, part := range parts {
463463+ xrpcParts[i] = map[string]any{
464464+ "partNumber": part.PartNumber,
465465+ "etag": part.ETag,
466466+ }
467467+ }
468468+500469 reqBody := map[string]any{
501501- "did": p.did,
502502- "digest": digest,
503503- "upload_id": uploadID,
504504- "parts": parts,
470470+ "action": "complete",
471471+ "uploadId": uploadID,
472472+ "digest": digest,
473473+ "parts": xrpcParts,
505474 }
506475507476 body, err := json.Marshal(reqBody)
···509478 return err
510479 }
511480512512- url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
481481+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
513482 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
514483 if err != nil {
515484 return err
···530499 return nil
531500}
532501533533-// abortMultipartUpload aborts a multipart upload via hold service
502502+// abortMultipartUpload aborts a multipart upload via XRPC uploadBlob endpoint
534503func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploadID string) error {
535504 reqBody := map[string]any{
536536- "did": p.did,
537537- "digest": digest,
538538- "upload_id": uploadID,
505505+ "action": "abort",
506506+ "uploadId": uploadID,
507507+ "digest": digest,
539508 }
540509541510 body, err := json.Marshal(reqBody)
···543512 return err
544513 }
545514546546- url := fmt.Sprintf("%s/abort-multipart", p.storageEndpoint)
515515+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
547516 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
548517 if err != nil {
549518 return err
···624593 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
625594 defer cancel()
626595627627- // Get presigned URL for this part
596596+ // Get structured upload info for this part
628597 tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
629629- url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber)
598598+ uploadInfo, err := w.store.getPartUploadInfo(ctx, tempDigest, w.uploadID, w.partNumber)
630599 if err != nil {
631631- return fmt.Errorf("failed to get part presigned URL: %w", err)
600600+ return fmt.Errorf("failed to get part upload info: %w", err)
632601 }
633602634634- // Upload part to S3
635635- req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
603603+ // Determine HTTP method (default to PUT)
604604+ method := uploadInfo.Method
605605+ if method == "" {
606606+ method = "PUT"
607607+ }
608608+609609+ // Upload part (either to S3 presigned URL or back to XRPC with headers)
610610+ req, err := http.NewRequestWithContext(ctx, method, uploadInfo.URL, bytes.NewReader(w.buffer.Bytes()))
636611 if err != nil {
637612 return err
638613 }
639614 req.Header.Set("Content-Type", "application/octet-stream")
615615+616616+ // Apply any additional headers from the response (for buffered mode)
617617+ for key, value := range uploadInfo.Headers {
618618+ req.Header.Set(key, value)
619619+ }
640620641621 resp, err := w.store.httpClient.Do(req)
642622 if err != nil {
···650630 }
651631652632 // Store ETag for completion
633633+ // For buffered mode, ETag might be in JSON response body
653634 etag := resp.Header.Get("ETag")
654635 if etag == "" {
655655- return fmt.Errorf("no ETag in response")
636636+ // Try to parse JSON response for buffered mode
637637+ var result struct {
638638+ ETag string `json:"etag"`
639639+ }
640640+ if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.ETag != "" {
641641+ etag = result.ETag
642642+ } else {
643643+ return fmt.Errorf("no ETag in response")
644644+ }
656645 }
657646658647 w.parts = append(w.parts, CompletedPart{
···727716 }
728717 }
729718730730- // Complete multipart upload at temp location
719719+ // Complete multipart upload - XRPC complete action handles move internally
731720 tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
732721 fmt.Printf("🔒 [Commit] Completing multipart upload: uploadID=%s, parts=%d\n", w.uploadID, len(w.parts))
733722 if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil {
734723 return distribution.Descriptor{}, fmt.Errorf("failed to complete multipart upload: %w", err)
735735- }
736736-737737- // Move from temp → final location (server-side S3 copy)
738738- tempPath := fmt.Sprintf("uploads/temp-%s", w.id)
739739- finalPath := desc.Digest.String()
740740-741741- fmt.Printf("[Commit] Moving blob: %s → %s\n", tempPath, finalPath)
742742- moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
743743- w.store.storageEndpoint, tempPath, finalPath, w.store.did)
744744-745745- req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil)
746746- if err != nil {
747747- return distribution.Descriptor{}, fmt.Errorf("failed to create move request: %w", err)
748748- }
749749-750750- resp, err := w.store.httpClient.Do(req)
751751- if err != nil {
752752- return distribution.Descriptor{}, fmt.Errorf("failed to move blob: %w", err)
753753- }
754754- defer resp.Body.Close()
755755-756756- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
757757- bodyBytes, _ := io.ReadAll(resp.Body)
758758- return distribution.Descriptor{}, fmt.Errorf("move blob failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
759724 }
760725761726 fmt.Printf("[Commit] Upload completed successfully: digest=%s, size=%d, parts=%d\n", desc.Digest, w.size, len(w.parts))
+1-1
pkg/atproto/client.go
···2525 did string
2626 accessToken string // For Basic Auth only
2727 httpClient *http.Client
2828- useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
2828+ useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
2929 indigoClient *atclient.APIClient // indigo's API client for OAuth requests
3030}
3131
+6-6
pkg/atproto/lexicon.go
···394394// Uses CBOR encoding for efficient storage in hold's carstore
395395type CaptainRecord struct {
396396 Type string `json:"$type" cborgen:"$type"`
397397- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
398398- Public bool `json:"public" cborgen:"public"` // Public read access
399399- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
400400- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
401401- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
402402- Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
397397+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
398398+ Public bool `json:"public" cborgen:"public"` // Public read access
399399+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
400400+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
401401+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
402402+ Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
403403}
404404405405// CrewRecord represents a crew member in the hold
+4-4
pkg/auth/hold_remote.go
···544544// This function handles second+ denials: 1m, 5m, 15m, 1h
545545func getBackoffDuration(denialCount int) time.Duration {
546546 backoffs := []time.Duration{
547547- 1 * time.Minute, // 1st DB denial (2nd overall) - being added soon
548548- 5 * time.Minute, // 2nd DB denial (3rd overall) - probably not happening
549549- 15 * time.Minute, // 3rd DB denial (4th overall) - definitely not soon
550550- 60 * time.Minute, // 4th+ DB denial (5th+ overall) - stop hammering
547547+ 1 * time.Minute, // 1st DB denial (2nd overall) - being added soon
548548+ 5 * time.Minute, // 2nd DB denial (3rd overall) - probably not happening
549549+ 15 * time.Minute, // 3rd DB denial (4th overall) - definitely not soon
550550+ 60 * time.Minute, // 4th+ DB denial (5th+ overall) - stop hammering
551551 }
552552553553 idx := denialCount - 1
···77 "fmt"
88 "io"
99 "net/http"
1010+ "slices"
1011 "strings"
11121213 "github.com/bluesky-social/indigo/atproto/identity"
1314 "github.com/bluesky-social/indigo/atproto/syntax"
1415)
1616+1717+// HTTPClient interface allows injecting a custom HTTP client for testing
1818+type HTTPClient interface {
1919+ Do(*http.Request) (*http.Response, error)
2020+}
15211622// ValidatedUser represents a successfully validated user from DPoP + OAuth
1723type ValidatedUser struct {
···2733// 2. Extract DPoP header (proof JWT)
2834// 3. Call user's PDS to validate token via com.atproto.server.getSession
2935// 4. Return validated user DID
3030-func ValidateDPoPRequest(r *http.Request) (*ValidatedUser, error) {
3636+//
3737+// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
3838+// This allows tests to inject a mock HTTP client.
3939+func ValidateDPoPRequest(r *http.Request, httpClient HTTPClient) (*ValidatedUser, error) {
3140 // Extract Authorization header
3241 authHeader := r.Header.Get("Authorization")
3342 if authHeader == "" {
···7281 }
73827483 // Validate token with the user's PDS
7575- session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof)
8484+ session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof, httpClient)
7685 if err != nil {
7786 return nil, fmt.Errorf("token validation failed: %w", err)
7887 }
···139148}
140149141150// validateTokenWithPDS calls the user's PDS to validate the token
142142-func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string) (*SessionResponse, error) {
151151+// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
152152+func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string, httpClient HTTPClient) (*SessionResponse, error) {
143153 // Call com.atproto.server.getSession with DPoP headers
144154 url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", strings.TrimSuffix(pdsURL, "/"))
145155···152162 req.Header.Set("Authorization", "DPoP "+accessToken)
153163 req.Header.Set("DPoP", dpopProof)
154164155155- resp, err := http.DefaultClient.Do(req)
165165+ // Use provided client or default to http.DefaultClient
166166+ client := httpClient
167167+ if client == nil {
168168+ client = http.DefaultClient
169169+ }
170170+171171+ resp, err := client.Do(req)
156172 if err != nil {
157173 return nil, fmt.Errorf("failed to call PDS: %w", err)
158174 }
···194210}
195211196212// 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) {
213213+// and that the authenticated user is either the hold owner or a crew member with crew:admin permission.
214214+// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
215215+func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
199216 // Validate DPoP + OAuth token
200200- user, err := ValidateDPoPRequest(r)
217217+ user, err := ValidateDPoPRequest(r, httpClient)
201218 if err != nil {
202219 return nil, fmt.Errorf("authentication failed: %w", err)
203220 }
···222239 for _, member := range crew {
223240 if member.Record.Member == user.DID {
224241 // 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- }
242242+ if slices.Contains(member.Record.Permissions, "crew:admin") {
243243+ return user, nil
229244 }
230245 // User is crew but doesn't have admin permission
231246 return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission")
···235250 // User is neither owner nor authorized crew
236251 return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)")
237252}
253253+254254+// ValidateBlobWriteAccess validates that the request has valid DPoP + OAuth tokens
255255+// and that the authenticated user is either the hold owner or a crew member with blob:write permission.
256256+// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
257257+func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
258258+ // Validate DPoP + OAuth token
259259+ user, err := ValidateDPoPRequest(r, httpClient)
260260+ if err != nil {
261261+ return nil, fmt.Errorf("authentication failed: %w", err)
262262+ }
263263+264264+ // Get captain record to check owner and public settings
265265+ _, captain, err := pds.GetCaptainRecord(r.Context())
266266+ if err != nil {
267267+ return nil, fmt.Errorf("failed to get captain record: %w", err)
268268+ }
269269+270270+ // Check if user is the owner (always has write access)
271271+ if user.DID == captain.Owner {
272272+ return user, nil
273273+ }
274274+275275+ // Check if user is crew with blob:write permission
276276+ crew, err := pds.ListCrewMembers(r.Context())
277277+ if err != nil {
278278+ return nil, fmt.Errorf("failed to check crew membership: %w", err)
279279+ }
280280+281281+ for _, member := range crew {
282282+ if member.Record.Member == user.DID {
283283+ // Check if this crew member has blob:write permission
284284+ if slices.Contains(member.Record.Permissions, "blob:write") {
285285+ return user, nil
286286+ }
287287+ // User is crew but doesn't have write permission
288288+ return nil, fmt.Errorf("crew member lacks required 'blob:write' permission")
289289+ }
290290+ }
291291+292292+ // User is neither owner nor authorized crew
293293+ return nil, fmt.Errorf("user is not authorized for blob write (must be hold owner or crew with blob:write permission)")
294294+}
295295+296296+// ValidateBlobReadAccess validates that the request has read access to blobs
297297+// If captain.public = true: No auth required (returns nil user to indicate public access)
298298+// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read permission).
299299+// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
300300+func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
301301+ // Get captain record to check public setting
302302+ _, captain, err := pds.GetCaptainRecord(r.Context())
303303+ if err != nil {
304304+ return nil, fmt.Errorf("failed to get captain record: %w", err)
305305+ }
306306+307307+ // If hold is public, allow access without authentication
308308+ if captain.Public {
309309+ return nil, nil // nil user indicates public access
310310+ }
311311+312312+ // Private hold - require authentication
313313+ user, err := ValidateDPoPRequest(r, httpClient)
314314+ if err != nil {
315315+ return nil, fmt.Errorf("authentication required for private hold: %w", err)
316316+ }
317317+318318+ // Check if user is the owner (always has read access)
319319+ if user.DID == captain.Owner {
320320+ return user, nil
321321+ }
322322+323323+ // Check if user is crew with blob:read permission
324324+ crew, err := pds.ListCrewMembers(r.Context())
325325+ if err != nil {
326326+ return nil, fmt.Errorf("failed to check crew membership: %w", err)
327327+ }
328328+329329+ for _, member := range crew {
330330+ if member.Record.Member == user.DID {
331331+ // Check if this crew member has blob:read permission
332332+ if slices.Contains(member.Record.Permissions, "blob:read") {
333333+ return user, nil
334334+ }
335335+ // User is crew but doesn't have read permission
336336+ return nil, fmt.Errorf("crew member lacks required 'blob:read' permission")
337337+ }
338338+ }
339339+340340+ // User is neither owner nor authorized crew
341341+ return nil, fmt.Errorf("user is not authorized for blob read (must be hold owner or crew with blob:read permission)")
342342+}
+587
pkg/hold/pds/auth_test.go
···11+package pds
22+33+import (
44+ "encoding/base64"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "net/http"
99+ "net/http/httptest"
1010+ "slices"
1111+ "strings"
1212+ "testing"
1313+ "time"
1414+1515+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1616+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1717+)
1818+1919+// Tests for authorization functions in auth.go
2020+2121+// mockPDSClient is a mock HTTP client that simulates a PDS server
2222+// It validates DPoP tokens and returns session information
2323+type mockPDSClient struct{}
2424+2525+func (m *mockPDSClient) Do(req *http.Request) (*http.Response, error) {
2626+ // Verify request is for getSession endpoint
2727+ if !strings.Contains(req.URL.Path, "/xrpc/com.atproto.server.getSession") {
2828+ return &http.Response{
2929+ StatusCode: http.StatusNotFound,
3030+ Body: http.NoBody,
3131+ }, nil
3232+ }
3333+3434+ // Verify DPoP headers are present
3535+ authHeader := req.Header.Get("Authorization")
3636+ dpopHeader := req.Header.Get("DPoP")
3737+3838+ if authHeader == "" || dpopHeader == "" {
3939+ return &http.Response{
4040+ StatusCode: http.StatusUnauthorized,
4141+ Body: http.NoBody,
4242+ }, nil
4343+ }
4444+4545+ // Extract access token from Authorization header
4646+ parts := strings.SplitN(authHeader, " ", 2)
4747+ if len(parts) != 2 || parts[0] != "DPoP" {
4848+ return &http.Response{
4949+ StatusCode: http.StatusUnauthorized,
5050+ Body: http.NoBody,
5151+ }, nil
5252+ }
5353+5454+ accessToken := parts[1]
5555+5656+ // Parse token to extract DID
5757+ did, _, err := extractDIDFromToken(accessToken)
5858+ if err != nil {
5959+ return &http.Response{
6060+ StatusCode: http.StatusBadRequest,
6161+ Body: http.NoBody,
6262+ }, nil
6363+ }
6464+6565+ // Return session response
6666+ session := SessionResponse{
6767+ DID: did,
6868+ Handle: strings.Replace(did, "did:plc:", "", 1) + ".test",
6969+ }
7070+7171+ body, _ := json.Marshal(session)
7272+7373+ return &http.Response{
7474+ StatusCode: http.StatusOK,
7575+ Body: io.NopCloser(strings.NewReader(string(body))),
7676+ Header: http.Header{"Content-Type": []string{"application/json"}},
7777+ }, nil
7878+}
7979+8080+// DPoPTestHelper provides utilities for creating valid DPoP requests in tests
8181+type DPoPTestHelper struct {
8282+ privKey atcrypto.PrivateKey
8383+ did string
8484+ pdsURL string
8585+}
8686+8787+// NewDPoPTestHelper creates a new test helper for the given DID and PDS
8888+func NewDPoPTestHelper(did, pdsURL string) (*DPoPTestHelper, error) {
8989+ // Generate a test P-256 key (required for OAuth DPoP)
9090+ // Note: ATProto uses K-256 for DID keys, but OAuth DPoP requires P-256
9191+ privKey, err := atcrypto.GeneratePrivateKeyP256()
9292+ if err != nil {
9393+ return nil, fmt.Errorf("failed to generate key: %w", err)
9494+ }
9595+9696+ return &DPoPTestHelper{
9797+ privKey: privKey,
9898+ did: did,
9999+ pdsURL: pdsURL,
100100+ }, nil
101101+}
102102+103103+// CreateAccessToken creates a mock OAuth access token for testing
104104+// This mimics what a real PDS would issue
105105+func (h *DPoPTestHelper) CreateAccessToken() (string, error) {
106106+ // Create access token claims
107107+ claims := map[string]any{
108108+ "sub": h.did, // Subject (DID)
109109+ "iss": h.pdsURL, // Issuer (PDS URL)
110110+ "aud": "atcr", // Audience
111111+ "iat": time.Now().Unix(), // Issued at
112112+ "exp": time.Now().Add(1 * time.Hour).Unix(), // Expires in 1 hour
113113+ }
114114+115115+ // For testing, we create a valid JWT structure without actually validating the signature
116116+ // The ValidateDPoPRequest in real use would validate this by calling the PDS
117117+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256K","typ":"JWT"}`))
118118+ payload, err := json.Marshal(claims)
119119+ if err != nil {
120120+ return "", fmt.Errorf("failed to marshal claims: %w", err)
121121+ }
122122+ encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
123123+124124+ // Create a mock signature (in real use, the PDS validates this)
125125+ signature := base64.RawURLEncoding.EncodeToString([]byte("mock-signature-for-testing"))
126126+127127+ tokenString := fmt.Sprintf("%s.%s.%s", header, encodedPayload, signature)
128128+ return tokenString, nil
129129+}
130130+131131+// CreateDPoPProof creates a DPoP proof JWT for the given HTTP request
132132+func (h *DPoPTestHelper) CreateDPoPProof(method, url string) (string, error) {
133133+ return oauth.NewAuthDPoP(method, url, "", h.privKey)
134134+}
135135+136136+// AddDPoPToRequest adds proper DPoP headers to an HTTP request
137137+func (h *DPoPTestHelper) AddDPoPToRequest(req *http.Request) error {
138138+ // Create access token
139139+ accessToken, err := h.CreateAccessToken()
140140+ if err != nil {
141141+ return fmt.Errorf("failed to create access token: %w", err)
142142+ }
143143+144144+ // Create DPoP proof for this specific request
145145+ dpopProof, err := h.CreateDPoPProof(req.Method, req.URL.String())
146146+ if err != nil {
147147+ return fmt.Errorf("failed to create DPoP proof: %w", err)
148148+ }
149149+150150+ // Add headers
151151+ req.Header.Set("Authorization", "DPoP "+accessToken)
152152+ req.Header.Set("DPoP", dpopProof)
153153+154154+ return nil
155155+}
156156+157157+// AddTestDPoP is a quick helper for common test case: owner with standard PDS
158158+func AddTestDPoP(req *http.Request, did, pdsURL string) error {
159159+ helper, err := NewDPoPTestHelper(did, pdsURL)
160160+ if err != nil {
161161+ return err
162162+ }
163163+ return helper.AddDPoPToRequest(req)
164164+}
165165+166166+// TestValidateBlobWriteAccess_Owner tests that the hold owner has write access
167167+func TestValidateBlobWriteAccess_Owner(t *testing.T) {
168168+ pds, ctx := setupTestPDS(t)
169169+170170+ ownerDID := "did:plc:owner123"
171171+172172+ // Bootstrap with owner
173173+ err := pds.Bootstrap(ctx, ownerDID, true, false)
174174+ if err != nil {
175175+ t.Fatalf("Failed to bootstrap PDS: %v", err)
176176+ }
177177+178178+ // Create DPoP helper for owner
179179+ dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
180180+ if err != nil {
181181+ t.Fatalf("Failed to create DPoP helper: %v", err)
182182+ }
183183+184184+ // Create request with proper DPoP tokens
185185+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
186186+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
187187+ t.Fatalf("Failed to add DPoP to request: %v", err)
188188+ }
189189+190190+ // Use mock PDS client
191191+ mockClient := &mockPDSClient{}
192192+193193+ // Test owner has write access
194194+ user, err := ValidateBlobWriteAccess(req, pds, mockClient)
195195+ if err != nil {
196196+ t.Errorf("Expected owner to have write access, got error: %v", err)
197197+ }
198198+199199+ if user == nil {
200200+ t.Fatal("Expected non-nil user")
201201+ }
202202+203203+ if user.DID != ownerDID {
204204+ t.Errorf("Expected DID %s, got %s", ownerDID, user.DID)
205205+ }
206206+207207+ if !user.Authorized {
208208+ t.Error("Expected user to be authorized")
209209+ }
210210+}
211211+212212+// TestValidateBlobWriteAccess_CrewPermissions tests crew permission checking
213213+func TestValidateBlobWriteAccess_CrewPermissions(t *testing.T) {
214214+ pds, ctx := setupTestPDS(t)
215215+216216+ ownerDID := "did:plc:owner123"
217217+218218+ // Bootstrap
219219+ err := pds.Bootstrap(ctx, ownerDID, true, false)
220220+ if err != nil {
221221+ t.Fatalf("Failed to bootstrap PDS: %v", err)
222222+ }
223223+224224+ // Add crew member with blob:write permission
225225+ writerDID := "did:plc:writer123"
226226+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
227227+ if err != nil {
228228+ t.Fatalf("Failed to add crew member: %v", err)
229229+ }
230230+231231+ // Add crew member without blob:write permission
232232+ readerDID := "did:plc:reader123"
233233+ _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
234234+ if err != nil {
235235+ t.Fatalf("Failed to add crew member: %v", err)
236236+ }
237237+238238+ mockClient := &mockPDSClient{}
239239+240240+ // Test writer (has blob:write permission) can write
241241+ t.Run("crew with blob:write can write", func(t *testing.T) {
242242+ dpopHelper, err := NewDPoPTestHelper(writerDID, "https://test-pds.example.com")
243243+ if err != nil {
244244+ t.Fatalf("Failed to create DPoP helper: %v", err)
245245+ }
246246+247247+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
248248+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
249249+ t.Fatalf("Failed to add DPoP to request: %v", err)
250250+ }
251251+252252+ user, err := ValidateBlobWriteAccess(req, pds, mockClient)
253253+ if err != nil {
254254+ t.Errorf("Expected writer to have write access, got error: %v", err)
255255+ }
256256+257257+ if user == nil || user.DID != writerDID {
258258+ t.Errorf("Expected user DID %s, got %v", writerDID, user)
259259+ }
260260+ })
261261+262262+ // Test reader (no blob:write permission) cannot write
263263+ t.Run("crew without blob:write cannot write", func(t *testing.T) {
264264+ dpopHelper, err := NewDPoPTestHelper(readerDID, "https://test-pds.example.com")
265265+ if err != nil {
266266+ t.Fatalf("Failed to create DPoP helper: %v", err)
267267+ }
268268+269269+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
270270+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
271271+ t.Fatalf("Failed to add DPoP to request: %v", err)
272272+ }
273273+274274+ _, err = ValidateBlobWriteAccess(req, pds, mockClient)
275275+ if err == nil {
276276+ t.Error("Expected reader without blob:write permission to be denied")
277277+ }
278278+279279+ if !strings.Contains(err.Error(), "blob:write") {
280280+ t.Errorf("Expected error about blob:write permission, got: %v", err)
281281+ }
282282+ })
283283+}
284284+285285+// TestValidateBlobReadAccess_PublicHold tests public hold access
286286+func TestValidateBlobReadAccess_PublicHold(t *testing.T) {
287287+ pds, ctx := setupTestPDS(t)
288288+289289+ ownerDID := "did:plc:owner123"
290290+291291+ // Bootstrap with public=true
292292+ err := pds.Bootstrap(ctx, ownerDID, true, false)
293293+ if err != nil {
294294+ t.Fatalf("Failed to bootstrap PDS: %v", err)
295295+ }
296296+297297+ // Verify captain record has public=true
298298+ _, captain, err := pds.GetCaptainRecord(ctx)
299299+ if err != nil {
300300+ t.Fatalf("Failed to get captain record: %v", err)
301301+ }
302302+303303+ if !captain.Public {
304304+ t.Error("Expected public=true for captain record")
305305+ }
306306+307307+ // Create request without auth headers (anonymous user)
308308+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
309309+310310+ // This should return nil (public access allowed) for public holds
311311+ user, err := ValidateBlobReadAccess(req, pds, nil)
312312+ if err != nil {
313313+ t.Errorf("Expected public access for public hold, got error: %v", err)
314314+ }
315315+316316+ // nil user indicates public access
317317+ if user != nil {
318318+ t.Error("Expected nil user for public access")
319319+ }
320320+}
321321+322322+// TestValidateBlobReadAccess_PrivateHold tests private hold access
323323+func TestValidateBlobReadAccess_PrivateHold(t *testing.T) {
324324+ pds, ctx := setupTestPDS(t)
325325+326326+ ownerDID := "did:plc:owner123"
327327+328328+ // Bootstrap with public=false
329329+ err := pds.Bootstrap(ctx, ownerDID, false, false)
330330+ if err != nil {
331331+ t.Fatalf("Failed to bootstrap PDS: %v", err)
332332+ }
333333+334334+ // Update captain to be private
335335+ _, err = pds.UpdateCaptainRecord(ctx, false, false)
336336+ if err != nil {
337337+ t.Fatalf("Failed to update captain record: %v", err)
338338+ }
339339+340340+ // Verify captain record has public=false
341341+ _, captain, err := pds.GetCaptainRecord(ctx)
342342+ if err != nil {
343343+ t.Fatalf("Failed to get captain record: %v", err)
344344+ }
345345+346346+ if captain.Public {
347347+ t.Error("Expected public=false for captain record")
348348+ }
349349+350350+ // Create request without auth headers (anonymous user)
351351+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
352352+353353+ // This should return error (auth required) for private holds
354354+ user, err := ValidateBlobReadAccess(req, pds, nil)
355355+ if err == nil {
356356+ t.Error("Expected error for private hold without auth")
357357+ }
358358+359359+ if user != nil {
360360+ t.Error("Expected nil user when auth fails")
361361+ }
362362+}
363363+364364+// TestValidateOwnerOrCrewAdmin tests admin permission checking
365365+func TestValidateOwnerOrCrewAdmin(t *testing.T) {
366366+ pds, ctx := setupTestPDS(t)
367367+368368+ ownerDID := "did:plc:owner123"
369369+370370+ // Bootstrap
371371+ err := pds.Bootstrap(ctx, ownerDID, true, false)
372372+ if err != nil {
373373+ t.Fatalf("Failed to bootstrap PDS: %v", err)
374374+ }
375375+376376+ // Add crew member with crew:admin permission
377377+ adminDID := "did:plc:admin123"
378378+ _, err = pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"})
379379+ if err != nil {
380380+ t.Fatalf("Failed to add crew admin: %v", err)
381381+ }
382382+383383+ // Add crew member without crew:admin permission
384384+ writerDID := "did:plc:writer123"
385385+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
386386+ if err != nil {
387387+ t.Fatalf("Failed to add crew writer: %v", err)
388388+ }
389389+390390+ // Verify crew records were created
391391+ crew, err := pds.ListCrewMembers(ctx)
392392+ if err != nil {
393393+ t.Fatalf("Failed to list crew members: %v", err)
394394+ }
395395+396396+ // Verify admin has crew:admin permission
397397+ hasAdminPermission := false
398398+ for _, member := range crew {
399399+ if member.Record.Member == adminDID {
400400+ if slices.Contains(member.Record.Permissions, "crew:admin") {
401401+ hasAdminPermission = true
402402+ }
403403+ }
404404+ }
405405+406406+ if !hasAdminPermission {
407407+ t.Error("Admin crew member should have crew:admin permission")
408408+ }
409409+410410+ // Verify writer does NOT have crew:admin permission
411411+ writerHasAdminPermission := false
412412+ for _, member := range crew {
413413+ if member.Record.Member == writerDID {
414414+ if slices.Contains(member.Record.Permissions, "crew:admin") {
415415+ writerHasAdminPermission = true
416416+ }
417417+ }
418418+ }
419419+420420+ if writerHasAdminPermission {
421421+ t.Error("Writer crew member should NOT have crew:admin permission")
422422+ }
423423+424424+ // Test that function requires auth (will fail without DPoP tokens)
425425+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
426426+ _, err = ValidateOwnerOrCrewAdmin(req, pds, nil)
427427+ if err == nil {
428428+ t.Error("Expected error for missing auth headers")
429429+ }
430430+}
431431+432432+// TestCrewPermissions tests various permission combinations
433433+func TestCrewPermissions(t *testing.T) {
434434+ pds, ctx := setupTestPDS(t)
435435+436436+ ownerDID := "did:plc:owner123"
437437+438438+ // Bootstrap
439439+ err := pds.Bootstrap(ctx, ownerDID, true, false)
440440+ if err != nil {
441441+ t.Fatalf("Failed to bootstrap PDS: %v", err)
442442+ }
443443+444444+ tests := []struct {
445445+ name string
446446+ did string
447447+ role string
448448+ permissions []string
449449+ }{
450450+ {
451451+ name: "full admin",
452452+ did: "did:plc:fulladmin",
453453+ role: "admin",
454454+ permissions: []string{"crew:admin", "blob:write", "blob:read"},
455455+ },
456456+ {
457457+ name: "writer only",
458458+ did: "did:plc:writer",
459459+ role: "writer",
460460+ permissions: []string{"blob:write"},
461461+ },
462462+ {
463463+ name: "reader only",
464464+ did: "did:plc:reader",
465465+ role: "reader",
466466+ permissions: []string{"blob:read"},
467467+ },
468468+ {
469469+ name: "read-write",
470470+ did: "did:plc:readwrite",
471471+ role: "editor",
472472+ permissions: []string{"blob:read", "blob:write"},
473473+ },
474474+ }
475475+476476+ // Add all crew members
477477+ for _, tt := range tests {
478478+ _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions)
479479+ if err != nil {
480480+ t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
481481+ }
482482+ }
483483+484484+ // Verify all crew members were created
485485+ crew, err := pds.ListCrewMembers(ctx)
486486+ if err != nil {
487487+ t.Fatalf("Failed to list crew members: %v", err)
488488+ }
489489+490490+ // Should have: 1 owner (from bootstrap) + 4 test crew members
491491+ expectedCount := len(tests) + 1
492492+ if len(crew) != expectedCount {
493493+ t.Errorf("Expected %d crew members (owner + %d test members), got %d",
494494+ expectedCount, len(tests), len(crew))
495495+ }
496496+497497+ // Verify each crew member has the expected permissions
498498+ for _, tt := range tests {
499499+ found := false
500500+ for _, member := range crew {
501501+ if member.Record.Member == tt.did {
502502+ found = true
503503+504504+ // Check that all expected permissions are present
505505+ for _, expectedPerm := range tt.permissions {
506506+ hasPerm := slices.Contains(member.Record.Permissions, expectedPerm)
507507+ if !hasPerm {
508508+ t.Errorf("Crew member %s missing expected permission %s",
509509+ tt.name, expectedPerm)
510510+ }
511511+ }
512512+513513+ // Verify role
514514+ if member.Record.Role != tt.role {
515515+ t.Errorf("Crew member %s has role %s, expected %s",
516516+ tt.name, member.Record.Role, tt.role)
517517+ }
518518+ }
519519+ }
520520+521521+ if !found {
522522+ t.Errorf("Crew member %s not found in list", tt.name)
523523+ }
524524+ }
525525+}
526526+527527+// TestCaptainRecordSettings tests captain record public/allowAllCrew settings
528528+func TestCaptainRecordSettings(t *testing.T) {
529529+ tests := []struct {
530530+ name string
531531+ public bool
532532+ allowAllCrew bool
533533+ }{
534534+ {
535535+ name: "public hold, crew approval required",
536536+ public: true,
537537+ allowAllCrew: false,
538538+ },
539539+ {
540540+ name: "public hold, open crew",
541541+ public: true,
542542+ allowAllCrew: true,
543543+ },
544544+ {
545545+ name: "private hold, crew approval required",
546546+ public: false,
547547+ allowAllCrew: false,
548548+ },
549549+ {
550550+ name: "private hold, open crew",
551551+ public: false,
552552+ allowAllCrew: true,
553553+ },
554554+ }
555555+556556+ for _, tt := range tests {
557557+ t.Run(tt.name, func(t *testing.T) {
558558+ pds, ctx := setupTestPDS(t)
559559+560560+ ownerDID := "did:plc:owner123"
561561+562562+ // Bootstrap with specified settings
563563+ err := pds.Bootstrap(ctx, ownerDID, tt.public, tt.allowAllCrew)
564564+ if err != nil {
565565+ t.Fatalf("Failed to bootstrap PDS: %v", err)
566566+ }
567567+568568+ // Verify captain record has expected settings
569569+ _, captain, err := pds.GetCaptainRecord(ctx)
570570+ if err != nil {
571571+ t.Fatalf("Failed to get captain record: %v", err)
572572+ }
573573+574574+ if captain.Public != tt.public {
575575+ t.Errorf("Expected public=%v, got %v", tt.public, captain.Public)
576576+ }
577577+578578+ if captain.AllowAllCrew != tt.allowAllCrew {
579579+ t.Errorf("Expected allowAllCrew=%v, got %v", tt.allowAllCrew, captain.AllowAllCrew)
580580+ }
581581+582582+ if captain.Owner != ownerDID {
583583+ t.Errorf("Expected owner %s, got %s", ownerDID, captain.Owner)
584584+ }
585585+ })
586586+ }
587587+}
+274
pkg/hold/pds/did_test.go
···11+package pds
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "path/filepath"
77+ "testing"
88+)
99+1010+// TestGenerateDIDFromURL tests DID generation from various URL formats
1111+func TestGenerateDIDFromURL(t *testing.T) {
1212+ tests := []struct {
1313+ name string
1414+ publicURL string
1515+ expectedDID string
1616+ }{
1717+ {
1818+ name: "standard HTTP with standard port",
1919+ publicURL: "http://hold.example.com",
2020+ expectedDID: "did:web:hold.example.com",
2121+ },
2222+ {
2323+ name: "standard HTTPS with standard port",
2424+ publicURL: "https://hold.example.com",
2525+ expectedDID: "did:web:hold.example.com",
2626+ },
2727+ {
2828+ name: "HTTP with non-standard port",
2929+ publicURL: "http://hold.example.com:8080",
3030+ expectedDID: "did:web:hold.example.com:8080",
3131+ },
3232+ {
3333+ name: "HTTPS with non-standard port",
3434+ publicURL: "https://hold.example.com:8443",
3535+ expectedDID: "did:web:hold.example.com:8443",
3636+ },
3737+ {
3838+ name: "localhost with port",
3939+ publicURL: "http://localhost:8080",
4040+ expectedDID: "did:web:localhost:8080",
4141+ },
4242+ {
4343+ name: "HTTP with explicit port 80",
4444+ publicURL: "http://hold.example.com:80",
4545+ expectedDID: "did:web:hold.example.com",
4646+ },
4747+ {
4848+ name: "HTTPS with explicit port 443",
4949+ publicURL: "https://hold.example.com:443",
5050+ expectedDID: "did:web:hold.example.com",
5151+ },
5252+ {
5353+ name: "subdomain",
5454+ publicURL: "https://hold1.atcr.io",
5555+ expectedDID: "did:web:hold1.atcr.io",
5656+ },
5757+ }
5858+5959+ for _, tt := range tests {
6060+ t.Run(tt.name, func(t *testing.T) {
6161+ did := GenerateDIDFromURL(tt.publicURL)
6262+ if did != tt.expectedDID {
6363+ t.Errorf("Expected DID %s, got %s", tt.expectedDID, did)
6464+ }
6565+ })
6666+ }
6767+}
6868+6969+// TestGenerateDIDFromURL_InvalidURL tests handling of invalid URLs
7070+func TestGenerateDIDFromURL_InvalidURL(t *testing.T) {
7171+ // Invalid URLs get parsed with empty hostname, which defaults to localhost
7272+ did := GenerateDIDFromURL("not a url")
7373+ if did != "did:web:localhost" {
7474+ t.Errorf("Expected did:web:localhost for invalid URL, got %s", did)
7575+ }
7676+}
7777+7878+// TestGenerateDIDDocument tests DID document generation
7979+func TestGenerateDIDDocument(t *testing.T) {
8080+ ctx := context.Background()
8181+ tmpDir := t.TempDir()
8282+8383+ dbPath := filepath.Join(tmpDir, "pds.db")
8484+ keyPath := filepath.Join(tmpDir, "signing-key")
8585+ publicURL := "https://hold.example.com"
8686+8787+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath)
8888+ if err != nil {
8989+ t.Fatalf("Failed to create PDS: %v", err)
9090+ }
9191+9292+ doc, err := pds.GenerateDIDDocument(publicURL)
9393+ if err != nil {
9494+ t.Fatalf("Failed to generate DID document: %v", err)
9595+ }
9696+9797+ // Verify required fields
9898+ if doc.ID != "did:web:hold.example.com" {
9999+ t.Errorf("Expected DID did:web:hold.example.com, got %s", doc.ID)
100100+ }
101101+102102+ // Verify context
103103+ if len(doc.Context) != 3 {
104104+ t.Errorf("Expected 3 context entries, got %d", len(doc.Context))
105105+ }
106106+107107+ expectedContexts := []string{
108108+ "https://www.w3.org/ns/did/v1",
109109+ "https://w3id.org/security/multikey/v1",
110110+ "https://w3id.org/security/suites/secp256k1-2019/v1",
111111+ }
112112+ for i, expected := range expectedContexts {
113113+ if doc.Context[i] != expected {
114114+ t.Errorf("Expected context[%d] = %s, got %s", i, expected, doc.Context[i])
115115+ }
116116+ }
117117+118118+ // Verify alsoKnownAs
119119+ if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
120120+ t.Errorf("Expected alsoKnownAs=['at://hold.example.com'], got %v", doc.AlsoKnownAs)
121121+ }
122122+123123+ // Verify verification method
124124+ if len(doc.VerificationMethod) != 1 {
125125+ t.Fatalf("Expected 1 verification method, got %d", len(doc.VerificationMethod))
126126+ }
127127+128128+ vm := doc.VerificationMethod[0]
129129+ if vm.ID != "did:web:hold.example.com#atproto" {
130130+ t.Errorf("Expected verification method ID did:web:hold.example.com#atproto, got %s", vm.ID)
131131+ }
132132+ if vm.Type != "Multikey" {
133133+ t.Errorf("Expected type Multikey, got %s", vm.Type)
134134+ }
135135+ if vm.Controller != "did:web:hold.example.com" {
136136+ t.Errorf("Expected controller did:web:hold.example.com, got %s", vm.Controller)
137137+ }
138138+ if vm.PublicKeyMultibase == "" {
139139+ t.Error("Expected non-empty publicKeyMultibase")
140140+ }
141141+142142+ // Verify authentication
143143+ if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
144144+ t.Errorf("Expected authentication=['did:web:hold.example.com#atproto'], got %v", doc.Authentication)
145145+ }
146146+147147+ // Verify services
148148+ if len(doc.Service) != 2 {
149149+ t.Fatalf("Expected 2 services, got %d", len(doc.Service))
150150+ }
151151+152152+ // Check PDS service
153153+ pdsService := doc.Service[0]
154154+ if pdsService.ID != "#atproto_pds" {
155155+ t.Errorf("Expected service ID #atproto_pds, got %s", pdsService.ID)
156156+ }
157157+ if pdsService.Type != "AtprotoPersonalDataServer" {
158158+ t.Errorf("Expected service type AtprotoPersonalDataServer, got %s", pdsService.Type)
159159+ }
160160+ if pdsService.ServiceEndpoint != publicURL {
161161+ t.Errorf("Expected service endpoint %s, got %s", publicURL, pdsService.ServiceEndpoint)
162162+ }
163163+164164+ // Check hold service
165165+ holdService := doc.Service[1]
166166+ if holdService.ID != "#atcr_hold" {
167167+ t.Errorf("Expected service ID #atcr_hold, got %s", holdService.ID)
168168+ }
169169+ if holdService.Type != "AtcrHoldService" {
170170+ t.Errorf("Expected service type AtcrHoldService, got %s", holdService.Type)
171171+ }
172172+ if holdService.ServiceEndpoint != publicURL {
173173+ t.Errorf("Expected service endpoint %s, got %s", publicURL, holdService.ServiceEndpoint)
174174+ }
175175+}
176176+177177+// TestGenerateDIDDocument_WithPort tests DID document with non-standard port
178178+func TestGenerateDIDDocument_WithPort(t *testing.T) {
179179+ ctx := context.Background()
180180+ tmpDir := t.TempDir()
181181+182182+ dbPath := filepath.Join(tmpDir, "pds.db")
183183+ keyPath := filepath.Join(tmpDir, "signing-key")
184184+ publicURL := "https://hold.example.com:8443"
185185+186186+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com:8443", publicURL, dbPath, keyPath)
187187+ if err != nil {
188188+ t.Fatalf("Failed to create PDS: %v", err)
189189+ }
190190+191191+ doc, err := pds.GenerateDIDDocument(publicURL)
192192+ if err != nil {
193193+ t.Fatalf("Failed to generate DID document: %v", err)
194194+ }
195195+196196+ // Verify DID includes port
197197+ if doc.ID != "did:web:hold.example.com:8443" {
198198+ t.Errorf("Expected DID did:web:hold.example.com:8443, got %s", doc.ID)
199199+ }
200200+201201+ // Verify alsoKnownAs includes port
202202+ if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
203203+ t.Errorf("Expected alsoKnownAs with port, got %s", doc.AlsoKnownAs[0])
204204+ }
205205+}
206206+207207+// TestMarshalDIDDocument tests DID document JSON marshaling
208208+func TestMarshalDIDDocument(t *testing.T) {
209209+ ctx := context.Background()
210210+ tmpDir := t.TempDir()
211211+212212+ dbPath := filepath.Join(tmpDir, "pds.db")
213213+ keyPath := filepath.Join(tmpDir, "signing-key")
214214+ publicURL := "https://hold.example.com"
215215+216216+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath)
217217+ if err != nil {
218218+ t.Fatalf("Failed to create PDS: %v", err)
219219+ }
220220+221221+ jsonBytes, err := pds.MarshalDIDDocument()
222222+ if err != nil {
223223+ t.Fatalf("Failed to marshal DID document: %v", err)
224224+ }
225225+226226+ // Verify it's valid JSON
227227+ var doc map[string]any
228228+ if err := json.Unmarshal(jsonBytes, &doc); err != nil {
229229+ t.Fatalf("Failed to unmarshal DID document JSON: %v", err)
230230+ }
231231+232232+ // Verify required fields
233233+ if id, ok := doc["id"].(string); !ok || id != "did:web:hold.example.com" {
234234+ t.Errorf("Expected id='did:web:hold.example.com', got %v", doc["id"])
235235+ }
236236+237237+ if _, ok := doc["@context"]; !ok {
238238+ t.Error("Expected @context field in JSON")
239239+ }
240240+241241+ if _, ok := doc["verificationMethod"]; !ok {
242242+ t.Error("Expected verificationMethod field in JSON")
243243+ }
244244+245245+ if _, ok := doc["service"]; !ok {
246246+ t.Error("Expected service field in JSON")
247247+ }
248248+249249+ // Verify pretty-printed (has indentation)
250250+ if len(jsonBytes) < 100 {
251251+ t.Error("Expected pretty-printed JSON to be reasonably sized")
252252+ }
253253+}
254254+255255+// TestGenerateDIDDocument_InvalidURL tests error handling
256256+func TestGenerateDIDDocument_InvalidURL(t *testing.T) {
257257+ ctx := context.Background()
258258+ tmpDir := t.TempDir()
259259+260260+ dbPath := filepath.Join(tmpDir, "pds.db")
261261+ keyPath := filepath.Join(tmpDir, "signing-key")
262262+ publicURL := "https://hold.example.com"
263263+264264+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath)
265265+ if err != nil {
266266+ t.Fatalf("Failed to create PDS: %v", err)
267267+ }
268268+269269+ // Try to generate DID document with invalid URL
270270+ _, err = pds.GenerateDIDDocument("ht!tp://invalid url")
271271+ if err == nil {
272272+ t.Error("Expected error for invalid URL, got nil")
273273+ }
274274+}
+10-10
pkg/hold/pds/events.go
···1919 eventSeq int64
2020 eventHistory []HistoricalEvent // Ring buffer for cursor backfill
2121 maxHistory int
2222- holdDID string // DID of the hold for setting repo field
2222+ holdDID string // DID of the hold for setting repo field
2323}
24242525// Subscriber represents a WebSocket client subscribed to the firehose
···37373838// RepoCommitEvent represents a #commit event in subscribeRepos
3939type RepoCommitEvent struct {
4040- Seq int64 `json:"seq" cborgen:"seq"`
4141- Repo string `json:"repo" cborgen:"repo"`
4242- Commit string `json:"commit" cborgen:"commit"` // CID string
4343- Rev string `json:"rev" cborgen:"rev"`
4444- Since *string `json:"since,omitempty" cborgen:"since,omitempty"`
4545- Blocks []byte `json:"blocks" cborgen:"blocks"` // CAR slice bytes
4646- Ops []*atproto.SyncSubscribeRepos_RepoOp `json:"ops" cborgen:"ops"`
4747- Time string `json:"time" cborgen:"time"`
4848- Type string `json:"$type" cborgen:"$type"` // Always "#commit"
4040+ Seq int64 `json:"seq" cborgen:"seq"`
4141+ Repo string `json:"repo" cborgen:"repo"`
4242+ Commit string `json:"commit" cborgen:"commit"` // CID string
4343+ Rev string `json:"rev" cborgen:"rev"`
4444+ Since *string `json:"since,omitempty" cborgen:"since,omitempty"`
4545+ Blocks []byte `json:"blocks" cborgen:"blocks"` // CAR slice bytes
4646+ Ops []*atproto.SyncSubscribeRepos_RepoOp `json:"ops" cborgen:"ops"`
4747+ Time string `json:"time" cborgen:"time"`
4848+ Type string `json:"$type" cborgen:"$type"` // Always "#commit"
4949}
50505151// NewEventBroadcaster creates a new event broadcaster
···55// Reason: The indigo library is unmaintained and contains a critical bug in UpdateRecord
66//
77// Modifications from original:
88-// - Changed package from 'repomgr' to 'pds' for integration with hold service
99-// - Fixed UpdateRecord bug (line 263): Changed r.PutRecord to r.UpdateRecord
1010-// (UpdateRecord was incorrectly calling PutRecord, causing incorrect MST operations)
1111-// - Removed 5 Prometheus metrics calls (openAndSigCheckDuration, calcDiffDuration,
1212-// writeCarSliceDuration, repoOpsImported) as metrics are not used in this project
1313-// - Added PutRecord method (lines 309-381) for creating records with explicit rkeys
1414-// (like CreateRecord but with specified rkey instead of auto-generated TID)
1515-// Based on streamplace/indigo implementation
88+// - Changed package from 'repomgr' to 'pds' for integration with hold service
99+// - Fixed UpdateRecord bug (line 263): Changed r.PutRecord to r.UpdateRecord
1010+// (UpdateRecord was incorrectly calling PutRecord, causing incorrect MST operations)
1111+// - Removed 5 Prometheus metrics calls (openAndSigCheckDuration, calcDiffDuration,
1212+// writeCarSliceDuration, repoOpsImported) as metrics are not used in this project
1313+// - Added PutRecord method (lines 309-381) for creating records with explicit rkeys
1414+// (like CreateRecord but with specified rkey instead of auto-generated TID)
1515+// Based on streamplace/indigo implementation
1616package pds
17171818import (
+53-25
pkg/hold/pds/xrpc.go
···2727 publicURL string
2828 blobStore BlobStore
2929 broadcaster *EventBroadcaster
3030+ httpClient HTTPClient // For testing - allows injecting mock HTTP client
3031}
31323233// BlobStore interface wraps the existing hold service storage operations
···4849 // Multipart upload operations (used for OCI container layers only)
4950 // StartMultipartUpload initiates a multipart upload, returns uploadID and mode
5051 StartMultipartUpload(ctx context.Context, digest string) (uploadID string, mode string, err error)
5151- // GetPartUploadURL returns a presigned URL for uploading a specific part
5252- GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (url string, err error)
5252+ // GetPartUploadURL returns structured upload info (URL + optional headers) for a specific part
5353+ GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*PartUploadInfo, error)
5354 // CompleteMultipartUpload finalizes a multipart upload
5455 CompleteMultipartUpload(ctx context.Context, uploadID string, parts []PartInfo) error
5556 // AbortMultipartUpload cancels a multipart upload
···6465 ETag string `json:"etag"`
6566}
66676868+// PartUploadInfo contains structured information for uploading a part
6969+// Used for both S3 presigned URLs and buffered mode with headers
7070+type PartUploadInfo struct {
7171+ URL string `json:"url"` // URL to PUT the part to
7272+ Method string `json:"method,omitempty"` // HTTP method (usually "PUT")
7373+ Headers map[string]string `json:"headers,omitempty"` // Additional headers required for the request
7474+}
7575+6776// NewXRPCHandler creates a new XRPC handler
6868-func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore, broadcaster *EventBroadcaster) *XRPCHandler {
7777+func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore, broadcaster *EventBroadcaster, httpClient HTTPClient) *XRPCHandler {
6978 return &XRPCHandler{
7079 pds: pds,
7180 publicURL: publicURL,
7281 blobStore: blobStore,
7382 broadcaster: broadcaster,
8383+ httpClient: httpClient,
7484 }
7585}
7686···7888func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
7989 return func(w http.ResponseWriter, r *http.Request) {
8090 w.Header().Set("Access-Control-Allow-Origin", "*")
8181- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
8282- w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
9191+ w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS")
9292+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, DPoP, X-Upload-Id, X-Part-Number, X-ATCR-DID")
83938494 // Handle preflight OPTIONS requests
8595 if r.Method == http.MethodOptions {
···444454 }
445455446456 // Validate DPoP + OAuth and check authorization
447447- _, err := ValidateOwnerOrCrewAdmin(r, h.pds)
457457+ _, err := ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
448458 if err != nil {
449459 http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
450460 return
···733743734744 // Mode 3: Direct blob upload (ATProto-compliant)
735745 // Receives raw bytes, computes CID, stores via distribution driver
736736- // TODO: Authentication check
737737-738738- // Extract DID for ATProto blob storage (per-DID paths)
739739- did := r.URL.Query().Get("did")
740740- if did == "" {
741741- // TODO: Extract from auth context when authentication is implemented
742742- // For now, use hold's DID as fallback
743743- did = h.pds.DID()
746746+ // Requires admin-level access (captain or crew admin)
747747+ user, err := ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
748748+ if err != nil {
749749+ http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
750750+ return
744751 }
752752+753753+ // Use authenticated user's DID for ATProto blob storage (per-DID paths)
754754+ did := user.DID
745755746756 // Upload blob directly - blobStore will compute CID and store
747757 blobCID, size, err := h.blobStore.UploadBlob(r.Context(), did, r.Body)
···770780func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request, uploadID, partNumberStr string) {
771781 ctx := r.Context()
772782783783+ // Validate blob write access
784784+ // This checks DPoP + OAuth tokens and verifies user is captain or crew with blob:write permission
785785+ _, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient)
786786+ if err != nil {
787787+ http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
788788+ return
789789+ }
790790+773791 // Parse part number
774792 partNumber, err := strconv.Atoi(partNumberStr)
775793 if err != nil {
···816834 return
817835 }
818836837837+ // Validate blob write access for all multipart operations
838838+ // This checks DPoP + OAuth tokens and verifies user is captain or crew with blob:write permission
839839+ user, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient)
840840+ if err != nil {
841841+ http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
842842+ return
843843+ }
844844+819845 // Route based on action
820846 switch req.Action {
821847 case "start":
···844870 return
845871 }
846872847847- // Extract DID from query or header (for authorization)
848848- did := r.URL.Query().Get("did")
849849- if did == "" {
850850- did = r.Header.Get("X-ATCR-DID")
851851- }
852852-853853- url, err := h.blobStore.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, did)
873873+ uploadInfo, err := h.blobStore.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, user.DID)
854874 if err != nil {
855875 http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
856876 return
857877 }
858878859879 w.Header().Set("Content-Type", "application/json")
860860- json.NewEncoder(w).Encode(map[string]any{
861861- "url": url,
862862- })
880880+ json.NewEncoder(w).Encode(uploadInfo)
863881864882 case "complete":
865883 // Complete multipart upload
···902920903921// HandleGetBlob wraps existing presigned download URL logic
904922// Supports both ATProto CIDs and OCI sha256 digests
923923+// Authorization: If captain.public = true, open to all. If false, requires crew with blob:read permission.
905924func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
906925 if r.Method != http.MethodGet && r.Method != http.MethodHead {
907926 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
···918937919938 if did != h.pds.DID() {
920939 http.Error(w, "invalid did", http.StatusBadRequest)
940940+ return
941941+ }
942942+943943+ // Validate blob read access
944944+ // If captain.public = true, returns nil (public access allowed)
945945+ // If captain.public = false, validates auth and checks for blob:read permission
946946+ _, err := ValidateBlobReadAccess(r, h.pds, h.httpClient)
947947+ if err != nil {
948948+ http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
921949 return
922950 }
923951···10351063 }
1036106410371065 // Validate DPoP + OAuth token from Authorization and DPoP headers
10381038- user, err := ValidateDPoPRequest(r)
10661066+ user, err := ValidateDPoPRequest(r, h.httpClient)
10391067 if err != nil {
10401068 http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
10411069 return
+45-20
pkg/hold/pds/xrpc_multipart_test.go
···77 "testing"
88)
991010+// addTestDPoPAuth adds DPoP authentication headers to a request for testing
1111+func addTestDPoPAuth(t *testing.T, req *http.Request, did string) {
1212+ t.Helper()
1313+ dpopHelper, err := NewDPoPTestHelper(did, "https://test-pds.example.com")
1414+ if err != nil {
1515+ t.Fatalf("Failed to create DPoP helper: %v", err)
1616+ }
1717+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
1818+ t.Fatalf("Failed to add DPoP to request: %v", err)
1919+ }
2020+}
2121+1022// ATCR-Specific Tests: Non-standard multipart upload extensions
1123//
1224// This file contains tests for ATCR's custom multipart upload extensions
···2840 }
29413042 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
4343+ // Add DPoP authentication - owner has blob:write permission
4444+ addTestDPoPAuth(t, req, "did:plc:testowner123")
3145 w := httptest.NewRecorder()
32463347 handler.HandleUploadBlob(w, req)
34484949+ // Should return 200 OK with upload metadata
3550 if w.Code != http.StatusOK {
3636- t.Errorf("Expected status 200, got %d", w.Code)
5151+ t.Errorf("Expected status 200 OK, got %d", w.Code)
3752 }
38533939- // Verify response contains uploadId and mode
4054 result := assertJSONResponse(t, w, http.StatusOK)
41554256 if uploadID, ok := result["uploadId"].(string); !ok || uploadID == "" {
···6276 }
63776478 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
7979+ addTestDPoPAuth(t, req, "did:plc:testowner123")
6580 w := httptest.NewRecorder()
66816782 handler.HandleUploadBlob(w, req)
···80958196 uploadID := "test-upload-123"
8297 partNumber := 1
8383- did := "did:plc:testuser"
9898+ expectedDID := "did:plc:testowner123" // DID from authenticated user
849985100 body := map[string]any{
86101 "action": "part",
···88103 "partNumber": partNumber,
89104 }
901059191- req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob?did="+did, body)
106106+ req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
107107+ addTestDPoPAuth(t, req, expectedDID)
92108 w := httptest.NewRecorder()
9310994110 handler.HandleUploadBlob(w, req)
95111112112+ // Should return 200 OK with presigned URL
96113 if w.Code != http.StatusOK {
9797- t.Errorf("Expected status 200, got %d", w.Code)
114114+ t.Errorf("Expected status 200 OK, got %d", w.Code)
98115 }
99116100100- // Verify response contains URL
101117 result := assertJSONResponse(t, w, http.StatusOK)
102118103119 if url, ok := result["url"].(string); !ok || url == "" {
104120 t.Error("Expected url string in response")
105121 }
106122107107- // Verify blob store was called
123123+ // Verify blob store was called with authenticated user's DID
108124 if len(blobStore.partURLCalls) != 1 {
109125 t.Fatalf("Expected GetPartUploadURL to be called once")
110126 }
111127 call := blobStore.partURLCalls[0]
112112- if call.uploadID != uploadID || call.partNumber != partNumber || call.did != did {
128128+ if call.uploadID != uploadID || call.partNumber != partNumber || call.did != expectedDID {
113129 t.Errorf("Expected GetPartUploadURL(%s, %d, %s), got (%s, %d, %s)",
114114- uploadID, partNumber, did, call.uploadID, call.partNumber, call.did)
130130+ uploadID, partNumber, expectedDID, call.uploadID, call.partNumber, call.did)
115131 }
116132}
117133···150166 for _, tt := range tests {
151167 t.Run(tt.name, func(t *testing.T) {
152168 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
169169+ addTestDPoPAuth(t, req, "did:plc:testowner123")
153170 w := httptest.NewRecorder()
154171155172 handler.HandleUploadBlob(w, req)
···181198 }
182199183200 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
201201+ addTestDPoPAuth(t, req, "did:plc:testowner123")
184202 w := httptest.NewRecorder()
185203186204 handler.HandleUploadBlob(w, req)
187205206206+ // Should return 200 OK with completion status
188207 if w.Code != http.StatusOK {
189189- t.Errorf("Expected status 200, got %d", w.Code)
208208+ t.Errorf("Expected status 200 OK, got %d", w.Code)
190209 }
191210192192- // Verify response
193211 result := assertJSONResponse(t, w, http.StatusOK)
194212195213 if status, ok := result["status"].(string); !ok || status != "completed" {
···237255 for _, tt := range tests {
238256 t.Run(tt.name, func(t *testing.T) {
239257 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
258258+ addTestDPoPAuth(t, req, "did:plc:testowner123")
240259 w := httptest.NewRecorder()
241260242261 handler.HandleUploadBlob(w, req)
···263282 }
264283265284 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
285285+ addTestDPoPAuth(t, req, "did:plc:testowner123")
266286 w := httptest.NewRecorder()
267287268288 handler.HandleUploadBlob(w, req)
269289290290+ // Should return 200 OK with abort status
270291 if w.Code != http.StatusOK {
271271- t.Errorf("Expected status 200, got %d", w.Code)
292292+ t.Errorf("Expected status 200 OK, got %d", w.Code)
272293 }
273294274274- // Verify response
275295 result := assertJSONResponse(t, w, http.StatusOK)
276296277297 if status, ok := result["status"].(string); !ok || status != "aborted" {
···293313 }
294314295315 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
316316+ addTestDPoPAuth(t, req, "did:plc:testowner123")
296317 w := httptest.NewRecorder()
297318298319 handler.HandleUploadBlob(w, req)
···316337 req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data))
317338 req.Header.Set("X-Upload-Id", uploadID)
318339 req.Header.Set("X-Part-Number", partNumber)
340340+ addTestDPoPAuth(t, req, "did:plc:testowner123")
319341 w := httptest.NewRecorder()
320342321343 handler.HandleUploadBlob(w, req)
322344345345+ // Should return 200 OK with ETag
323346 if w.Code != http.StatusOK {
324324- t.Errorf("Expected status 200, got %d", w.Code)
347347+ t.Errorf("Expected status 200 OK, got %d", w.Code)
325348 }
326349327327- // Verify response contains ETag
328350 result := assertJSONResponse(t, w, http.StatusOK)
329351330352 if etag, ok := result["etag"].(string); !ok || etag == "" {
···347369 handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
348370349371 tests := []struct {
350350- name string
351351- uploadID string
352352- partNumber string
353353- setUploadID bool
354354- setPartNumber bool
372372+ name string
373373+ uploadID string
374374+ partNumber string
375375+ setUploadID bool
376376+ setPartNumber bool
355377 }{
356378 {
357379 name: "missing both headers",
···381403 if tt.setPartNumber {
382404 req.Header.Set("X-Part-Number", tt.partNumber)
383405 }
406406+ addTestDPoPAuth(t, req, "did:plc:testowner123")
384407 w := httptest.NewRecorder()
385408386409 handler.HandleUploadBlob(w, req)
···399422 req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("data")))
400423 req.Header.Set("X-Upload-Id", "test-123")
401424 req.Header.Set("X-Part-Number", "not-a-number")
425425+ addTestDPoPAuth(t, req, "did:plc:testowner123")
402426 w := httptest.NewRecorder()
403427404428 handler.HandleUploadBlob(w, req)
···417441 }
418442419443 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
444444+ addTestDPoPAuth(t, req, "did:plc:testowner123")
420445 w := httptest.NewRecorder()
421446422447 handler.HandleUploadBlob(w, req)
+138-41
pkg/hold/pds/xrpc_test.go
···5454 t.Fatalf("Failed to bootstrap PDS: %v", err)
5555 }
56565757- // Create XRPC handler
5858- handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil)
5757+ // Create mock PDS client for DPoP validation
5858+ mockClient := &mockPDSClient{}
5959+6060+ // Create XRPC handler with mock HTTP client
6161+ handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
59626063 return handler, ctx
6164}
···652655// TestHandleListRecords_EmptyCollection tests listing empty collection
653656func TestHandleListRecords_EmptyCollection(t *testing.T) {
654657 pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet
655655- handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil)
658658+ mockClient := &mockPDSClient{}
659659+ handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
656660657661 // Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members)
658662 err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
···744748 }
745749746750 req := makeXRPCPostRequest("/xrpc/com.atproto.repo.deleteRecord", body)
751751+752752+ // Add DPoP authentication - owner has admin permission to delete crew
753753+ ownerDID := "did:plc:testowner123"
754754+ dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
755755+ if err != nil {
756756+ t.Fatalf("Failed to create DPoP helper: %v", err)
757757+ }
758758+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
759759+ t.Fatalf("Failed to add DPoP to request: %v", err)
760760+ }
761761+747762 w := httptest.NewRecorder()
748763749749- // Note: This test will fail auth check since we're not providing DPoP tokens
750750- // For now, we're testing the request parsing and response structure
751751- // A real implementation would need proper auth mocking
752764 handler.HandleDeleteRecord(w, req)
753765754754- // We expect 403 Forbidden due to missing auth
755755- // This tests that the endpoint is parsing JSON body correctly
756756- if w.Code != http.StatusForbidden {
757757- // If somehow auth passes (shouldn't in this test), verify response structure
758758- if w.Code == http.StatusOK {
759759- result := assertJSONResponse(t, w, http.StatusOK)
766766+ // Should return 200 OK with commit metadata
767767+ if w.Code != http.StatusOK {
768768+ t.Errorf("Expected status 200 OK, got %d", w.Code)
769769+ }
760770761761- // Per spec, response should have commit object
762762- if commit, ok := result["commit"].(map[string]any); !ok {
763763- t.Error("Expected commit object in response")
764764- } else {
765765- if cid, ok := commit["cid"].(string); !ok || cid == "" {
766766- t.Error("Expected cid in commit object")
767767- }
768768- if rev, ok := commit["rev"].(string); !ok || rev == "" {
769769- t.Error("Expected rev in commit object")
770770- }
771771- }
771771+ result := assertJSONResponse(t, w, http.StatusOK)
772772+773773+ // Per spec, response should have commit object
774774+ if commit, ok := result["commit"].(map[string]any); !ok {
775775+ t.Error("Expected commit object in response")
776776+ } else {
777777+ if cid, ok := commit["cid"].(string); !ok || cid == "" {
778778+ t.Error("Expected cid in commit object")
779779+ }
780780+ if rev, ok := commit["rev"].(string); !ok || rev == "" {
781781+ t.Error("Expected rev in commit object")
772782 }
773783 }
774784}
···902912// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-list-repos
903913func TestHandleListRepos_EmptyRepo(t *testing.T) {
904914 pds, ctx := setupTestPDS(t) // Don't bootstrap
905905- handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil)
915915+ mockClient := &mockPDSClient{}
916916+ handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
906917907918 // setupTestPDS creates the PDS/database but doesn't initialize the repo
908919 // Check if implementation returns repos before initialization
···13341345 partUploadError error
1335134613361347 // Track calls
13371337- downloadCalls []string // Track digests requested for download
13381338- uploadCalls []string // Track digests requested for upload
13391339- uploadBlobCalls []uploadBlobCall // Track direct blob uploads
13401340- startCalls []string // Track digests for multipart start
13411341- partURLCalls []partURLCall
13421342- completeCalls []string
13431343- abortCalls []string
13441344- partUploadCalls []partUploadCall
13481348+ downloadCalls []string // Track digests requested for download
13491349+ uploadCalls []string // Track digests requested for upload
13501350+ uploadBlobCalls []uploadBlobCall // Track direct blob uploads
13511351+ startCalls []string // Track digests for multipart start
13521352+ partURLCalls []partURLCall
13531353+ completeCalls []string
13541354+ abortCalls []string
13551355+ partUploadCalls []partUploadCall
13451356}
1346135713471358type uploadBlobCall struct {
···14191430 return "test-upload-id", "s3native", nil
14201431}
1421143214221422-func (m *mockBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (string, error) {
14331433+func (m *mockBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*PartUploadInfo, error) {
14231434 m.partURLCalls = append(m.partURLCalls, partURLCall{uploadID, partNumber, did})
14241435 if m.partURLError != nil {
14251425- return "", m.partURLError
14361436+ return nil, m.partURLError
14261437 }
14271427- return "https://s3.example.com/part/" + uploadID, nil
14381438+ return &PartUploadInfo{
14391439+ URL: "https://s3.example.com/part/" + uploadID,
14401440+ Method: "PUT",
14411441+ }, nil
14281442}
1429144314301444func (m *mockBlobStore) CompleteMultipartUpload(ctx context.Context, uploadID string, parts []PartInfo) error {
···14511465 return "test-etag-" + uploadID, nil
14521466}
1453146714541454-// setupTestXRPCHandlerWithBlobs creates handler with mock blob store
14681468+// setupTestXRPCHandlerWithBlobs creates handler with mock blob store and mock PDS client
14551469func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockBlobStore, context.Context) {
14561470 t.Helper()
14571471···14881502 // Create mock blob store
14891503 blobStore := newMockBlobStore()
1490150414911491- // Create XRPC handler with mock blob store
14921492- handler := NewXRPCHandler(pds, "https://hold.example.com", blobStore, nil)
15051505+ // Create mock PDS client for DPoP validation
15061506+ mockClient := &mockPDSClient{}
15071507+15081508+ // Create XRPC handler with mock blob store and mock HTTP client
15091509+ handler := NewXRPCHandler(pds, "https://hold.example.com", blobStore, nil, mockClient)
1493151014941511 return handler, blobStore, ctx
14951512}
···15071524 // Test standard single blob upload (POST with raw bytes)
15081525 req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(blobData))
15091526 req.Header.Set("Content-Type", "application/octet-stream")
15271527+15281528+ // Add DPoP authentication - owner has admin permission for blob upload
15291529+ ownerDID := "did:plc:testowner123"
15301530+ dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
15311531+ if err != nil {
15321532+ t.Fatalf("Failed to create DPoP helper: %v", err)
15331533+ }
15341534+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
15351535+ t.Fatalf("Failed to add DPoP to request: %v", err)
15361536+ }
15371537+15101538 w := httptest.NewRecorder()
1511153915121540 handler.HandleUploadBlob(w, req)
···15591587 // Empty blob should succeed (edge case)
15601588 req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte{}))
15611589 req.Header.Set("Content-Type", "application/octet-stream")
15901590+15911591+ // Add DPoP authentication
15921592+ ownerDID := "did:plc:testowner123"
15931593+ dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
15941594+ if err != nil {
15951595+ t.Fatalf("Failed to create DPoP helper: %v", err)
15961596+ }
15971597+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
15981598+ t.Fatalf("Failed to add DPoP to request: %v", err)
15991599+ }
16001600+15621601 w := httptest.NewRecorder()
1563160215641603 handler.HandleUploadBlob(w, req)
1565160415661566- // Should succeed with empty blob
16051605+ // Should return 200 OK for empty blob (edge case)
15671606 if w.Code != http.StatusOK {
15681568- t.Errorf("Expected status 200, got %d", w.Code)
16071607+ t.Errorf("Expected status 200 OK for empty blob, got %d", w.Code)
15691608 }
1570160915711610 // Verify blob store was called with 0 bytes
···1600163916011640 req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("test data")))
16021641 req.Header.Set("Content-Type", "application/octet-stream")
16421642+16431643+ // Add DPoP authentication
16441644+ ownerDID := "did:plc:testowner123"
16451645+ dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
16461646+ if err != nil {
16471647+ t.Fatalf("Failed to create DPoP helper: %v", err)
16481648+ }
16491649+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
16501650+ t.Fatalf("Failed to add DPoP to request: %v", err)
16511651+ }
16521652+16031653 w := httptest.NewRecorder()
1604165416051655 handler.HandleUploadBlob(w, req)
1606165616571657+ // Should get 500 Internal Server Error for blob store error
16071658 if w.Code != http.StatusInternalServerError {
16081608- t.Errorf("Expected status 500, got %d", w.Code)
16591659+ t.Errorf("Expected status 500 for blob store error, got %d", w.Code)
16091660 }
16101661}
16111662···17751826 t.Errorf("Expected status 500, got %d", w.Code)
17761827 }
17771828}
18291829+18301830+// TestHandleGetBlobCORSHeaders tests that CORS headers are set for blob downloads
18311831+// // Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
18321832+func TestHandleGetBlob_CORSHeaders(t *testing.T) {
18331833+ handler, _, ctx := setupTestXRPCHandlerWithBlobs(t)
18341834+18351835+ // Make hold public
18361836+ _, err := handler.pds.UpdateCaptainRecord(ctx, true, false)
18371837+ if err != nil {
18381838+ t.Fatalf("Failed to update captain: %v", err)
18391839+ }
18401840+18411841+ holdDID := "did:web:hold.example.com"
18421842+ cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
18431843+ url := fmt.Sprintf("/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", holdDID, cid)
18441844+18451845+ // Test GET request
18461846+ req := httptest.NewRequest(http.MethodGet, url, nil)
18471847+ w := httptest.NewRecorder()
18481848+18491849+ // Wrap with CORS middleware
18501850+ corsHandler := corsMiddleware(handler.HandleGetBlob)
18511851+ corsHandler(w, req)
18521852+18531853+ // Verify CORS headers are present
18541854+ if origin := w.Header().Get("Access-Control-Allow-Origin"); origin != "*" {
18551855+ t.Errorf("Expected Access-Control-Allow-Origin: *, got %s", origin)
18561856+ }
18571857+18581858+ // Test OPTIONS preflight
18591859+ req2 := httptest.NewRequest(http.MethodOptions, url, nil)
18601860+ w2 := httptest.NewRecorder()
18611861+18621862+ corsHandler(w2, req2)
18631863+18641864+ if w2.Code != http.StatusOK {
18651865+ t.Errorf("Expected OPTIONS to return 200, got %d", w2.Code)
18661866+ }
18671867+18681868+ methods := w2.Header().Get("Access-Control-Allow-Methods")
18691869+ if !strings.Contains(methods, "GET") || !strings.Contains(methods, "HEAD") {
18701870+ t.Errorf("Expected Access-Control-Allow-Methods to include GET and HEAD, got %s", methods)
18711871+ }
18721872+18731873+ t.Logf("✓ CORS headers correctly set for blob downloads")
18741874+}
+15-6
pkg/hold/service.go
···2222type HoldService struct {
2323 driver storagedriver.StorageDriver
2424 config *Config
2525- s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
2626- bucket string // S3 bucket name
2727- s3PathPrefix string // S3 path prefix (if any)
2828- MultipartMgr *MultipartManager // Exported for access in route handlers
2929- pds HoldPDSInterface // Embedded PDS for captain/crew records
3030- authorizer auth.HoldAuthorizer // Authorizer for access control
2525+ s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
2626+ bucket string // S3 bucket name
2727+ s3PathPrefix string // S3 path prefix (if any)
2828+ MultipartMgr *MultipartManager // Exported for access in route handlers
2929+ pds HoldPDSInterface // Embedded PDS for captain/crew records
3030+ authorizer auth.HoldAuthorizer // Authorizer for access control
3131}
3232+3333+// PresignedURLOperation defines the type of presigned URL operation
3434+type PresignedURLOperation string
3535+3636+const (
3737+ OperationGet PresignedURLOperation = "GET"
3838+ OperationHead PresignedURLOperation = "HEAD"
3939+ OperationPut PresignedURLOperation = "PUT"
4040+)
32413342// NewHoldService creates a new hold service
3443// holdPDS must be a *pds.HoldPDS but we use any to avoid import cycle
+45-10
pkg/hold/storage.go
···77777878 // Check if presigned URLs are disabled
7979 if s.config.Server.DisablePresignedURLs {
8080- log.Printf("Presigned URLs disabled, using proxy URL")
8181- return s.getProxyURL(digest, did), nil
8080+ log.Printf("Presigned URLs disabled, using XRPC endpoint")
8181+ url := s.getProxyURL(digest, did, operation)
8282+ if url == "" {
8383+ return "", fmt.Errorf("XRPC proxy not supported for PUT operations - use multipart upload")
8484+ }
8585+ return url, nil
8286 }
83878488 // Generate presigned URL if S3 client is available
···122126 url, err := req.Presign(15 * time.Minute)
123127 if err != nil {
124128 log.Printf("[getPresignedURL] Presign FAILED for %s: %v", operation, err)
125125- log.Printf(" Falling back to proxy URL")
126126- return s.getProxyURL(digest, did), nil
129129+ log.Printf(" Falling back to XRPC endpoint")
130130+ proxyURL := s.getProxyURL(digest, did, operation)
131131+ if proxyURL == "" {
132132+ return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")
133133+ }
134134+ return proxyURL, nil
127135 }
128136129137 return url, nil
130138 }
131139132132- // Fallback: return proxy URL through this service
133133- return s.getProxyURL(digest, did), nil
140140+ // Fallback: return XRPC endpoint through this service
141141+ proxyURL := s.getProxyURL(digest, did, operation)
142142+ if proxyURL == "" {
143143+ return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
144144+ }
145145+ return proxyURL, nil
146146+}
147147+148148+// getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable)
149149+// For GET/HEAD operations, returns the XRPC getBlob endpoint
150150+// For PUT operations, this fallback is no longer supported - use multipart upload instead
151151+func (s *HoldService) getProxyURL(digest, did string, operation PresignedURLOperation) string {
152152+ // For read operations, use XRPC getBlob endpoint
153153+ if operation == OperationGet || operation == OperationHead {
154154+ // Generate hold DID from public URL
155155+ holdDID := s.getHoldDID()
156156+ return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
157157+ s.config.Server.PublicURL, holdDID, digest)
158158+ }
159159+160160+ // For PUT operations, proxy fallback is not supported with XRPC
161161+ // Clients should use multipart upload flow via com.atproto.repo.uploadBlob
162162+ return ""
134163}
135164136136-// getProxyURL returns a proxy URL for blob operations (fallback when presigned URLs unavailable)
137137-func (s *HoldService) getProxyURL(digest, did string) string {
138138- // All operations use the same proxy endpoint
139139- return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
165165+// getHoldDID generates a did:web from the hold's public URL
166166+func (s *HoldService) getHoldDID() string {
167167+ // Convert URL to did:web format
168168+ // https://hold01.atcr.io → did:web:hold01.atcr.io
169169+ url := s.config.Server.PublicURL
170170+ url = strings.TrimPrefix(url, "https://")
171171+ url = strings.TrimPrefix(url, "http://")
172172+ url = strings.Split(url, "/")[0] // Remove path
173173+ url = strings.Split(url, ":")[0] // Remove port
174174+ return fmt.Sprintf("did:web:%s", url)
140175}