···16161717# Storage driver type (s3, filesystem)
1818# Default: s3
1919-STORAGE_DRIVER=s3
1919+STORAGE_DRIVER=filesystem
20202121# For S3/Storj/Minio:
2222AWS_ACCESS_KEY_ID=your_access_key
···5050# Your ATProto DID (REQUIRED for registration)
5151# Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social
5252#
5353-# On first run with HOLD_CREW_OWNER set:
5353+# On first run with HOLD_OWNER set:
5454# 1. Hold service will print an OAuth URL to the logs
5555# 2. Visit the URL in your browser to authorize
5656# 3. Hold service creates hold + crew records in your PDS
···6060# - Hold service checks if already registered
6161# - Skips OAuth if records exist
6262#
6363-HOLD_CREW_OWNER=did:plc:your-did-here
6363+HOLD_OWNER=did:plc:your-did-here
+4-4
CLAUDE.md
···4040export HOLD_PUBLIC_URL=http://127.0.0.1:8080
4141export STORAGE_DRIVER=filesystem
4242export STORAGE_ROOT_DIR=/tmp/atcr-hold
4343-export HOLD_CREW_OWNER=did:plc:your-did-here
4343+export HOLD_OWNER=did:plc:your-did-here
4444./atcr-hold
4545# Check logs for OAuth URL, visit in browser to complete registration
4646```
···299299- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
300300- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
301301- `HOLD_PUBLIC` - Allow public reads (default: false)
302302-- `HOLD_CREW_OWNER` - DID for auto-registration (optional)
302302+- `HOLD_OWNER` - DID for auto-registration (optional)
303303304304**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc.
305305···386386- Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*`
387387- Authorization: Based on PDS records (`hold.public`, crew records)
388388- Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC`
389389-- Auto-registration: `HOLD_CREW_OWNER` (optional)
389389+- Auto-registration: `HOLD_OWNER` (optional)
390390391391**Credential Helper**:
392392- Token storage: `~/.atcr/oauth-token.json`
···439439440440**Adding BYOS support for a user**:
4414411. User sets environment variables (storage credentials, public URL)
442442-2. User runs hold service with `HOLD_CREW_OWNER` set - auto-registration via OAuth
442442+2. User runs hold service with `HOLD_OWNER` set - auto-registration via OAuth
4434433. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS
4444444. AppView automatically queries PDS and routes blobs to user's storage
4454455. No AppView changes needed - fully decentralized
+1-1
SPEC.md
···431431432432Unified Model
433433434434- Every hold service requires HOLD_CREW_OWNER:
434434+ Every hold service requires HOLD_OWNER:
435435 - Owner's PDS has the io.atcr.hold record
436436 - Owner's PDS has all io.atcr.hold.crew records
437437 - Authorization is always governed by PDS records
+225-91
cmd/hold/main.go
···34343535// RegistrationConfig defines auto-registration settings
3636type RegistrationConfig struct {
3737- // OwnerDID is the owner's ATProto DID (from env: HOLD_CREW_OWNER)
3737+ // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER)
3838 // If set, auto-registration is enabled
3939 OwnerDID string `yaml:"owner_did"`
4040}
···5555 // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC)
5656 Public bool `yaml:"public"`
57575858+ // TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE)
5959+ TestMode bool `yaml:"test_mode"`
6060+5861 // ReadTimeout for HTTP requests
5962 ReadTimeout time.Duration `yaml:"read_timeout"`
6063···64676568// HoldService provides presigned URLs for blob storage in a hold
6669type HoldService struct {
6767- driver storagedriver.StorageDriver
6868- config *Config
7070+ driver storagedriver.StorageDriver
7171+ config *Config
7272+ oauthCodeCh chan string
7373+ oauthErrCh chan error
7474+ oauthState string
7575+ codeVerifier string
6976}
70777178// NewHoldService creates a new hold service
···7885 }
79868087 return &HoldService{
8181- driver: driver,
8282- config: cfg,
8888+ driver: driver,
8989+ config: cfg,
9090+ oauthCodeCh: make(chan string, 1),
9191+ oauthErrCh: make(chan error, 1),
8392 }, nil
8493}
8594···183192 ctx := context.Background()
184193 expiry := time.Now().Add(15 * time.Minute)
185194186186- url, err := s.getUploadURL(ctx, req.Digest, req.Size)
195195+ url, err := s.getUploadURL(ctx, req.Digest, req.Size, req.DID)
187196 if err != nil {
188197 http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
189198 return
···200209201210// HandleProxyGet proxies a blob download through the service
202211func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) {
203203- if r.Method != http.MethodGet {
212212+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
204213 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
205214 return
206215 }
···228237 return
229238 }
230239231231- // Read blob from storage
232240 ctx := r.Context()
233233- path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
241241+ path := blobPath(digest)
234242243243+ // For HEAD requests, just check if blob exists
244244+ if r.Method == http.MethodHead {
245245+ stat, err := s.driver.Stat(ctx, path)
246246+ if err != nil {
247247+ http.Error(w, "blob not found", http.StatusNotFound)
248248+ return
249249+ }
250250+ w.Header().Set("Content-Type", "application/octet-stream")
251251+ w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
252252+ w.WriteHeader(http.StatusOK)
253253+ return
254254+ }
255255+256256+ // For GET requests, read and return the blob
235257 content, err := s.driver.GetContent(ctx, path)
236258 if err != nil {
237259 http.Error(w, "blob not found", http.StatusNotFound)
···244266245267// HandleProxyPut proxies a blob upload through the service
246268func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) {
269269+ log.Printf("HandleProxyPut: method=%s, path=%s, query=%s", r.Method, r.URL.Path, r.URL.RawQuery)
270270+247271 if r.Method != http.MethodPut {
248272 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
249273 return
···260284 did = r.Header.Get("X-ATCR-DID")
261285 }
262286287287+ log.Printf("HandleProxyPut: digest=%s, did=%s", digest, did)
288288+263289 // Authorize WRITE access
264264- if !s.isAuthorizedWrite(did) {
290290+ authorized := s.isAuthorizedWrite(did)
291291+ log.Printf("HandleProxyPut: authorization check: did=%s, authorized=%v", did, authorized)
292292+ if !authorized {
265293 if did == "" {
294294+ log.Printf("HandleProxyPut: rejecting - no DID provided")
266295 http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
267296 } else {
297297+ log.Printf("HandleProxyPut: rejecting - DID not authorized for write")
268298 http.Error(w, "forbidden: write access denied", http.StatusForbidden)
269299 }
270300 return
···272302273303 // Write blob to storage
274304 ctx := r.Context()
275275- path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
305305+ path := blobPath(digest)
276306277307 content, err := io.ReadAll(r.Body)
278308 if err != nil {
309309+ log.Printf("HandleProxyPut: failed to read body: %v", err)
279310 http.Error(w, "failed to read body", http.StatusBadRequest)
280311 return
281312 }
282313314314+ log.Printf("HandleProxyPut: writing blob to path=%s, size=%d bytes", path, len(content))
283315 if err := s.driver.PutContent(ctx, path, content); err != nil {
316316+ log.Printf("HandleProxyPut: failed to store blob: %v", err)
284317 http.Error(w, "failed to store blob", http.StatusInternalServerError)
285318 return
286319 }
287320321321+ log.Printf("HandleProxyPut: successfully stored blob digest=%s, size=%d", digest, len(content))
288322 w.WriteHeader(http.StatusCreated)
289323}
290324···400434// getDownloadURL generates a download URL for a blob
401435func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) {
402436 // Check if blob exists
403403- path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
437437+ path := blobPath(digest)
404438 _, err := s.driver.Stat(ctx, path)
405439 if err != nil {
406440 return "", fmt.Errorf("blob not found: %w", err)
···408442409443 // For drivers that support presigned URLs (S3), use those
410444 // For now, return a proxy URL through this service
411411- return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil
445445+ return fmt.Sprintf("%s/blobs/%s", s.config.Server.PublicURL, digest), nil
412446}
413447414448// getUploadURL generates an upload URL for a blob
415415-func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64) (string, error) {
449449+// Note: This is called from HandlePutPresignedURL which has the DID in the request
450450+func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) {
416451 // For drivers that support presigned URLs (S3), use those
417417- // For now, return a proxy URL through this service
418418- return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil
452452+ // For now, return a proxy URL through this service with DID for authorization
453453+ return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil
419454}
420455421456// RegisterRequest represents a request to register this hold in a user's PDS
···515550 })
516551}
517552553553+// HandleOAuthCallback handles OAuth callback from authorization server
554554+func (s *HoldService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
555555+ code := r.URL.Query().Get("code")
556556+ receivedState := r.URL.Query().Get("state")
557557+558558+ if receivedState != s.oauthState {
559559+ s.oauthErrCh <- fmt.Errorf("invalid state parameter")
560560+ http.Error(w, "Invalid state", http.StatusBadRequest)
561561+ return
562562+ }
563563+564564+ if code == "" {
565565+ s.oauthErrCh <- fmt.Errorf("no authorization code received")
566566+ http.Error(w, "No code", http.StatusBadRequest)
567567+ return
568568+ }
569569+570570+ w.Header().Set("Content-Type", "text/html")
571571+ fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
572572+573573+ // Send code to registration flow
574574+ select {
575575+ case s.oauthCodeCh <- code:
576576+ default:
577577+ // Channel already has a value or nobody is listening
578578+ }
579579+}
580580+518581func main() {
519582 // Load configuration from environment variables
520583 cfg, err := loadConfigFromEnv()
···528591 log.Fatalf("Failed to create hold service: %v", err)
529592 }
530593531531- // Auto-register if owner DID is set
532532- if cfg.Registration.OwnerDID != "" {
533533- if err := service.AutoRegister(); err != nil {
534534- log.Printf("WARNING: Auto-registration failed: %v", err)
535535- log.Printf("You can register manually later using the /register endpoint")
536536- } else {
537537- log.Printf("Successfully registered hold service in PDS")
538538- }
539539- }
540540-541594 // Setup HTTP routes
542595 mux := http.NewServeMux()
543596 mux.HandleFunc("/health", service.HealthHandler)
544597 mux.HandleFunc("/register", service.HandleRegister)
545598 mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL)
546599 mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL)
600600+ mux.HandleFunc("/oauth/callback", service.HandleOAuthCallback) // OAuth callback on same port
601601+602602+ // OAuth client metadata endpoint for ATProto OAuth
603603+ clientID := cfg.Server.PublicURL + "/client-metadata.json"
604604+ clientMetadata := oauth.NewClientMetadata(clientID, []string{cfg.Server.PublicURL + "/oauth/callback"})
605605+ clientMetadata.ClientName = "ATCR Hold Service"
606606+ clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service
607607+ mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata))
547608 mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
548548- if r.Method == http.MethodGet {
609609+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
549610 service.HandleProxyGet(w, r)
550611 } else if r.Method == http.MethodPut {
551612 service.HandleProxyPut(w, r)
···562623 WriteTimeout: cfg.Server.WriteTimeout,
563624 }
564625565565- log.Printf("Starting hold service on %s", cfg.Server.Addr)
566566- if err := server.ListenAndServe(); err != nil {
626626+ // Start server in goroutine so we can do auto-registration after it's running
627627+ serverErr := make(chan error, 1)
628628+ go func() {
629629+ log.Printf("Starting hold service on %s", cfg.Server.Addr)
630630+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
631631+ serverErr <- err
632632+ }
633633+ }()
634634+635635+ // Give server a moment to start
636636+ time.Sleep(100 * time.Millisecond)
637637+638638+ // Auto-register if owner DID is set (now that server is running)
639639+ if cfg.Registration.OwnerDID != "" {
640640+ if err := service.AutoRegister(); err != nil {
641641+ log.Printf("WARNING: Auto-registration failed: %v", err)
642642+ log.Printf("You can register manually later using the /register endpoint")
643643+ } else {
644644+ log.Printf("Successfully registered hold service in PDS")
645645+ }
646646+ }
647647+648648+ // Wait for server error or shutdown
649649+ if err := <-serverErr; err != nil {
567650 log.Fatalf("Server failed: %v", err)
568651 }
569652}
···581664 return nil, fmt.Errorf("HOLD_PUBLIC_URL is required")
582665 }
583666 cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true"
584584- cfg.Server.ReadTimeout = 30 * time.Second
585585- cfg.Server.WriteTimeout = 30 * time.Second
667667+ cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
668668+ cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads
669669+ cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads
586670587671 // Registration configuration (optional)
588588- cfg.Registration.OwnerDID = os.Getenv("HOLD_CREW_OWNER")
672672+ cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
589673590674 // Storage configuration - build from env vars based on storage type
591675 storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
···647731 return defaultValue
648732}
649733734734+// blobPath converts a digest (e.g., "sha256:abc123...") to a storage path
735735+// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
736736+// where xx is the first 2 characters of the hash for directory sharding
737737+// NOTE: Path must start with / for filesystem driver
738738+func blobPath(digest string) string {
739739+ // Split digest into algorithm and hash
740740+ parts := strings.SplitN(digest, ":", 2)
741741+ if len(parts) != 2 {
742742+ // Fallback for malformed digest
743743+ return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
744744+ }
745745+746746+ algorithm := parts[0]
747747+ hash := parts[1]
748748+749749+ // Use first 2 characters for sharding
750750+ if len(hash) < 2 {
751751+ return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
752752+ }
753753+754754+ return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
755755+}
756756+650757// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
651758func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
652759 // We need to query the PDS without authentication to check public records
···685792 }
686793687794 if reg.OwnerDID == "" {
688688- return fmt.Errorf("HOLD_CREW_OWNER not set - required for registration")
795795+ return fmt.Errorf("HOLD_OWNER not set - required for registration")
689796 }
690797691798 ctx := context.Background()
···730837731838// registerWithOAuth performs OAuth flow and registers the hold
732839func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error {
733733- // Use 127.0.0.1 for localhost callback (works better than "localhost")
734734- callbackAddr := "127.0.0.1:8888"
735735- redirectURI := fmt.Sprintf("http://%s/callback", callbackAddr)
840840+ // Extract port from publicURL for test mode
841841+ var redirectURI string
842842+ var clientID string
843843+844844+ // Define the scopes we need for hold registration
845845+ // Need create and update permissions for hold and crew collections
846846+ scopes := fmt.Sprintf("atproto repo:%s?action=create repo:%s?action=update repo:%s?action=create repo:%s?action=update",
847847+ atproto.HoldCollection, atproto.HoldCollection,
848848+ atproto.HoldCrewCollection, atproto.HoldCrewCollection)
849849+850850+ if s.config.Server.TestMode {
851851+ // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
852852+ // Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080")
853853+ parsedURL, err := url.Parse(publicURL)
854854+ if err != nil {
855855+ return fmt.Errorf("failed to parse public URL: %w", err)
856856+ }
857857+ port := parsedURL.Port()
858858+ if port == "" {
859859+ port = "8080" // default
860860+ }
861861+ redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port)
862862+ clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
863863+ url.QueryEscape(redirectURI), url.QueryEscape(scopes))
864864+ } else if strings.Contains(publicURL, "127.0.0.1") || strings.Contains(publicURL, "localhost") {
865865+ // Localhost development mode per ATProto OAuth spec
866866+ redirectURI = publicURL + "/oauth/callback"
867867+ clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
868868+ url.QueryEscape(redirectURI), url.QueryEscape(scopes))
869869+ } else {
870870+ // Production mode - use client metadata URL
871871+ redirectURI = publicURL + "/oauth/callback"
872872+ clientID = publicURL + "/client-metadata.json"
873873+ }
736874737875 // Create OAuth client
738738- oauthClient, err := oauth.NewClient("http://hold-service", redirectURI)
876876+ oauthClient, err := oauth.NewClient(clientID, redirectURI)
739877 if err != nil {
740878 return fmt.Errorf("failed to create OAuth client: %w", err)
741879 }
···746884 return fmt.Errorf("failed to initialize OAuth: %w", err)
747885 }
748886887887+ // Set the scopes we need for hold registration (create and update)
888888+ oauthClient.SetScopes([]string{
889889+ "atproto",
890890+ fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
891891+ fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
892892+ fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
893893+ fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
894894+ })
895895+749896 // Generate authorization URL
750750- state := "hold-registration"
751751- authURL, codeVerifier, err := oauthClient.AuthorizeURL(state)
897897+ s.oauthState = "hold-registration"
898898+ authURL, codeVerifier, err := oauthClient.AuthorizeURL(s.oauthState)
752899 if err != nil {
753900 return fmt.Errorf("failed to generate auth URL: %w", err)
754901 }
902902+ s.codeVerifier = codeVerifier
755903756904 // Print the OAuth URL for user to visit
757905 log.Print("\n" + strings.Repeat("=", 80))
···762910 log.Printf("Waiting for authorization...")
763911 log.Print(strings.Repeat("=", 80) + "\n")
764912765765- // Start temporary HTTP server for callback
766766- codeChan := make(chan string, 1)
767767- errChan := make(chan error, 1)
768768-769769- mux := http.NewServeMux()
770770- mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
771771- code := r.URL.Query().Get("code")
772772- receivedState := r.URL.Query().Get("state")
773773-774774- if receivedState != state {
775775- errChan <- fmt.Errorf("invalid state parameter")
776776- http.Error(w, "Invalid state", http.StatusBadRequest)
777777- return
778778- }
779779-780780- if code == "" {
781781- errChan <- fmt.Errorf("no authorization code received")
782782- http.Error(w, "No code", http.StatusBadRequest)
783783- return
784784- }
785785-786786- w.Header().Set("Content-Type", "text/html")
787787- fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
788788- codeChan <- code
789789- })
790790-791791- server := &http.Server{
792792- Addr: callbackAddr,
793793- Handler: mux,
794794- }
795795-796796- go func() {
797797- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
798798- errChan <- err
799799- }
800800- }()
801801-802802- // Wait for callback or error
913913+ // Wait for callback or error (callback happens on main server)
803914 var code string
804915 select {
805805- case code = <-codeChan:
806806- // Got the code, shutdown callback server
807807- server.Shutdown(context.Background())
808808- case err := <-errChan:
809809- server.Shutdown(context.Background())
916916+ case code = <-s.oauthCodeCh:
917917+ // Got the code from callback
918918+ case err := <-s.oauthErrCh:
810919 return err
811920 case <-time.After(5 * time.Minute):
812812- server.Shutdown(context.Background())
813921 return fmt.Errorf("OAuth timeout - no response after 5 minutes")
814922 }
815923816924 log.Printf("Authorization received, exchanging code for token...")
817925818926 // Exchange code for token
819819- token, err := oauthClient.Exchange(ctx, code, codeVerifier)
927927+ token, err := oauthClient.Exchange(ctx, code, s.codeVerifier)
820928 if err != nil {
821929 return fmt.Errorf("failed to exchange code: %w", err)
822930 }
···825933 log.Printf("DID: %s", did)
826934 log.Printf("PDS: %s", pdsEndpoint)
827935828828- // Now register with the token
829829- return s.registerWithToken(publicURL, did, pdsEndpoint, token.AccessToken)
936936+ // Now register with the token using DPoP
937937+ // Create ATProto client with DPoP transport from OAuth client
938938+ dpopKey := oauthClient.DPoPKey()
939939+ dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey)
940940+ // Set the access token in the transport for "ath" claim computation
941941+ dpopTransport.SetAccessToken(token.AccessToken)
942942+ client := atproto.NewClientWithDPoP(pdsEndpoint, did, token.AccessToken, dpopKey, dpopTransport)
943943+944944+ return s.registerWithClient(publicURL, did, client)
830945}
831946832832-// registerWithToken registers the hold using an access token
833833-func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken string) error {
947947+// registerWithClient registers the hold using an authenticated ATProto client
948948+func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error {
834949 // Derive hold name from URL (hostname)
835950 holdName, err := extractHostname(publicURL)
836951 if err != nil {
···840955 log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
841956842957 ctx := context.Background()
843843-844844- // Create ATProto client with owner's credentials
845845- client := atproto.NewClient(pdsEndpoint, did, accessToken)
846958847959 // Create HoldRecord
848960 holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
···865977 }
866978867979 log.Printf("✓ Created crew record: %s", crewResult.URI)
980980+981981+ // Update sailor profile to set this as the default hold
982982+ profile, err := atproto.GetProfile(ctx, client)
983983+ if err != nil {
984984+ log.Printf("Warning: failed to get sailor profile: %v", err)
985985+ } else {
986986+ if profile == nil {
987987+ // Create new profile with this hold as default
988988+ profile = atproto.NewSailorProfileRecord(publicURL)
989989+ } else {
990990+ // Update existing profile with new defaultHold
991991+ profile.DefaultHold = publicURL
992992+ profile.UpdatedAt = time.Now()
993993+ }
994994+995995+ err = atproto.UpdateProfile(ctx, client, profile)
996996+ if err != nil {
997997+ log.Printf("Warning: failed to update sailor profile: %v", err)
998998+ } else {
999999+ log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL)
10001000+ }
10011001+ }
86810028691003 log.Print("\n" + strings.Repeat("=", 80))
8701004 log.Printf("REGISTRATION COMPLETE")
+122
cmd/profile-update/main.go
···11+package main
22+33+import (
44+ "context"
55+ "encoding/base64"
66+ "encoding/json"
77+ "flag"
88+ "fmt"
99+ "log"
1010+ "os"
1111+ "path/filepath"
1212+ "strings"
1313+1414+ atprotoAuth "atcr.io/pkg/auth/atproto"
1515+ "atcr.io/pkg/atproto"
1616+)
1717+1818+// DockerConfig represents ~/.docker/config.json
1919+type DockerConfig struct {
2020+ Auths map[string]AuthEntry `json:"auths"`
2121+}
2222+2323+type AuthEntry struct {
2424+ Auth string `json:"auth"` // base64(username:password)
2525+}
2626+2727+func main() {
2828+ var defaultHold string
2929+ var registryURL string
3030+3131+ flag.StringVar(&defaultHold, "default-hold", "", "Default hold endpoint URL (e.g., http://172.28.0.3:8080)")
3232+ flag.StringVar(®istryURL, "registry", "127.0.0.1:5000", "Registry URL to read auth from Docker config")
3333+ flag.Parse()
3434+3535+ // Read Docker config
3636+ home, err := os.UserHomeDir()
3737+ if err != nil {
3838+ log.Fatalf("Failed to get home directory: %v", err)
3939+ }
4040+ dockerConfigPath := filepath.Join(home, ".docker", "config.json")
4141+4242+ configData, err := os.ReadFile(dockerConfigPath)
4343+ if err != nil {
4444+ log.Fatalf("Failed to read Docker config: %v\n\nMake sure you've logged in with: docker login %s", err, registryURL)
4545+ }
4646+4747+ var dockerConfig DockerConfig
4848+ if err := json.Unmarshal(configData, &dockerConfig); err != nil {
4949+ log.Fatalf("Failed to parse Docker config: %v", err)
5050+ }
5151+5252+ // Get auth for registry
5353+ authEntry, ok := dockerConfig.Auths[registryURL]
5454+ if !ok {
5555+ log.Fatalf("No auth found for registry %s in Docker config", registryURL)
5656+ }
5757+5858+ // Decode base64 auth (format: "username:password")
5959+ authBytes, err := base64.StdEncoding.DecodeString(authEntry.Auth)
6060+ if err != nil {
6161+ log.Fatalf("Failed to decode auth: %v", err)
6262+ }
6363+6464+ parts := strings.SplitN(string(authBytes), ":", 2)
6565+ if len(parts) != 2 {
6666+ log.Fatalf("Invalid auth format")
6767+ }
6868+6969+ handle := parts[0]
7070+ password := parts[1] // This should be an app password
7171+7272+ fmt.Printf("Handle: %s\n", handle)
7373+7474+ // Create session validator and get access token
7575+ validator := atprotoAuth.NewSessionValidator()
7676+ ctx := context.Background()
7777+7878+ did, pdsEndpoint, accessToken, err := validator.CreateSessionAndGetToken(ctx, handle, password)
7979+ if err != nil {
8080+ log.Fatalf("Failed to authenticate: %v", err)
8181+ }
8282+8383+ fmt.Printf("DID: %s\n", did)
8484+ fmt.Printf("PDS: %s\n\n", pdsEndpoint)
8585+8686+ // Create client with the access token from createSession
8787+ client := atproto.NewClient(pdsEndpoint, did, accessToken)
8888+8989+ // Get current profile
9090+ profile, err := atproto.GetProfile(ctx, client)
9191+ if err != nil {
9292+ log.Fatalf("Failed to get current profile: %v", err)
9393+ }
9494+9595+ if profile == nil {
9696+ if defaultHold == "" {
9797+ fmt.Println("No existing profile found.")
9898+ fmt.Println("\nTo create profile with default hold, use: -default-hold <url>")
9999+ return
100100+ }
101101+ fmt.Println("No existing profile found. Creating new profile...")
102102+ profile = atproto.NewSailorProfileRecord(defaultHold)
103103+ } else {
104104+ fmt.Printf("Current defaultHold: %s\n", profile.DefaultHold)
105105+ if defaultHold == "" {
106106+ // Just show current profile
107107+ fmt.Println("\nTo update, use: -default-hold <url>")
108108+ return
109109+ }
110110+ profile.DefaultHold = defaultHold
111111+ }
112112+113113+ // Update profile
114114+ if defaultHold != "" {
115115+ err = atproto.UpdateProfile(ctx, client, profile)
116116+ if err != nil {
117117+ log.Fatalf("Failed to update profile: %v", err)
118118+ }
119119+120120+ fmt.Printf("\n✓ Updated defaultHold to: %s\n", defaultHold)
121121+ }
122122+}
+20-1
docker-compose.yml
···1111 # Only auth keys (could be moved to secrets in production)
1212 - atcr-auth:/var/lib/atcr/auth
1313 restart: unless-stopped
1414+ networks:
1515+ atcr-network:
1616+ ipv4_address: 172.28.0.2
1417 # The registry should be stateless - all storage is external:
1518 # - Manifests/Tags -> ATProto PDS
1619 # - Blobs/Layers -> Hold service
1720 # Future: Add read_only: true for production deployments
18211922 hold:
2323+ environment:
2424+ HOLD_PUBLIC_URL: http://172.28.0.3:8080
2525+ HOLD_OWNER: did:plc:pddp4xt5lgnv2qsegbzzs4xg
2626+ HOLD_PUBLIC: false
2727+ STORAGE_DRIVER: filesystem
2828+ STORAGE_ROOT_DIR: /var/lib/atcr/hold
2929+ TEST_MODE: true
2030 build:
2131 context: .
2232 dockerfile: Dockerfile.hold
···2737 volumes:
2838 - atcr-hold:/var/lib/atcr/hold
2939 restart: unless-stopped
4040+ networks:
4141+ atcr-network:
4242+ ipv4_address: 172.28.0.3
4343+4444+networks:
4545+ atcr-network:
4646+ driver: bridge
4747+ ipam:
4848+ config:
4949+ - subnet: 172.28.0.0/24
30503151volumes:
3232- atcr-blobs:
3352 atcr-hold:
3453 atcr-auth:
+5-5
docs/BYOS.md
···174174175175**Standard registration workflow:**
176176177177-1. Set `HOLD_CREW_OWNER` to your DID:
177177+1. Set `HOLD_OWNER` to your DID:
178178 ```bash
179179- export HOLD_CREW_OWNER=did:plc:your-did-here
179179+ export HOLD_OWNER=did:plc:your-did-here
180180 ```
1811811821822. Start the hold service:
···249249# Set secrets
250250fly secrets set AWS_ACCESS_KEY_ID=...
251251fly secrets set AWS_SECRET_ACCESS_KEY=...
252252-fly secrets set HOLD_CREW_OWNER=did:plc:your-did-here
252252+fly secrets set HOLD_OWNER=did:plc:your-did-here
253253254254# Check logs for OAuth URL on first run
255255fly logs
···4044041. **Set environment variables**:
405405 ```bash
406406 export HOLD_PUBLIC_URL=https://alice-storage.fly.dev
407407- export HOLD_CREW_OWNER=did:plc:alice123
407407+ export HOLD_OWNER=did:plc:alice123
408408 export STORAGE_DRIVER=s3
409409 export AWS_ACCESS_KEY_ID=your_storj_access_key
410410 export AWS_SECRET_ACCESS_KEY=your_storj_secret_key
···4234231. **Deploy hold service** with S3 credentials and auto-registration:
424424 ```bash
425425 export HOLD_PUBLIC_URL=https://company-hold.fly.dev
426426- export HOLD_CREW_OWNER=did:plc:admin
426426+ export HOLD_OWNER=did:plc:admin
427427 export HOLD_PUBLIC=false
428428 export STORAGE_DRIVER=s3
429429 export AWS_ACCESS_KEY_ID=...