A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
fork

Configure Feed

Select the types of activity you want to include in your feed.

let appview work with did:plc based storage servers

+199 -178
+6 -3
pkg/appview/handlers/attestation_details.go
··· 263 263 // Two-hop flow: (1) get presigned URL from hold, (2) fetch blob from S3. 264 264 // serviceToken is optional — pass "" for public holds. 265 265 func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken string) ([]byte, error) { 266 - holdURL := atproto.ResolveHoldURL(holdEndpoint) 266 + holdURL, err := atproto.ResolveHoldURL(ctx, holdEndpoint) 267 + if err != nil { 268 + return nil, fmt.Errorf("could not resolve hold endpoint %s: %w", holdEndpoint, err) 269 + } 267 270 holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 268 - if holdURL == "" || holdDID == "" { 269 - return nil, fmt.Errorf("could not resolve hold endpoint: %s", holdEndpoint) 271 + if holdDID == "" { 272 + return nil, fmt.Errorf("could not resolve hold DID from: %s", holdEndpoint) 270 273 } 271 274 272 275 // Step 1: Request presigned URL from hold
+9 -2
pkg/appview/handlers/delete.go
··· 196 196 // deleteFromSingleHold deletes user data from a single hold 197 197 func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *db.User, holdDID, relationship string) HoldDeleteResult { 198 198 // Resolve hold DID to URL 199 - holdURL := atproto.ResolveHoldURL(holdDID) 200 - endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData" 199 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 201 200 202 201 result := HoldDeleteResult{ 203 202 HoldDID: holdDID, 204 203 Relationship: relationship, 205 204 Status: "failed", 206 205 } 206 + 207 + if err != nil { 208 + slog.Warn("Failed to resolve hold URL for deletion", "holdDid", holdDID, "error", err) 209 + result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err) 210 + return result 211 + } 212 + 213 + endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData" 207 214 208 215 // Check if we have OAuth refresher (needed for service tokens) 209 216 if h.Refresher == nil {
+1 -1
pkg/appview/handlers/device.go
··· 527 527 <h1>✓ Device Authorized!</h1> 528 528 <p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p> 529 529 <p>You can now close this window and return to your terminal.</p> 530 - <p><a href="/settings">View your authorized devices</a></p> 530 + <p><a href="/settings#devices">View your authorized devices</a></p> 531 531 </div> 532 532 </body> 533 533 </html>
+10 -3
pkg/appview/handlers/export.go
··· 167 167 // fetchSingleHoldExport fetches export data from a single hold 168 168 func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string, meta holdMetadata) HoldExportResult { 169 169 // Resolve hold DID to URL 170 - holdURL := atproto.ResolveHoldURL(holdDID) 171 - endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData" 170 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 172 171 173 172 result := HoldExportResult{ 174 173 HoldDID: holdDID, 175 - Endpoint: endpoint, 176 174 Relationship: meta.relationship, 177 175 FirstSeen: meta.firstSeen, 178 176 Status: "failed", 179 177 } 178 + 179 + if err != nil { 180 + slog.Warn("Failed to resolve hold URL for export", "holdDid", holdDID, "error", err) 181 + result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err) 182 + return result 183 + } 184 + 185 + endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData" 186 + result.Endpoint = endpoint 180 187 181 188 // Check if we have OAuth refresher (needed for service tokens) 182 189 if h.Refresher == nil {
+3 -3
pkg/appview/handlers/storage.go
··· 52 52 } 53 53 54 54 // Resolve hold URL from DID 55 - holdURL := atproto.ResolveHoldURL(holdDID) 56 - if holdURL == "" { 57 - slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID) 55 + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID) 56 + if err != nil { 57 + slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err) 58 58 h.renderError(w, "Failed to resolve hold service") 59 59 return 60 60 }
+9 -7
pkg/appview/handlers/subscription.go
··· 77 77 } 78 78 79 79 // Resolve hold DID to endpoint 80 - holdEndpoint := atproto.ResolveHoldURL(holdDID) 81 - if holdEndpoint == "" { 82 - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID) 80 + holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) 81 + if err != nil { 82 + slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) 83 83 h.renderHidden(w) 84 84 return 85 85 } ··· 197 197 } 198 198 199 199 // Resolve hold endpoint 200 - holdEndpoint := atproto.ResolveHoldURL(holdDID) 201 - if holdEndpoint == "" { 200 + holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) 201 + if err != nil { 202 + slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) 202 203 http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) 203 204 return 204 205 } ··· 285 286 } 286 287 287 288 // Resolve hold endpoint 288 - holdEndpoint := atproto.ResolveHoldURL(holdDID) 289 - if holdEndpoint == "" { 289 + holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) 290 + if err != nil { 291 + slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) 290 292 http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) 291 293 return 292 294 }
+5 -3
pkg/appview/holdhealth/checker.go
··· 51 51 // Checks {endpoint}/xrpc/_health and returns true if reachable 52 52 func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error) { 53 53 // Convert DID to HTTP URL if needed 54 - // did:web:hold.example.com → https://hold.example.com 55 - // https://hold.example.com → https://hold.example.com (passthrough) 56 - httpURL := atproto.ResolveHoldURL(endpoint) 54 + // Resolves any DID (did:web, did:plc) via identity directory 55 + httpURL, err := atproto.ResolveHoldURL(ctx, endpoint) 56 + if err != nil { 57 + return false, fmt.Errorf("failed to resolve hold URL: %w", err) 58 + } 57 59 58 60 // Build health check URL 59 61 healthURL := httpURL + "/xrpc/_health"
+5 -9
pkg/appview/holdhealth/checker_test.go
··· 65 65 checker := NewChecker(15 * time.Minute) 66 66 ctx := context.Background() 67 67 68 - // Test with DID format (did:web:host) 69 - // Extract host:port from test server URL 70 - // http://127.0.0.1:12345 → did:web:127.0.0.1:12345 71 - serverURL := server.URL 72 - didFormat := "did:web:" + serverURL[7:] // Remove "http://" 73 - 74 - reachable, err := checker.CheckHealth(ctx, didFormat) 68 + // Test with URL format (DID resolution requires real identity directory, 69 + // so we test with the URL format which passes through directly) 70 + reachable, err := checker.CheckHealth(ctx, server.URL) 75 71 if err != nil { 76 - t.Errorf("CheckHealth with DID returned error: %v", err) 72 + t.Errorf("CheckHealth with URL returned error: %v", err) 77 73 } 78 74 79 75 if !reachable { 80 - t.Error("Expected hold to be reachable with DID format") 76 + t.Error("Expected hold to be reachable with URL format") 81 77 } 82 78 } 83 79
+8 -2
pkg/appview/jetstream/backfill.go
··· 396 396 } 397 397 398 398 // Resolve hold DID to URL 399 - holdURL := atproto.ResolveHoldURL(holdDID) 399 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 400 + if err != nil { 401 + return fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err) 402 + } 400 403 401 404 // Create client for hold's PDS 402 405 holdClient := atproto.NewClient(holdURL, holdDID, "") ··· 442 445 // This is necessary for localhost/private holds that aren't discoverable via the relay 443 446 func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error { 444 447 // Resolve hold DID to URL 445 - holdURL := atproto.ResolveHoldURL(holdDID) 448 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 449 + if err != nil { 450 + return fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err) 451 + } 446 452 447 453 // Create client for hold's PDS 448 454 holdClient := atproto.NewClient(holdURL, holdDID, "")
+16 -12
pkg/appview/middleware/registry.go
··· 296 296 297 297 // Single-hop hold migration: check if this hold has declared a successor 298 298 holdDID = nr.resolveSuccessor(ctx, holdDID) 299 + 300 + // Resolve hold DID to HTTP URL via identity directory (cached 24h) 301 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 302 + if err != nil { 303 + return nil, fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err) 304 + } 305 + 299 306 // Auto-reconcile crew membership on first push/pull 300 307 // This ensures users can push immediately after docker login without web sign-in 301 308 // EnsureCrewMembership is best-effort and logs errors without failing the request ··· 463 470 DID: did, 464 471 Handle: handle, 465 472 HoldDID: holdDID, 473 + HoldURL: holdURL, 466 474 PDSEndpoint: pdsEndpoint, 467 475 Repository: repositoryName, 468 476 ServiceToken: serviceToken, // Cached service token from puller's PDS ··· 551 559 // isHoldReachable checks if a hold service is reachable 552 560 // Used in test mode to fallback to default hold when user's hold is unavailable 553 561 func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool { 554 - // Try to fetch the DID document 555 - hostname := strings.TrimPrefix(holdDID, "did:web:") 556 - 557 - // Try HTTP first (local), then HTTPS 558 - for _, scheme := range []string{"http", "https"} { 559 - testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname) 560 - client := atproto.NewClient("", "", "") 561 - _, err := client.FetchDIDDocument(ctx, testURL) 562 - if err == nil { 563 - return true 564 - } 562 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 563 + if err != nil { 564 + slog.Debug("Cannot resolve hold URL for reachability check", "component", "registry/middleware", "holdDID", holdDID, "error", err) 565 + return false 565 566 } 566 567 567 - return false 568 + testURL := holdURL + "/.well-known/did.json" 569 + client := atproto.NewClient("", "", "") 570 + _, err = client.FetchDIDDocument(ctx, testURL) 571 + return err == nil 568 572 } 569 573 570 574 // ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
+2 -4
pkg/appview/middleware/registry_test.go
··· 270 270 ctx := context.Background() 271 271 272 272 t.Run("reachable hold", func(t *testing.T) { 273 - // Extract hostname from test server URL 274 - // The mock server URL is like http://127.0.0.1:port, so we use the host part 275 - holdDID := fmt.Sprintf("did:web:%s", mockHold.Listener.Addr().String()) 276 - reachable := resolver.isHoldReachable(ctx, holdDID) 273 + // Use URL format directly — DID resolution requires real identity directory 274 + reachable := resolver.isHoldReachable(ctx, mockHold.URL) 277 275 assert.True(t, reachable, "should detect reachable hold") 278 276 }) 279 277
+2 -1
pkg/appview/storage/context.go
··· 21 21 // Puller = the authenticated user making the request (from JWT Subject) 22 22 DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123") 23 23 Handle string // Owner's handle (e.g., "alice.bsky.social") 24 - HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io") 24 + HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io" or "did:plc:abc123") 25 + HoldURL string // Resolved HTTP URL for the hold service 25 26 PDSEndpoint string // Owner's PDS endpoint URL 26 27 Repository string // Image repository name (e.g., "debian") 27 28 ServiceToken string // Service token for hold authentication (from puller's PDS)
+5 -1
pkg/appview/storage/crew.go
··· 30 30 } 31 31 32 32 // Resolve hold DID to HTTP endpoint 33 - holdEndpoint := atproto.ResolveHoldURL(holdDID) 33 + holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID) 34 + if err != nil { 35 + slog.Warn("failed to resolve hold URL", "holdDID", holdDID, "error", err) 36 + return 37 + } 34 38 35 39 // Get service token for the hold 36 40 // Only works with OAuth (refresher required) - app passwords can't get service tokens
+2 -3
pkg/appview/storage/manifest_store.go
··· 337 337 return nil 338 338 } 339 339 340 - // Resolve hold DID to HTTP endpoint 341 - // For did:web, this is straightforward (e.g., did:web:hold01.atcr.io → https://hold01.atcr.io) 342 - holdEndpoint := atproto.ResolveHoldURL(s.ctx.HoldDID) 340 + // Use pre-resolved hold URL from RegistryContext 341 + holdEndpoint := s.ctx.HoldURL 343 342 344 343 // Use service token from middleware (already cached and validated) 345 344 serviceToken := s.ctx.ServiceToken
+2 -2
pkg/appview/storage/proxy_blob_store.go
··· 40 40 41 41 // NewProxyBlobStore creates a new proxy blob store 42 42 func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore { 43 - // Resolve DID to URL once at construction time 44 - holdURL := atproto.ResolveHoldURL(ctx.HoldDID) 43 + // Use pre-resolved URL from RegistryContext (resolved in Registry.Repository()) 44 + holdURL := ctx.HoldURL 45 45 46 46 slog.Debug("NewProxyBlobStore created", "component", "proxy_blob_store", "hold_did", ctx.HoldDID, "hold_url", holdURL, "user_did", ctx.DID, "repo", ctx.Repository) 47 47
+15 -10
pkg/appview/storage/proxy_blob_store_test.go
··· 197 197 } 198 198 } 199 199 200 - // TestResolveHoldURL tests DID to URL conversion 200 + // TestResolveHoldURL tests URL passthrough (no network needed) 201 201 func TestResolveHoldURL(t *testing.T) { 202 + ctx := context.Background() 202 203 tests := []struct { 203 204 name string 204 - holdDID string 205 + holdURL string 205 206 expected string 206 207 }{ 207 208 { 208 - name: "did:web with http (TEST_MODE)", 209 - holdDID: "did:web:localhost:8080", 209 + name: "http URL passthrough", 210 + holdURL: "http://localhost:8080", 210 211 expected: "http://localhost:8080", 211 212 }, 212 213 { 213 - name: "did:web with https (production)", 214 - holdDID: "did:web:hold01.atcr.io", 214 + name: "https URL passthrough", 215 + holdURL: "https://hold01.atcr.io", 215 216 expected: "https://hold01.atcr.io", 216 217 }, 217 218 { 218 - name: "did:web with port", 219 - holdDID: "did:web:hold.example.com:3000", 219 + name: "http URL with port passthrough", 220 + holdURL: "http://hold.example.com:3000", 220 221 expected: "http://hold.example.com:3000", 221 222 }, 222 223 } 223 224 224 225 for _, tt := range tests { 225 226 t.Run(tt.name, func(t *testing.T) { 226 - result := atproto.ResolveHoldURL(tt.holdDID) 227 + result, err := atproto.ResolveHoldURL(ctx, tt.holdURL) 228 + if err != nil { 229 + t.Fatalf("Unexpected error: %v", err) 230 + } 227 231 if result != tt.expected { 228 232 t.Errorf("Expected %s, got %s", tt.expected, result) 229 233 } ··· 277 281 278 282 // TestNewProxyBlobStore tests ProxyBlobStore creation 279 283 func TestNewProxyBlobStore(t *testing.T) { 284 + expectedURL := "https://hold.example.com" 280 285 ctx := &RegistryContext{ 281 286 DID: "did:plc:test", 282 287 HoldDID: "did:web:hold.example.com", 288 + HoldURL: expectedURL, 283 289 PDSEndpoint: "https://pds.example.com", 284 290 Repository: "test-repo", 285 291 } ··· 298 304 t.Error("Expected holdURL to be set") 299 305 } 300 306 301 - expectedURL := "https://hold.example.com" 302 307 if store.holdURL != expectedURL { 303 308 t.Errorf("Expected holdURL %s, got %s", expectedURL, store.holdURL) 304 309 }
+43 -21
pkg/atproto/resolver.go
··· 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 ) 10 10 11 - // ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL 12 - // Handles both formats for backward compatibility: 13 - // - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io 14 - // - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080 15 - // - URL format: https://hold.example.com → https://hold.example.com (passthrough) 16 - func ResolveHoldURL(holdIdentifier string) string { 11 + // ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL. 12 + // For DIDs (both did:web and did:plc), resolves via the indigo identity directory 13 + // which caches results (24h TTL). Prefers the #atcr_hold service endpoint, 14 + // falls back to #atproto_pds. 15 + // 16 + // Supported formats: 17 + // - URL: https://hold.example.com → passthrough 18 + // - DID: did:web:hold01.atcr.io → resolved via /.well-known/did.json 19 + // - DID: did:plc:abc123 → resolved via PLC directory 20 + func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error) { 17 21 // If it's already a URL (has scheme), return as-is 18 22 if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") { 19 - return holdIdentifier 23 + return holdIdentifier, nil 20 24 } 21 25 22 - // If it's a DID, convert to URL 23 - if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok { 24 - hostname := after 25 - 26 - // Use HTTP for localhost/IP addresses with ports, HTTPS for domains 27 - if strings.Contains(hostname, ":") || 28 - strings.Contains(hostname, "127.0.0.1") || 29 - strings.Contains(hostname, "localhost") || 30 - // Check if it's an IP address (contains only digits and dots in first part) 31 - (len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') { 32 - return "http://" + hostname 33 - } 34 - return "https://" + hostname 26 + // If it's a DID, resolve via identity directory 27 + if strings.HasPrefix(holdIdentifier, "did:") { 28 + return ResolveHoldDIDToURL(ctx, holdIdentifier) 35 29 } 36 30 37 31 // Fallback: assume it's a hostname and use HTTPS 38 - return "https://" + holdIdentifier 32 + return "https://" + holdIdentifier, nil 33 + } 34 + 35 + // ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint. 36 + // Prefers the #atcr_hold service endpoint, falls back to #atproto_pds. 37 + // Uses the shared identity directory with cache TTL and event-driven invalidation. 38 + func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) { 39 + directory := GetDirectory() 40 + didParsed, err := syntax.ParseDID(did) 41 + if err != nil { 42 + return "", fmt.Errorf("invalid hold DID %q: %w", did, err) 43 + } 44 + 45 + ident, err := directory.LookupDID(ctx, didParsed) 46 + if err != nil { 47 + return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err) 48 + } 49 + 50 + // Prefer #atcr_hold service (hold-specific endpoint) 51 + if url := ident.GetServiceEndpoint("atcr_hold"); url != "" { 52 + return url, nil 53 + } 54 + 55 + // Fall back to #atproto_pds (hold publishes both with same URL) 56 + if url := ident.PDSEndpoint(); url != "" { 57 + return url, nil 58 + } 59 + 60 + return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did) 39 61 } 40 62 41 63 // ResolveDIDToPDS resolves a DID to its PDS endpoint.
+48 -89
pkg/atproto/resolver_test.go
··· 7 7 ) 8 8 9 9 func TestResolveHoldURL(t *testing.T) { 10 + ctx := context.Background() 11 + 12 + // URL passthrough and hostname fallback tests (no network needed) 10 13 tests := []struct { 11 14 name string 12 15 holdIdentifier string ··· 39 42 want: "http://hold.example.com/some/path", 40 43 }, 41 44 42 - // did:web to HTTPS (domain names) 43 - { 44 - name: "did:web domain to https", 45 - holdIdentifier: "did:web:hold01.atcr.io", 46 - want: "https://hold01.atcr.io", 47 - }, 48 - { 49 - name: "did:web subdomain to https", 50 - holdIdentifier: "did:web:my-hold.example.com", 51 - want: "https://my-hold.example.com", 52 - }, 53 - { 54 - name: "did:web simple domain to https", 55 - holdIdentifier: "did:web:example.com", 56 - want: "https://example.com", 57 - }, 58 - 59 - // did:web to HTTP (ports) 60 - { 61 - name: "did:web with port to http", 62 - holdIdentifier: "did:web:172.28.0.3:8080", 63 - want: "http://172.28.0.3:8080", 64 - }, 65 - { 66 - name: "did:web domain with port to http", 67 - holdIdentifier: "did:web:hold.example.com:8080", 68 - want: "http://hold.example.com:8080", 69 - }, 70 - { 71 - name: "did:web localhost with port to http", 72 - holdIdentifier: "did:web:localhost:8080", 73 - want: "http://localhost:8080", 74 - }, 75 - 76 - // did:web to HTTP (localhost) 77 - { 78 - name: "did:web localhost to http", 79 - holdIdentifier: "did:web:localhost", 80 - want: "http://localhost", 81 - }, 82 - 83 - // did:web to HTTP (127.0.0.1) 84 - { 85 - name: "did:web 127.0.0.1 to http", 86 - holdIdentifier: "did:web:127.0.0.1", 87 - want: "http://127.0.0.1", 88 - }, 89 - { 90 - name: "did:web 127.0.0.1 with port to http", 91 - holdIdentifier: "did:web:127.0.0.1:8080", 92 - want: "http://127.0.0.1:8080", 93 - }, 94 - 95 - // did:web to HTTP (IP addresses) 96 - { 97 - name: "did:web IPv4 address to http", 98 - holdIdentifier: "did:web:192.168.1.1", 99 - want: "http://192.168.1.1", 100 - }, 101 - { 102 - name: "did:web IPv4 with port to http", 103 - holdIdentifier: "did:web:10.0.0.5:3000", 104 - want: "http://10.0.0.5:3000", 105 - }, 106 - { 107 - name: "did:web private IP to http", 108 - holdIdentifier: "did:web:172.16.0.1", 109 - want: "http://172.16.0.1", 110 - }, 111 - 112 - // Fallback behavior (plain hostname) 45 + // Fallback behavior (plain hostname — not a DID, not a URL) 113 46 { 114 47 name: "plain hostname fallback to https", 115 48 holdIdentifier: "hold.example.com", ··· 127 60 holdIdentifier: "", 128 61 want: "https://", 129 62 }, 130 - { 131 - name: "did:web empty hostname", 132 - holdIdentifier: "did:web:", 133 - want: "https://", 134 - }, 135 - { 136 - name: "just did:web prefix", 137 - holdIdentifier: "did:web", 138 - want: "https://did:web", 139 - }, 140 63 } 141 64 142 65 for _, tt := range tests { 143 66 t.Run(tt.name, func(t *testing.T) { 144 - got := ResolveHoldURL(tt.holdIdentifier) 67 + got, err := ResolveHoldURL(ctx, tt.holdIdentifier) 68 + if err != nil { 69 + t.Errorf("ResolveHoldURL(%q) unexpected error: %v", tt.holdIdentifier, err) 70 + } 145 71 if got != tt.want { 146 72 t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.holdIdentifier, got, tt.want) 147 73 } ··· 149 75 } 150 76 } 151 77 152 - // TestResolveHoldURLRoundTrip tests that converting back and forth works 78 + // TestResolveHoldURLDIDRequiresNetwork tests that DID resolution requires 79 + // the identity directory (which needs network access) 80 + func TestResolveHoldURLDIDRequiresNetwork(t *testing.T) { 81 + ctx := context.Background() 82 + 83 + // did:web and did:plc both go through the identity directory now. 84 + // Without a real server, these should return errors. 85 + tests := []struct { 86 + name string 87 + holdIdentifier string 88 + }{ 89 + {"did:web nonexistent", "did:web:nonexistent.example.invalid"}, 90 + {"did:plc nonexistent", "did:plc:nonexistent000000000000"}, 91 + } 92 + 93 + for _, tt := range tests { 94 + t.Run(tt.name, func(t *testing.T) { 95 + _, err := ResolveHoldURL(ctx, tt.holdIdentifier) 96 + if err == nil { 97 + t.Errorf("ResolveHoldURL(%q) expected error for unresolvable DID, got nil", tt.holdIdentifier) 98 + } 99 + }) 100 + } 101 + } 102 + 103 + // TestResolveHoldURLRoundTrip tests that URL passthrough is idempotent 153 104 func TestResolveHoldURLRoundTrip(t *testing.T) { 105 + ctx := context.Background() 106 + 154 107 tests := []struct { 155 108 name string 156 109 input string 157 110 wantHTTP bool // true if result should be http, false for https 158 111 }{ 159 - {"domain to https and idempotent", "did:web:hold.atcr.io", false}, 160 - {"IP to http and idempotent", "did:web:192.168.1.1", true}, 161 - {"port to http and idempotent", "did:web:example.com:8080", true}, 112 + {"http URL idempotent", "http://192.168.1.1", true}, 113 + {"https URL idempotent", "https://hold.atcr.io", false}, 114 + {"http URL with port idempotent", "http://example.com:8080", true}, 162 115 } 163 116 164 117 for _, tt := range tests { 165 118 t.Run(tt.name, func(t *testing.T) { 166 - // First conversion 167 - first := ResolveHoldURL(tt.input) 119 + // First conversion (URL passthrough) 120 + first, err := ResolveHoldURL(ctx, tt.input) 121 + if err != nil { 122 + t.Fatalf("First ResolveHoldURL(%q) error: %v", tt.input, err) 123 + } 168 124 169 125 // Second conversion (should be idempotent since output is URL) 170 - second := ResolveHoldURL(first) 126 + second, err := ResolveHoldURL(ctx, first) 127 + if err != nil { 128 + t.Fatalf("Second ResolveHoldURL(%q) error: %v", first, err) 129 + } 171 130 172 131 if first != second { 173 132 t.Errorf("ResolveHoldURL is not idempotent: first=%q, second=%q", first, second)
+8 -2
pkg/auth/hold_remote.go
··· 222 222 // fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record 223 223 func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) { 224 224 // Resolve DID to URL 225 - holdURL := atproto.ResolveHoldURL(holdDID) 225 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 226 + if err != nil { 227 + return nil, fmt.Errorf("failed to resolve hold URL: %w", err) 228 + } 226 229 227 230 // Build XRPC request URL 228 231 // GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self ··· 327 330 // Uses O(1) lookup via getRecord with hash-based rkey instead of pagination 328 331 func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) { 329 332 // Resolve DID to URL 330 - holdURL := atproto.ResolveHoldURL(holdDID) 333 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 334 + if err != nil { 335 + return false, fmt.Errorf("failed to resolve hold URL: %w", err) 336 + } 331 337 332 338 // Generate deterministic rkey from member DID (hash-based) 333 339 rkey := atproto.CrewRecordKey(userDID)