···8899// HoldCaptainRecord represents a cached captain record from a hold's PDS
1010type HoldCaptainRecord struct {
1111- HoldDID string `json:"-"` // Set manually, not from JSON
1111+ HoldDID string `json:"-"` // Set manually, not from JSON
1212 OwnerDID string `json:"owner"`
1313 Public bool `json:"public"`
1414 AllowAllCrew bool `json:"allowAllCrew"`
1515 DeployedAt string `json:"deployedAt"`
1616 Region string `json:"region"`
1717 Provider string `json:"provider"`
1818- UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
1818+ UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
1919}
20202121// GetCaptainRecord retrieves a captain record from the cache
···55 "database/sql"
66 "fmt"
77 "log"
88+ "strings"
89 "sync"
910 "time"
1011)
···115116 return
116117 }
117118118118- log.Printf("Hold health worker: Checking %d unique hold endpoints", len(endpoints))
119119+ log.Printf("Hold health worker: Fetched %d hold endpoint entries from database", len(endpoints))
120120+121121+ // Deduplicate endpoints by normalizing to canonical DID format
122122+ // This handles cases where the same hold is stored with different representations:
123123+ // - http://172.28.0.3:8080 (internal IP)
124124+ // - http://hold01.atcr.io (external hostname)
125125+ // - did:web:hold01.atcr.io (DID format)
126126+ // All normalize to the same DID: did:web:hold01.atcr.io (or did:web:172.28.0.3:8080)
127127+ seen := make(map[string]bool)
128128+ uniqueEndpoints := make([]string, 0, len(endpoints))
129129+130130+ for _, endpoint := range endpoints {
131131+ // Normalize to canonical DID format
132132+ normalizedDID := normalizeHoldEndpoint(endpoint)
133133+134134+ // Skip if we've already seen this normalized DID
135135+ if seen[normalizedDID] {
136136+ continue
137137+ }
138138+139139+ seen[normalizedDID] = true
140140+ // Use the normalized DID for health checks
141141+ uniqueEndpoints = append(uniqueEndpoints, normalizedDID)
142142+ }
143143+144144+ log.Printf("Hold health worker: Checking %d unique hold endpoints (deduplicated from %d)", len(uniqueEndpoints), len(endpoints))
119145120146 // Check health concurrently with rate limiting
121147 // Use a semaphore to limit concurrent requests (max 10 at a time)
···126152 unreachable := 0
127153 var statsMu sync.Mutex
128154129129- for _, endpoint := range endpoints {
155155+ for _, endpoint := range uniqueEndpoints {
130156 wg.Add(1)
131157132158 go func(ep string) {
···193219194220 return endpoints, nil
195221}
222222+223223+// normalizeHoldEndpoint converts a hold endpoint (URL or DID) to canonical DID format
224224+// This ensures that different representations of the same hold are deduplicated:
225225+// - http://172.28.0.3:8080 → did:web:172.28.0.3:8080
226226+// - http://hold01.atcr.io → did:web:hold01.atcr.io
227227+// - https://hold01.atcr.io → did:web:hold01.atcr.io
228228+// - did:web:hold01.atcr.io → did:web:hold01.atcr.io (passthrough)
229229+func normalizeHoldEndpoint(endpoint string) string {
230230+ // Strip protocol and trailing slashes
231231+ normalized := endpoint
232232+ normalized = strings.TrimPrefix(normalized, "http://")
233233+ normalized = strings.TrimPrefix(normalized, "https://")
234234+ normalized = strings.TrimSuffix(normalized, "/")
235235+236236+ // If already a DID, return as-is
237237+ if strings.HasPrefix(endpoint, "did:") {
238238+ return endpoint
239239+ }
240240+241241+ // Extract hostname (remove path if present)
242242+ parts := strings.Split(normalized, "/")
243243+ hostname := parts[0]
244244+245245+ // Convert to did:web
246246+ return "did:web:" + hostname
247247+}
+22-22
pkg/hold/pds/server.go
···128128129129 if !captainExists {
130130131131- // Initialize repo if it doesn't exist yet
132132- // Check if repo exists by trying to get the head
133133- head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
134134- if err != nil || !head.Defined() {
135135- // Repo doesn't exist, initialize it
136136- // InitNewActor creates an empty repo with initial commit
137137- err = p.repomgr.InitNewActor(ctx, p.uid, "", p.did, "", "", "")
138138- if err != nil {
139139- return fmt.Errorf("failed to initialize repo: %w", err)
131131+ // Initialize repo if it doesn't exist yet
132132+ // Check if repo exists by trying to get the head
133133+ head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
134134+ if err != nil || !head.Defined() {
135135+ // Repo doesn't exist, initialize it
136136+ // InitNewActor creates an empty repo with initial commit
137137+ err = p.repomgr.InitNewActor(ctx, p.uid, "", p.did, "", "", "")
138138+ if err != nil {
139139+ return fmt.Errorf("failed to initialize repo: %w", err)
140140+ }
141141+ fmt.Printf("✅ Initialized empty repo\n")
140142 }
141141- fmt.Printf("✅ Initialized empty repo\n")
142142- }
143143144144- // Create captain record (hold ownership and settings)
145145- _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew)
146146- if err != nil {
147147- return fmt.Errorf("failed to create captain record: %w", err)
148148- }
144144+ // Create captain record (hold ownership and settings)
145145+ _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew)
146146+ if err != nil {
147147+ return fmt.Errorf("failed to create captain record: %w", err)
148148+ }
149149150150- fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew)
150150+ fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew)
151151152152- // Add hold owner as first crew member with admin role
153153- _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
154154- if err != nil {
155155- return fmt.Errorf("failed to add owner as crew member: %w", err)
156156- }
152152+ // Add hold owner as first crew member with admin role
153153+ _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
154154+ if err != nil {
155155+ return fmt.Errorf("failed to add owner as crew member: %w", err)
156156+ }
157157158158 fmt.Printf("✅ Added %s as hold admin\n", ownerDID)
159159 }