···114114 return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target)
115115 }
116116117117+ // Run go generate before building
118118+ if err := runGenerate(rootDir); err != nil {
119119+ return fmt.Errorf("go generate: %w", err)
120120+ }
121121+117122 // Build all binaries locally before touching servers
118123 fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...")
119124 for _, name := range toUpdate {
···297302 BasePath: naming.BasePath(),
298303 ScannerSecret: state.ScannerSecret,
299304 }
305305+}
306306+307307+// runGenerate runs go generate ./... in the given directory using host OS/arch
308308+// (no cross-compilation env vars — generate tools must run on the build machine).
309309+func runGenerate(dir string) error {
310310+ fmt.Println("Running go generate ./...")
311311+ cmd := exec.Command("go", "generate", "./...")
312312+ cmd.Dir = dir
313313+ cmd.Stdout = os.Stdout
314314+ cmd.Stderr = os.Stderr
315315+ return cmd.Run()
300316}
301317302318// buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64.
+7-6
pkg/appview/handlers/attestation_details.go
···119119 loggedIn := false
120120 if user := middleware.GetUser(r); user != nil && h.Refresher != nil {
121121 loggedIn = true
122122- holdDID := atproto.ResolveHoldDIDFromURL(details[0].HoldEndpoint)
123123- if holdDID == "" {
124124- holdDID = details[0].HoldEndpoint // might already be a DID
122122+ holdDID, resolveErr := atproto.ResolveHoldDID(ctx, details[0].HoldEndpoint)
123123+ if resolveErr != nil {
124124+ slog.Debug("Could not resolve hold DID for service token", "holdEndpoint", details[0].HoldEndpoint, "error", resolveErr)
125125+ holdDID = details[0].HoldEndpoint // fallback: use as-is
125126 }
126127 if token, err := auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint); err == nil {
127128 serviceToken = token
···267268 if err != nil {
268269 return nil, fmt.Errorf("could not resolve hold endpoint %s: %w", holdEndpoint, err)
269270 }
270270- holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
271271- if holdDID == "" {
272272- return nil, fmt.Errorf("could not resolve hold DID from: %s", holdEndpoint)
271271+ holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint)
272272+ if err != nil {
273273+ return nil, fmt.Errorf("could not resolve hold DID from %s: %w", holdEndpoint, err)
273274 }
274275275276 // Step 1: Request presigned URL from hold
+30-9
pkg/appview/handlers/scan_result.go
···4646 return
4747 }
48484949- // Derive hold DID from endpoint URL
5050- holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
5151- if holdDID == "" {
4949+ // Resolve hold identity: holdEndpoint may be a DID or URL
5050+ holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint)
5151+ if err != nil {
5252+ slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err)
5353+ h.renderBadge(w, vulnBadgeData{Error: true})
5454+ return
5555+ }
5656+5757+ // Resolve to HTTP endpoint URL (handles DID, URL, or hostname)
5858+ holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint)
5959+ if err != nil {
6060+ slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err)
5261 h.renderBadge(w, vulnBadgeData{Error: true})
5362 return
5463 }
···6170 defer cancel()
62716372 scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
6464- holdEndpoint,
7373+ holdURL,
6574 url.QueryEscape(holdDID),
6675 url.QueryEscape(atproto.ScanCollection),
6776 url.QueryEscape(rkey),
···116125 ScannedAt: scanRecord.ScannedAt,
117126 Found: true,
118127 Digest: digest,
119119- HoldEndpoint: holdEndpoint,
128128+ HoldEndpoint: holdDID,
120129 })
121130}
122131···175184 ScannedAt: scanRecord.ScannedAt,
176185 Found: true,
177186 Digest: fullDigest,
178178- HoldEndpoint: holdEndpoint,
187187+ HoldEndpoint: holdDID,
179188 }
180189}
181190···199208 digests = digests[:50]
200209 }
201210202202- holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
203203- if holdDID == "" {
211211+ holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint)
212212+ if err != nil {
204213 // Can't resolve hold — render empty OOB spans
214214+ slog.Debug("Failed to resolve hold DID for batch scan", "holdEndpoint", holdEndpoint, "error", err)
215215+ w.Header().Set("Content-Type", "text/html")
216216+ for _, d := range digests {
217217+ fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
218218+ }
219219+ return
220220+ }
221221+222222+ // Resolve to HTTP endpoint URL (handles DID, URL, or hostname)
223223+ holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint)
224224+ if err != nil {
225225+ slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err)
205226 w.Header().Set("Content-Type", "text/html")
206227 for _, d := range digests {
207228 fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
···229250 ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
230251 defer cancel()
231252232232- results[idx].data = fetchScanRecord(ctx, holdEndpoint, holdDID, hex)
253253+ results[idx].data = fetchScanRecord(ctx, holdURL, holdDID, hex)
233254 }(i, hexDigest)
234255 }
235256 wg.Wait()
+34
pkg/appview/handlers/scan_result_test.go
···1111 "atcr.io/pkg/appview/handlers"
1212)
13131414+// mockHoldDID is the DID returned by test hold servers for /.well-known/atproto-did
1515+const mockHoldDID = "did:web:hold.example.com"
1616+1717+// handleMockDID serves /.well-known/atproto-did for test hold servers.
1818+// Returns true if the request was handled, false if it should be passed to the next handler.
1919+func handleMockDID(w http.ResponseWriter, r *http.Request) bool {
2020+ if r.URL.Path == "/.well-known/atproto-did" {
2121+ w.Write([]byte(mockHoldDID))
2222+ return true
2323+ }
2424+ return false
2525+}
2626+1427// mockScanRecord returns a getRecord JSON envelope wrapping a scan record
1528func mockScanRecord(critical, high, medium, low, total int64) string {
1629 record := map[string]any{
···5164func TestScanResult_WithVulnerabilities(t *testing.T) {
5265 // Mock hold that returns a scan record with vulnerabilities
5366 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6767+ if handleMockDID(w, r) {
6868+ return
6969+ }
5470 w.Header().Set("Content-Type", "application/json")
5571 w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20)))
5672 }))
···96112func TestScanResult_Clean(t *testing.T) {
97113 // Mock hold that returns a scan record with zero vulnerabilities
98114 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115115+ if handleMockDID(w, r) {
116116+ return
117117+ }
99118 w.Header().Set("Content-Type", "application/json")
100119 w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0)))
101120 }))
···124143func TestScanResult_NotFound(t *testing.T) {
125144 // Mock hold that returns 404 (no scan record — scanning disabled or not yet scanned)
126145 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146146+ if handleMockDID(w, r) {
147147+ return
148148+ }
127149 http.Error(w, "record not found", http.StatusNotFound)
128150 }))
129151 defer hold.Close()
···145167func TestScanResult_HoldError(t *testing.T) {
146168 // Mock hold that returns 500
147169 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
170170+ if handleMockDID(w, r) {
171171+ return
172172+ }
148173 http.Error(w, "internal error", http.StatusInternalServerError)
149174 }))
150175 defer hold.Close()
···226251func TestScanResult_OnlyCriticalShown(t *testing.T) {
227252 // Only critical vulns, no high/medium/low
228253 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
254254+ if handleMockDID(w, r) {
255255+ return
256256+ }
229257 w.Header().Set("Content-Type", "application/json")
230258 w.Write([]byte(mockScanRecord(3, 0, 0, 0, 3)))
231259 }))
···272300func TestBatchScanResult_MultipleDigests(t *testing.T) {
273301 // Mock hold that returns different results based on rkey
274302 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303303+ if handleMockDID(w, r) {
304304+ return
305305+ }
275306 rkey := r.URL.Query().Get("rkey")
276307 w.Header().Set("Content-Type", "application/json")
277308 switch rkey {
···379410380411func TestBatchScanResult_SingleDigest(t *testing.T) {
381412 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
413413+ if handleMockDID(w, r) {
414414+ return
415415+ }
382416 w.Header().Set("Content-Type", "application/json")
383417 w.Write([]byte(mockScanRecord(1, 0, 0, 0, 1)))
384418 }))
+32-19
pkg/appview/handlers/vuln_details.go
···2121}
22222323// grypeReport is the minimal Grype JSON structure we need.
2424+// Grype v0.107+ uses PascalCase JSON keys.
2425type grypeReport struct {
2526 Matches []grypeMatch `json:"matches"`
2627}
27282829type grypeMatch struct {
2929- Vulnerability grypeVuln `json:"vulnerability"`
3030- Artifact grypeArtifact `json:"artifact"`
3030+ Vulnerability grypeVuln `json:"Vulnerability"`
3131+ Package grypePackage `json:"Package"`
3132}
32333334type grypeVuln struct {
3434- ID string `json:"id"`
3535- Severity string `json:"severity"`
3636- Fix grypeFix `json:"fix"`
3535+ ID string `json:"ID"`
3636+ Metadata grypeMetadata `json:"Metadata"`
3737+ Fix grypeFix `json:"Fix"`
3838+}
3939+4040+type grypeMetadata struct {
4141+ Severity string `json:"Severity"`
3742}
38433944type grypeFix struct {
4040- Versions []string `json:"versions"`
4141- State string `json:"state"`
4545+ Versions []string `json:"Versions"`
4646+ State string `json:"State"`
4247}
43484444-type grypeArtifact struct {
4545- Name string `json:"name"`
4646- Version string `json:"version"`
4747- Type string `json:"type"`
4949+type grypePackage struct {
5050+ Name string `json:"Name"`
5151+ Version string `json:"Version"`
5252+ Type string `json:"Type"`
4853}
49545055// vulnDetailsData is the template data for the vuln-details partial.
···9297 return
9398 }
94999595- holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
9696- if holdDID == "" {
100100+ holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint)
101101+ if err != nil {
102102+ slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err)
97103 h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"})
104104+ return
105105+ }
106106+107107+ // Resolve to HTTP endpoint URL (handles DID, URL, or hostname)
108108+ holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint)
109109+ if err != nil {
110110+ h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold endpoint"})
98111 return
99112 }
100113···105118106119 // Step 1: Fetch the scan record to get the VulnReportBlob CID
107120 scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
108108- holdEndpoint,
121121+ holdURL,
109122 url.QueryEscape(holdDID),
110123 url.QueryEscape(atproto.ScanCollection),
111124 url.QueryEscape(rkey),
···163176164177 blobCID := scanRecord.VulnReportBlob.Ref.String()
165178 blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
166166- holdEndpoint,
179179+ holdURL,
167180 url.QueryEscape(holdDID),
168181 url.QueryEscape(blobCID),
169182 )
···211224 matches = append(matches, vulnMatch{
212225 CVEID: m.Vulnerability.ID,
213226 CVEURL: cveURL,
214214- Severity: m.Vulnerability.Severity,
215215- Package: m.Artifact.Name,
216216- Version: m.Artifact.Version,
227227+ Severity: m.Vulnerability.Metadata.Severity,
228228+ Package: m.Package.Name,
229229+ Version: m.Package.Version,
217230 FixedIn: fixedIn,
218218- Type: m.Artifact.Type,
231231+ Type: m.Package.Type,
219232 })
220233 }
221234
+15-1
pkg/appview/handlers/vuln_details_test.go
···159159func TestVulnDetails_FullReport(t *testing.T) {
160160 grypeJSON := mockGrypeReport()
161161162162- // Mock hold that serves both getRecord and getBlob
162162+ // Mock hold that serves DID resolution, getRecord, and getBlob
163163 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164164 path := r.URL.Path
165165+ if path == "/.well-known/atproto-did" {
166166+ w.Write([]byte("did:web:hold.example.com"))
167167+ return
168168+ }
165169 if strings.Contains(path, "getRecord") {
166170 w.Header().Set("Content-Type", "application/json")
167171 w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3)))
···231235func TestVulnDetails_NoVulnReportBlob(t *testing.T) {
232236 // Mock hold returns scan record WITHOUT VulnReportBlob
233237 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
238238+ if handleMockDID(w, r) {
239239+ return
240240+ }
234241 w.Header().Set("Content-Type", "application/json")
235242 w.Write([]byte(mockScanRecordWithoutBlob(2, 5, 10, 3, 20)))
236243 }))
···263270func TestVulnDetails_NotFound(t *testing.T) {
264271 // Mock hold returns 404
265272 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
273273+ if handleMockDID(w, r) {
274274+ return
275275+ }
266276 http.Error(w, "not found", http.StatusNotFound)
267277 }))
268278 defer hold.Close()
···286296287297 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
288298 path := r.URL.Path
299299+ if path == "/.well-known/atproto-did" {
300300+ w.Write([]byte("did:web:hold.example.com"))
301301+ return
302302+ }
289303 if strings.Contains(path, "getRecord") {
290304 w.Header().Set("Content-Type", "application/json")
291305 w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3)))
···127127128128 for _, endpoint := range endpoints {
129129 // Normalize to canonical DID format
130130- normalizedDID := atproto.ResolveHoldDIDFromURL(endpoint)
130130+ normalizedDID, err := atproto.ResolveHoldDID(ctx, endpoint)
131131+ if err != nil {
132132+ slog.Debug("Failed to resolve hold DID during health check", "endpoint", endpoint, "error", err)
133133+ continue
134134+ }
131135132136 // Skip if we've already seen this normalized DID
133137 if seen[normalizedDID] {
+9-5
pkg/appview/jetstream/processor.go
···252252 // Old manifests use holdEndpoint field (URL format) - convert to DID
253253 holdDID := manifestRecord.HoldDID
254254 if holdDID == "" && manifestRecord.HoldEndpoint != "" {
255255- // Legacy manifest - convert URL to DID
256256- holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
255255+ // Legacy manifest - resolve URL to DID via /.well-known/atproto-did
256256+ if resolved, err := atproto.ResolveHoldDID(ctx, manifestRecord.HoldEndpoint); err != nil {
257257+ slog.Warn("Failed to resolve hold DID from legacy manifest endpoint", "holdEndpoint", manifestRecord.HoldEndpoint, "error", err)
258258+ } else {
259259+ holdDID = resolved
260260+ }
257261 }
258262259263 // Detect artifact type from config media type
···445449 }
446450447451 // Convert hold URL/DID to canonical DID
448448- holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
449449- if holdDID == "" {
450450- slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold)
452452+ holdDID, err := atproto.ResolveHoldDID(ctx, profileRecord.DefaultHold)
453453+ if err != nil {
454454+ slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err)
451455 return nil
452456 }
453457
+9-6
pkg/appview/server.go
···352352 if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
353353 slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold)
354354355355- holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
356356-357357- profile.DefaultHold = holdDID
358358- if err := storage.UpdateProfile(ctx, client, profile); err != nil {
359359- slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
355355+ if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold); resolveErr != nil {
356356+ slog.Warn("Failed to resolve hold DID from URL", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold, "error", resolveErr)
360357 } else {
361361- slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID)
358358+ holdDID = resolvedDID
359359+ profile.DefaultHold = holdDID
360360+ if err := storage.UpdateProfile(ctx, client, profile); err != nil {
361361+ slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
362362+ } else {
363363+ slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID)
364364+ }
362365 }
363366 } else {
364367 holdDID = profile.DefaultHold
···3636 // This ensures we store DIDs consistently in new profiles
3737 normalizedDID := ""
3838 if defaultHoldDID != "" {
3939- normalizedDID = atproto.ResolveHoldDIDFromURL(defaultHoldDID)
3939+ resolved, err := atproto.ResolveHoldDID(ctx, defaultHoldDID)
4040+ if err != nil {
4141+ slog.Warn("Failed to resolve hold DID for new profile", "component", "profile", "defaultHold", defaultHoldDID, "error", err)
4242+ } else {
4343+ normalizedDID = resolved
4444+ }
4045 }
41464247 // Profile doesn't exist - create it
···7378 // Migrate old URL-based defaultHold to DID format
7479 // This ensures backward compatibility with profiles created before DID migration
7580 if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
7676- // Convert URL to DID transparently
7777- migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
7878- profile.DefaultHold = migratedDID
8181+ // Convert URL to DID by querying /.well-known/atproto-did
8282+ migratedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold)
8383+ if resolveErr != nil {
8484+ slog.Warn("Failed to resolve hold DID during profile migration", "component", "profile", "defaultHold", profile.DefaultHold, "error", resolveErr)
8585+ } else {
8686+ profile.DefaultHold = migratedDID
79878080- // Persist the migration to PDS in a background goroutine
8181- // Use a lock to ensure only one goroutine migrates this DID
8282- did := client.DID()
8383- if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded {
8484- // We got the lock - launch goroutine to persist the migration
8585- go func() {
8686- // Clean up lock when done (after a short delay to batch requests)
8787- defer func() {
8888- time.Sleep(1 * time.Second)
8989- migrationLocks.Delete(did)
9090- }()
8888+ // Persist the migration to PDS in a background goroutine
8989+ // Use a lock to ensure only one goroutine migrates this DID
9090+ did := client.DID()
9191+ if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded {
9292+ // We got the lock - launch goroutine to persist the migration
9393+ go func() {
9494+ // Clean up lock when done (after a short delay to batch requests)
9595+ defer func() {
9696+ time.Sleep(1 * time.Second)
9797+ migrationLocks.Delete(did)
9898+ }()
91999292- // Create a new context with timeout for the background operation
9393- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
9494- defer cancel()
100100+ // Create a new context with timeout for the background operation
101101+ bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
102102+ defer cancel()
951039696- // Update the profile on the PDS
9797- profile.UpdatedAt = time.Now()
9898- if err := UpdateProfile(ctx, client, &profile); err != nil {
9999- slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err)
100100- } else {
101101- slog.Debug("Persisted defaultHold migration to DID", "component", "profile", "migrated_did", migratedDID, "did", did)
102102- }
103103- }()
104104+ // Update the profile on the PDS
105105+ profile.UpdatedAt = time.Now()
106106+ if err := UpdateProfile(bgCtx, client, &profile); err != nil {
107107+ slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err)
108108+ } else {
109109+ slog.Debug("Persisted defaultHold migration to DID", "component", "profile", "migrated_did", migratedDID, "did", did)
110110+ }
111111+ }()
112112+ }
104113 }
105114 }
106115···113122 // Normalize defaultHold to DID if it's a URL
114123 // This ensures we always store DIDs, even if user provides a URL
115124 if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
116116- profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
117117- slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold)
125125+ if resolved, err := atproto.ResolveHoldDID(ctx, profile.DefaultHold); err != nil {
126126+ slog.Warn("Failed to resolve hold DID during profile update", "component", "profile", "defaultHold", profile.DefaultHold, "error", err)
127127+ } else {
128128+ profile.DefaultHold = resolved
129129+ slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold)
130130+ }
118131 }
119132120133 _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile)
+206-39
pkg/appview/storage/profile_test.go
···33import (
44 "context"
55 "encoding/json"
66+ "fmt"
67 "net/http"
78 "net/http/httptest"
89 "strings"
···2324 {
2425 name: "with DID",
2526 defaultHoldDID: "did:web:hold01.atcr.io",
2626- wantNormalized: "did:web:hold01.atcr.io",
2727- },
2828- {
2929- name: "with URL - should normalize to DID",
3030- defaultHoldDID: "https://hold01.atcr.io",
3127 wantNormalized: "did:web:hold01.atcr.io",
3228 },
3329 {
···104100 }
105101 })
106102 }
103103+104104+ // URL normalization test uses a local test server for /.well-known/atproto-did
105105+ t.Run("with URL - should normalize to DID", func(t *testing.T) {
106106+ var createdProfile *atproto.SailorProfileRecord
107107+108108+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109109+ // Handle hold DID resolution
110110+ if r.URL.Path == "/.well-known/atproto-did" {
111111+ w.Write([]byte("did:web:hold01.atcr.io"))
112112+ return
113113+ }
114114+115115+ // GetRecord: profile doesn't exist
116116+ if r.Method == "GET" {
117117+ w.WriteHeader(http.StatusNotFound)
118118+ return
119119+ }
120120+121121+ // PutRecord: create profile
122122+ if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
123123+ var body map[string]any
124124+ json.NewDecoder(r.Body).Decode(&body)
125125+126126+ recordData := body["record"].(map[string]any)
127127+ defaultHold := recordData["defaultHold"]
128128+ defaultHoldStr := ""
129129+ if defaultHold != nil {
130130+ defaultHoldStr = defaultHold.(string)
131131+ }
132132+ if defaultHoldStr != "did:web:hold01.atcr.io" {
133133+ t.Errorf("defaultHold = %v, want did:web:hold01.atcr.io", defaultHoldStr)
134134+ }
135135+136136+ profileBytes, _ := json.Marshal(recordData)
137137+ json.Unmarshal(profileBytes, &createdProfile)
138138+139139+ w.WriteHeader(http.StatusOK)
140140+ w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
141141+ return
142142+ }
143143+144144+ w.WriteHeader(http.StatusBadRequest)
145145+ }))
146146+ defer server.Close()
147147+148148+ client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
149149+ err := EnsureProfile(context.Background(), client, server.URL)
150150+ if err != nil {
151151+ t.Fatalf("EnsureProfile() error = %v", err)
152152+ }
153153+154154+ if createdProfile == nil {
155155+ t.Fatal("Profile was not created")
156156+ }
157157+158158+ if createdProfile.DefaultHold != "did:web:hold01.atcr.io" {
159159+ t.Errorf("DefaultHold = %v, want did:web:hold01.atcr.io", createdProfile.DefaultHold)
160160+ }
161161+ })
107162}
108163109164// TestEnsureProfile_Exists tests that EnsureProfile doesn't recreate existing profiles
···176231 wantNil: false,
177232 wantErr: false,
178233 expectMigration: false,
179179- expectedHoldDID: "did:web:hold01.atcr.io",
180180- },
181181- {
182182- name: "profile with URL (migration needed)",
183183- serverResponse: `{
184184- "uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
185185- "value": {
186186- "$type": "io.atcr.sailor.profile",
187187- "defaultHold": "https://hold01.atcr.io",
188188- "createdAt": "2025-01-01T00:00:00Z",
189189- "updatedAt": "2025-01-01T00:00:00Z"
190190- }
191191- }`,
192192- serverStatus: http.StatusOK,
193193- wantNil: false,
194194- wantErr: false,
195195- expectMigration: true,
196196- originalHoldURL: "https://hold01.atcr.io",
197234 expectedHoldDID: "did:web:hold01.atcr.io",
198235 },
199236 {
···293330 }
294331 })
295332 }
333333+334334+ // URL migration test uses a local test server for /.well-known/atproto-did
335335+ t.Run("profile with URL (migration needed)", func(t *testing.T) {
336336+ migrationLocks = sync.Map{}
337337+338338+ var mu sync.Mutex
339339+ putRecordCalled := false
340340+ var migrationRequest map[string]any
341341+342342+ var server *httptest.Server
343343+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
344344+ // Handle hold DID resolution
345345+ if r.URL.Path == "/.well-known/atproto-did" {
346346+ w.Write([]byte("did:web:hold01.atcr.io"))
347347+ return
348348+ }
349349+350350+ // GetRecord - return profile with URL pointing to this server
351351+ if r.Method == "GET" {
352352+ response := fmt.Sprintf(`{
353353+ "uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
354354+ "value": {
355355+ "$type": "io.atcr.sailor.profile",
356356+ "defaultHold": %q,
357357+ "createdAt": "2025-01-01T00:00:00Z",
358358+ "updatedAt": "2025-01-01T00:00:00Z"
359359+ }
360360+ }`, server.URL)
361361+ w.WriteHeader(http.StatusOK)
362362+ w.Write([]byte(response))
363363+ return
364364+ }
365365+366366+ // PutRecord (migration)
367367+ if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
368368+ mu.Lock()
369369+ putRecordCalled = true
370370+ json.NewDecoder(r.Body).Decode(&migrationRequest)
371371+ mu.Unlock()
372372+ w.WriteHeader(http.StatusOK)
373373+ w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
374374+ return
375375+ }
376376+ }))
377377+ defer server.Close()
378378+379379+ client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
380380+ profile, err := GetProfile(context.Background(), client)
381381+382382+ if err != nil {
383383+ t.Fatalf("GetProfile() error = %v", err)
384384+ }
385385+386386+ if profile == nil {
387387+ t.Fatal("GetProfile() returned nil, want profile")
388388+ }
389389+390390+ if profile.DefaultHold != "did:web:hold01.atcr.io" {
391391+ t.Errorf("DefaultHold = %v, want did:web:hold01.atcr.io", profile.DefaultHold)
392392+ }
393393+394394+ // Give migration goroutine time to execute
395395+ time.Sleep(50 * time.Millisecond)
396396+397397+ mu.Lock()
398398+ called := putRecordCalled
399399+ request := migrationRequest
400400+ mu.Unlock()
401401+402402+ if !called {
403403+ t.Error("Expected migration PutRecord to be called")
404404+ }
405405+406406+ if request != nil {
407407+ recordData := request["record"].(map[string]any)
408408+ migratedHold := recordData["defaultHold"]
409409+ if migratedHold != "did:web:hold01.atcr.io" {
410410+ t.Errorf("Migrated defaultHold = %v, want did:web:hold01.atcr.io", migratedHold)
411411+ }
412412+ }
413413+ })
296414}
297415298416// TestGetProfile_MigrationLocking tests that concurrent migrations don't happen
···303421 putRecordCount := 0
304422 var mu sync.Mutex
305423306306- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307307- // GetRecord - return profile with URL
424424+ var server *httptest.Server
425425+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
426426+ // Handle hold DID resolution
427427+ if r.URL.Path == "/.well-known/atproto-did" {
428428+ w.Write([]byte("did:web:hold01.atcr.io"))
429429+ return
430430+ }
431431+432432+ // GetRecord - return profile with URL pointing to this server
308433 if r.Method == "GET" {
309309- response := `{
434434+ response := fmt.Sprintf(`{
310435 "uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
311436 "value": {
312437 "$type": "io.atcr.sailor.profile",
313313- "defaultHold": "https://hold01.atcr.io",
438438+ "defaultHold": %q,
314439 "createdAt": "2025-01-01T00:00:00Z",
315440 "updatedAt": "2025-01-01T00:00:00Z"
316441 }
317317- }`
442442+ }`, server.URL)
318443 w.WriteHeader(http.StatusOK)
319444 w.Write([]byte(response))
320445 return
···384509 wantErr: false,
385510 },
386511 {
387387- name: "update with URL - should normalize",
388388- profile: &atproto.SailorProfileRecord{
389389- Type: atproto.SailorProfileCollection,
390390- DefaultHold: "https://hold02.atcr.io",
391391- CreatedAt: time.Now(),
392392- UpdatedAt: time.Now(),
393393- },
394394- wantNormalized: "did:web:hold02.atcr.io",
395395- wantErr: false,
396396- },
397397- {
398512 name: "clear default hold",
399513 profile: &atproto.SailorProfileRecord{
400514 Type: atproto.SailorProfileCollection,
···458572 }
459573 })
460574 }
575575+576576+ // URL normalization test uses a local test server for /.well-known/atproto-did
577577+ t.Run("update with URL - should normalize", func(t *testing.T) {
578578+ var sentProfile map[string]any
579579+580580+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
581581+ // Handle hold DID resolution
582582+ if r.URL.Path == "/.well-known/atproto-did" {
583583+ w.Write([]byte("did:web:hold02.atcr.io"))
584584+ return
585585+ }
586586+587587+ if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
588588+ var body map[string]any
589589+ json.NewDecoder(r.Body).Decode(&body)
590590+ sentProfile = body
591591+592592+ if body["rkey"] != ProfileRKey {
593593+ t.Errorf("rkey = %v, want %v", body["rkey"], ProfileRKey)
594594+ }
595595+596596+ w.WriteHeader(http.StatusOK)
597597+ w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
598598+ return
599599+ }
600600+ w.WriteHeader(http.StatusBadRequest)
601601+ }))
602602+ defer server.Close()
603603+604604+ profile := &atproto.SailorProfileRecord{
605605+ Type: atproto.SailorProfileCollection,
606606+ DefaultHold: server.URL, // URL pointing to test server with /.well-known/atproto-did
607607+ CreatedAt: time.Now(),
608608+ UpdatedAt: time.Now(),
609609+ }
610610+611611+ client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
612612+ err := UpdateProfile(context.Background(), client, profile)
613613+ if err != nil {
614614+ t.Errorf("UpdateProfile() error = %v", err)
615615+ return
616616+ }
617617+618618+ recordData := sentProfile["record"].(map[string]any)
619619+ defaultHold := recordData["defaultHold"].(string)
620620+ if defaultHold != "did:web:hold02.atcr.io" {
621621+ t.Errorf("defaultHold = %v, want did:web:hold02.atcr.io", defaultHold)
622622+ }
623623+624624+ if profile.DefaultHold != "did:web:hold02.atcr.io" {
625625+ t.Errorf("profile.DefaultHold = %v, want did:web:hold02.atcr.io (should be updated in-place)", profile.DefaultHold)
626626+ }
627627+ })
461628}
462629463630// TestProfileRKey tests that profile record key is always "self"
···33import (
44 "context"
55 "fmt"
66+ "io"
77+ "net/http"
68 "strings"
79810 "github.com/bluesky-social/indigo/atproto/syntax"
···30323133 // Fallback: assume it's a hostname and use HTTPS
3234 return "https://" + holdIdentifier, nil
3535+}
3636+3737+// ResolveHoldDID resolves a hold identifier (DID, URL, or hostname) to its actual DID.
3838+// If the input is already a DID, it is returned as-is.
3939+// If the input is a URL or hostname, the hold's /.well-known/atproto-did endpoint is
4040+// fetched to discover the real DID (which may be did:web or did:plc).
4141+func ResolveHoldDID(ctx context.Context, holdIdentifier string) (string, error) {
4242+ if holdIdentifier == "" {
4343+ return "", fmt.Errorf("empty hold identifier")
4444+ }
4545+4646+ // If already a DID, return as-is
4747+ if IsDID(holdIdentifier) {
4848+ return holdIdentifier, nil
4949+ }
5050+5151+ // Normalize to a full URL
5252+ holdURL := holdIdentifier
5353+ if !strings.HasPrefix(holdURL, "http://") && !strings.HasPrefix(holdURL, "https://") {
5454+ holdURL = "https://" + holdURL
5555+ }
5656+ holdURL = strings.TrimSuffix(holdURL, "/")
5757+5858+ // Fetch /.well-known/atproto-did to discover the hold's actual DID
5959+ req, err := http.NewRequestWithContext(ctx, "GET", holdURL+"/.well-known/atproto-did", nil)
6060+ if err != nil {
6161+ return "", fmt.Errorf("failed to create request for hold DID resolution: %w", err)
6262+ }
6363+6464+ resp, err := http.DefaultClient.Do(req)
6565+ if err != nil {
6666+ return "", fmt.Errorf("failed to fetch hold DID from %s: %w", holdURL, err)
6767+ }
6868+ defer resp.Body.Close()
6969+7070+ if resp.StatusCode != http.StatusOK {
7171+ return "", fmt.Errorf("hold at %s returned status %d for DID resolution", holdURL, resp.StatusCode)
7272+ }
7373+7474+ body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
7575+ if err != nil {
7676+ return "", fmt.Errorf("failed to read hold DID response: %w", err)
7777+ }
7878+7979+ did := strings.TrimSpace(string(body))
8080+ if !IsDID(did) {
8181+ return "", fmt.Errorf("hold at %s returned invalid DID: %q", holdURL, did)
8282+ }
8383+8484+ return did, nil
3385}
34863587// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
+7-3
pkg/hold/gc/gc.go
···621621 continue
622622 }
623623624624- if gc.manifestBelongsToHold(&manifest, holdDID) {
624624+ if gc.manifestBelongsToHold(ctx, &manifest, holdDID) {
625625 manifests = append(manifests, &manifestInfo{
626626 URI: rec.URI,
627627 UserDID: userDID,
···640640}
641641642642// manifestBelongsToHold checks if a manifest references this hold via HoldDID or legacy HoldEndpoint.
643643-func (gc *GarbageCollector) manifestBelongsToHold(manifest *atproto.ManifestRecord, holdDID string) bool {
643643+func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest *atproto.ManifestRecord, holdDID string) bool {
644644 if manifest.HoldDID == holdDID {
645645 return true
646646 }
647647 // Legacy: check holdEndpoint converted to DID
648648 if manifest.HoldEndpoint != "" {
649649- resolved := atproto.ResolveHoldDIDFromURL(manifest.HoldEndpoint)
649649+ resolved, err := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint)
650650+ if err != nil {
651651+ gc.logger.Debug("Failed to resolve hold DID from legacy endpoint", "holdEndpoint", manifest.HoldEndpoint, "error", err)
652652+ return false
653653+ }
650654 return resolved == holdDID
651655 }
652656 return false
···14931493 "operation", operation,
14941494 "digest", digest)
14951495 slog.Debug("Using XRPC proxy fallback")
14961496- proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation)
14961496+ proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation)
14971497 if proxyURL == "" {
14981498 return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")
14991499 }
···15041504 }
1505150515061506 // Fallback: return XRPC endpoint through this service
15071507- proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation)
15071507+ proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation)
15081508 if proxyURL == "" {
15091509 return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
15101510 }
···15231523// getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable)
15241524// For GET/HEAD operations, returns the XRPC getBlob endpoint
15251525// For PUT operations, this fallback is no longer supported - use multipart upload instead
15261526-func getProxyURL(publicURL string, digest, did string, operation string) string {
15261526+func getProxyURL(publicURL string, digest, holdDID string, operation string) string {
15271527 // For read operations, use XRPC getBlob endpoint
15281528 if operation == http.MethodGet || operation == http.MethodHead {
15291529- // Generate hold DID from public URL using shared function
15301530- holdDID := atproto.ResolveHoldDIDFromURL(publicURL)
15311529 return fmt.Sprintf("%s%s?did=%s&cid=%s",
15321530 publicURL, atproto.SyncGetBlob, holdDID, digest)
15331531 }
+1-1
pkg/hold/server.go
···192192193193 // Initialize scan broadcaster if scanner secret is configured
194194 if cfg.Scanner.Secret != "" {
195195- holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
195195+ holdDID := s.PDS.DID()
196196 var sb *pds.ScanBroadcaster
197197 if s.holdDB != nil {
198198 sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, s3Service, s.PDS)
+3-1
scanner/internal/scan/extractor.go
···142142143143 switch header.Typeflag {
144144 case tar.TypeDir:
145145- if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
145145+ // Always set owner write bit so we can create files inside (e.g. Go module
146146+ // cache dirs are 0555 in images, which would block subsequent writes)
147147+ if err := os.MkdirAll(target, os.FileMode(header.Mode)|0200); err != nil {
146148 return fmt.Errorf("failed to create directory %s: %w", target, err)
147149 }
148150