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

let appview work with did:plc based storage servers

evan.jarrett.net abefcfd1 0d723cb7

verified
+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)