···263263// Two-hop flow: (1) get presigned URL from hold, (2) fetch blob from S3.
264264// serviceToken is optional — pass "" for public holds.
265265func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken string) ([]byte, error) {
266266- holdURL := atproto.ResolveHoldURL(holdEndpoint)
266266+ holdURL, err := atproto.ResolveHoldURL(ctx, holdEndpoint)
267267+ if err != nil {
268268+ return nil, fmt.Errorf("could not resolve hold endpoint %s: %w", holdEndpoint, err)
269269+ }
267270 holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
268268- if holdURL == "" || holdDID == "" {
269269- return nil, fmt.Errorf("could not resolve hold endpoint: %s", holdEndpoint)
271271+ if holdDID == "" {
272272+ return nil, fmt.Errorf("could not resolve hold DID from: %s", holdEndpoint)
270273 }
271274272275 // Step 1: Request presigned URL from hold
+9-2
pkg/appview/handlers/delete.go
···196196// deleteFromSingleHold deletes user data from a single hold
197197func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *db.User, holdDID, relationship string) HoldDeleteResult {
198198 // Resolve hold DID to URL
199199- holdURL := atproto.ResolveHoldURL(holdDID)
200200- endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
199199+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
201200202201 result := HoldDeleteResult{
203202 HoldDID: holdDID,
204203 Relationship: relationship,
205204 Status: "failed",
206205 }
206206+207207+ if err != nil {
208208+ slog.Warn("Failed to resolve hold URL for deletion", "holdDid", holdDID, "error", err)
209209+ result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err)
210210+ return result
211211+ }
212212+213213+ endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
207214208215 // Check if we have OAuth refresher (needed for service tokens)
209216 if h.Refresher == nil {
+1-1
pkg/appview/handlers/device.go
···527527 <h1>✓ Device Authorized!</h1>
528528 <p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
529529 <p>You can now close this window and return to your terminal.</p>
530530- <p><a href="/settings">View your authorized devices</a></p>
530530+ <p><a href="/settings#devices">View your authorized devices</a></p>
531531 </div>
532532</body>
533533</html>
+10-3
pkg/appview/handlers/export.go
···167167// fetchSingleHoldExport fetches export data from a single hold
168168func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string, meta holdMetadata) HoldExportResult {
169169 // Resolve hold DID to URL
170170- holdURL := atproto.ResolveHoldURL(holdDID)
171171- endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData"
170170+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
172171173172 result := HoldExportResult{
174173 HoldDID: holdDID,
175175- Endpoint: endpoint,
176174 Relationship: meta.relationship,
177175 FirstSeen: meta.firstSeen,
178176 Status: "failed",
179177 }
178178+179179+ if err != nil {
180180+ slog.Warn("Failed to resolve hold URL for export", "holdDid", holdDID, "error", err)
181181+ result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err)
182182+ return result
183183+ }
184184+185185+ endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData"
186186+ result.Endpoint = endpoint
180187181188 // Check if we have OAuth refresher (needed for service tokens)
182189 if h.Refresher == nil {
+3-3
pkg/appview/handlers/storage.go
···5252 }
53535454 // Resolve hold URL from DID
5555- holdURL := atproto.ResolveHoldURL(holdDID)
5656- if holdURL == "" {
5757- slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID)
5555+ holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID)
5656+ if err != nil {
5757+ slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err)
5858 h.renderError(w, "Failed to resolve hold service")
5959 return
6060 }
+9-7
pkg/appview/handlers/subscription.go
···7777 }
78787979 // Resolve hold DID to endpoint
8080- holdEndpoint := atproto.ResolveHoldURL(holdDID)
8181- if holdEndpoint == "" {
8282- slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID)
8080+ holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
8181+ if err != nil {
8282+ slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
8383 h.renderHidden(w)
8484 return
8585 }
···197197 }
198198199199 // Resolve hold endpoint
200200- holdEndpoint := atproto.ResolveHoldURL(holdDID)
201201- if holdEndpoint == "" {
200200+ holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
201201+ if err != nil {
202202+ slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
202203 http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
203204 return
204205 }
···285286 }
286287287288 // Resolve hold endpoint
288288- holdEndpoint := atproto.ResolveHoldURL(holdDID)
289289- if holdEndpoint == "" {
289289+ holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
290290+ if err != nil {
291291+ slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
290292 http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
291293 return
292294 }
+5-3
pkg/appview/holdhealth/checker.go
···5151// Checks {endpoint}/xrpc/_health and returns true if reachable
5252func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error) {
5353 // Convert DID to HTTP URL if needed
5454- // did:web:hold.example.com → https://hold.example.com
5555- // https://hold.example.com → https://hold.example.com (passthrough)
5656- httpURL := atproto.ResolveHoldURL(endpoint)
5454+ // Resolves any DID (did:web, did:plc) via identity directory
5555+ httpURL, err := atproto.ResolveHoldURL(ctx, endpoint)
5656+ if err != nil {
5757+ return false, fmt.Errorf("failed to resolve hold URL: %w", err)
5858+ }
57595860 // Build health check URL
5961 healthURL := httpURL + "/xrpc/_health"
+5-9
pkg/appview/holdhealth/checker_test.go
···6565 checker := NewChecker(15 * time.Minute)
6666 ctx := context.Background()
67676868- // Test with DID format (did:web:host)
6969- // Extract host:port from test server URL
7070- // http://127.0.0.1:12345 → did:web:127.0.0.1:12345
7171- serverURL := server.URL
7272- didFormat := "did:web:" + serverURL[7:] // Remove "http://"
7373-7474- reachable, err := checker.CheckHealth(ctx, didFormat)
6868+ // Test with URL format (DID resolution requires real identity directory,
6969+ // so we test with the URL format which passes through directly)
7070+ reachable, err := checker.CheckHealth(ctx, server.URL)
7571 if err != nil {
7676- t.Errorf("CheckHealth with DID returned error: %v", err)
7272+ t.Errorf("CheckHealth with URL returned error: %v", err)
7773 }
78747975 if !reachable {
8080- t.Error("Expected hold to be reachable with DID format")
7676+ t.Error("Expected hold to be reachable with URL format")
8177 }
8278}
8379
+8-2
pkg/appview/jetstream/backfill.go
···396396 }
397397398398 // Resolve hold DID to URL
399399- holdURL := atproto.ResolveHoldURL(holdDID)
399399+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
400400+ if err != nil {
401401+ return fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
402402+ }
400403401404 // Create client for hold's PDS
402405 holdClient := atproto.NewClient(holdURL, holdDID, "")
···442445// This is necessary for localhost/private holds that aren't discoverable via the relay
443446func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error {
444447 // Resolve hold DID to URL
445445- holdURL := atproto.ResolveHoldURL(holdDID)
448448+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
449449+ if err != nil {
450450+ return fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
451451+ }
446452447453 // Create client for hold's PDS
448454 holdClient := atproto.NewClient(holdURL, holdDID, "")
+16-12
pkg/appview/middleware/registry.go
···296296297297 // Single-hop hold migration: check if this hold has declared a successor
298298 holdDID = nr.resolveSuccessor(ctx, holdDID)
299299+300300+ // Resolve hold DID to HTTP URL via identity directory (cached 24h)
301301+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
302302+ if err != nil {
303303+ return nil, fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
304304+ }
305305+299306 // Auto-reconcile crew membership on first push/pull
300307 // This ensures users can push immediately after docker login without web sign-in
301308 // EnsureCrewMembership is best-effort and logs errors without failing the request
···463470 DID: did,
464471 Handle: handle,
465472 HoldDID: holdDID,
473473+ HoldURL: holdURL,
466474 PDSEndpoint: pdsEndpoint,
467475 Repository: repositoryName,
468476 ServiceToken: serviceToken, // Cached service token from puller's PDS
···551559// isHoldReachable checks if a hold service is reachable
552560// Used in test mode to fallback to default hold when user's hold is unavailable
553561func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
554554- // Try to fetch the DID document
555555- hostname := strings.TrimPrefix(holdDID, "did:web:")
556556-557557- // Try HTTP first (local), then HTTPS
558558- for _, scheme := range []string{"http", "https"} {
559559- testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
560560- client := atproto.NewClient("", "", "")
561561- _, err := client.FetchDIDDocument(ctx, testURL)
562562- if err == nil {
563563- return true
564564- }
562562+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
563563+ if err != nil {
564564+ slog.Debug("Cannot resolve hold URL for reachability check", "component", "registry/middleware", "holdDID", holdDID, "error", err)
565565+ return false
565566 }
566567567567- return false
568568+ testURL := holdURL + "/.well-known/did.json"
569569+ client := atproto.NewClient("", "", "")
570570+ _, err = client.FetchDIDDocument(ctx, testURL)
571571+ return err == nil
568572}
569573570574// 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
···270270 ctx := context.Background()
271271272272 t.Run("reachable hold", func(t *testing.T) {
273273- // Extract hostname from test server URL
274274- // The mock server URL is like http://127.0.0.1:port, so we use the host part
275275- holdDID := fmt.Sprintf("did:web:%s", mockHold.Listener.Addr().String())
276276- reachable := resolver.isHoldReachable(ctx, holdDID)
273273+ // Use URL format directly — DID resolution requires real identity directory
274274+ reachable := resolver.isHoldReachable(ctx, mockHold.URL)
277275 assert.True(t, reachable, "should detect reachable hold")
278276 })
279277
+2-1
pkg/appview/storage/context.go
···2121 // Puller = the authenticated user making the request (from JWT Subject)
2222 DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123")
2323 Handle string // Owner's handle (e.g., "alice.bsky.social")
2424- HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
2424+ HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io" or "did:plc:abc123")
2525+ HoldURL string // Resolved HTTP URL for the hold service
2526 PDSEndpoint string // Owner's PDS endpoint URL
2627 Repository string // Image repository name (e.g., "debian")
2728 ServiceToken string // Service token for hold authentication (from puller's PDS)
+5-1
pkg/appview/storage/crew.go
···3030 }
31313232 // Resolve hold DID to HTTP endpoint
3333- holdEndpoint := atproto.ResolveHoldURL(holdDID)
3333+ holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID)
3434+ if err != nil {
3535+ slog.Warn("failed to resolve hold URL", "holdDID", holdDID, "error", err)
3636+ return
3737+ }
34383539 // Get service token for the hold
3640 // Only works with OAuth (refresher required) - app passwords can't get service tokens
+2-3
pkg/appview/storage/manifest_store.go
···337337 return nil
338338 }
339339340340- // Resolve hold DID to HTTP endpoint
341341- // For did:web, this is straightforward (e.g., did:web:hold01.atcr.io → https://hold01.atcr.io)
342342- holdEndpoint := atproto.ResolveHoldURL(s.ctx.HoldDID)
340340+ // Use pre-resolved hold URL from RegistryContext
341341+ holdEndpoint := s.ctx.HoldURL
343342344343 // Use service token from middleware (already cached and validated)
345344 serviceToken := s.ctx.ServiceToken
+2-2
pkg/appview/storage/proxy_blob_store.go
···40404141// NewProxyBlobStore creates a new proxy blob store
4242func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
4343- // Resolve DID to URL once at construction time
4444- holdURL := atproto.ResolveHoldURL(ctx.HoldDID)
4343+ // Use pre-resolved URL from RegistryContext (resolved in Registry.Repository())
4444+ holdURL := ctx.HoldURL
45454646 slog.Debug("NewProxyBlobStore created", "component", "proxy_blob_store", "hold_did", ctx.HoldDID, "hold_url", holdURL, "user_did", ctx.DID, "repo", ctx.Repository)
4747
···88 "github.com/bluesky-social/indigo/atproto/syntax"
99)
10101111-// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
1212-// Handles both formats for backward compatibility:
1313-// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
1414-// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
1515-// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
1616-func ResolveHoldURL(holdIdentifier string) string {
1111+// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL.
1212+// For DIDs (both did:web and did:plc), resolves via the indigo identity directory
1313+// which caches results (24h TTL). Prefers the #atcr_hold service endpoint,
1414+// falls back to #atproto_pds.
1515+//
1616+// Supported formats:
1717+// - URL: https://hold.example.com → passthrough
1818+// - DID: did:web:hold01.atcr.io → resolved via /.well-known/did.json
1919+// - DID: did:plc:abc123 → resolved via PLC directory
2020+func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error) {
1721 // If it's already a URL (has scheme), return as-is
1822 if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
1919- return holdIdentifier
2323+ return holdIdentifier, nil
2024 }
21252222- // If it's a DID, convert to URL
2323- if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok {
2424- hostname := after
2525-2626- // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
2727- if strings.Contains(hostname, ":") ||
2828- strings.Contains(hostname, "127.0.0.1") ||
2929- strings.Contains(hostname, "localhost") ||
3030- // Check if it's an IP address (contains only digits and dots in first part)
3131- (len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
3232- return "http://" + hostname
3333- }
3434- return "https://" + hostname
2626+ // If it's a DID, resolve via identity directory
2727+ if strings.HasPrefix(holdIdentifier, "did:") {
2828+ return ResolveHoldDIDToURL(ctx, holdIdentifier)
3529 }
36303731 // Fallback: assume it's a hostname and use HTTPS
3838- return "https://" + holdIdentifier
3232+ return "https://" + holdIdentifier, nil
3333+}
3434+3535+// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
3636+// Prefers the #atcr_hold service endpoint, falls back to #atproto_pds.
3737+// Uses the shared identity directory with cache TTL and event-driven invalidation.
3838+func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) {
3939+ directory := GetDirectory()
4040+ didParsed, err := syntax.ParseDID(did)
4141+ if err != nil {
4242+ return "", fmt.Errorf("invalid hold DID %q: %w", did, err)
4343+ }
4444+4545+ ident, err := directory.LookupDID(ctx, didParsed)
4646+ if err != nil {
4747+ return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err)
4848+ }
4949+5050+ // Prefer #atcr_hold service (hold-specific endpoint)
5151+ if url := ident.GetServiceEndpoint("atcr_hold"); url != "" {
5252+ return url, nil
5353+ }
5454+5555+ // Fall back to #atproto_pds (hold publishes both with same URL)
5656+ if url := ident.PDSEndpoint(); url != "" {
5757+ return url, nil
5858+ }
5959+6060+ return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
3961}
40624163// ResolveDIDToPDS resolves a DID to its PDS endpoint.
+48-89
pkg/atproto/resolver_test.go
···77)
8899func TestResolveHoldURL(t *testing.T) {
1010+ ctx := context.Background()
1111+1212+ // URL passthrough and hostname fallback tests (no network needed)
1013 tests := []struct {
1114 name string
1215 holdIdentifier string
···3942 want: "http://hold.example.com/some/path",
4043 },
41444242- // did:web to HTTPS (domain names)
4343- {
4444- name: "did:web domain to https",
4545- holdIdentifier: "did:web:hold01.atcr.io",
4646- want: "https://hold01.atcr.io",
4747- },
4848- {
4949- name: "did:web subdomain to https",
5050- holdIdentifier: "did:web:my-hold.example.com",
5151- want: "https://my-hold.example.com",
5252- },
5353- {
5454- name: "did:web simple domain to https",
5555- holdIdentifier: "did:web:example.com",
5656- want: "https://example.com",
5757- },
5858-5959- // did:web to HTTP (ports)
6060- {
6161- name: "did:web with port to http",
6262- holdIdentifier: "did:web:172.28.0.3:8080",
6363- want: "http://172.28.0.3:8080",
6464- },
6565- {
6666- name: "did:web domain with port to http",
6767- holdIdentifier: "did:web:hold.example.com:8080",
6868- want: "http://hold.example.com:8080",
6969- },
7070- {
7171- name: "did:web localhost with port to http",
7272- holdIdentifier: "did:web:localhost:8080",
7373- want: "http://localhost:8080",
7474- },
7575-7676- // did:web to HTTP (localhost)
7777- {
7878- name: "did:web localhost to http",
7979- holdIdentifier: "did:web:localhost",
8080- want: "http://localhost",
8181- },
8282-8383- // did:web to HTTP (127.0.0.1)
8484- {
8585- name: "did:web 127.0.0.1 to http",
8686- holdIdentifier: "did:web:127.0.0.1",
8787- want: "http://127.0.0.1",
8888- },
8989- {
9090- name: "did:web 127.0.0.1 with port to http",
9191- holdIdentifier: "did:web:127.0.0.1:8080",
9292- want: "http://127.0.0.1:8080",
9393- },
9494-9595- // did:web to HTTP (IP addresses)
9696- {
9797- name: "did:web IPv4 address to http",
9898- holdIdentifier: "did:web:192.168.1.1",
9999- want: "http://192.168.1.1",
100100- },
101101- {
102102- name: "did:web IPv4 with port to http",
103103- holdIdentifier: "did:web:10.0.0.5:3000",
104104- want: "http://10.0.0.5:3000",
105105- },
106106- {
107107- name: "did:web private IP to http",
108108- holdIdentifier: "did:web:172.16.0.1",
109109- want: "http://172.16.0.1",
110110- },
111111-112112- // Fallback behavior (plain hostname)
4545+ // Fallback behavior (plain hostname — not a DID, not a URL)
11346 {
11447 name: "plain hostname fallback to https",
11548 holdIdentifier: "hold.example.com",
···12760 holdIdentifier: "",
12861 want: "https://",
12962 },
130130- {
131131- name: "did:web empty hostname",
132132- holdIdentifier: "did:web:",
133133- want: "https://",
134134- },
135135- {
136136- name: "just did:web prefix",
137137- holdIdentifier: "did:web",
138138- want: "https://did:web",
139139- },
14063 }
1416414265 for _, tt := range tests {
14366 t.Run(tt.name, func(t *testing.T) {
144144- got := ResolveHoldURL(tt.holdIdentifier)
6767+ got, err := ResolveHoldURL(ctx, tt.holdIdentifier)
6868+ if err != nil {
6969+ t.Errorf("ResolveHoldURL(%q) unexpected error: %v", tt.holdIdentifier, err)
7070+ }
14571 if got != tt.want {
14672 t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.holdIdentifier, got, tt.want)
14773 }
···14975 }
15076}
15177152152-// TestResolveHoldURLRoundTrip tests that converting back and forth works
7878+// TestResolveHoldURLDIDRequiresNetwork tests that DID resolution requires
7979+// the identity directory (which needs network access)
8080+func TestResolveHoldURLDIDRequiresNetwork(t *testing.T) {
8181+ ctx := context.Background()
8282+8383+ // did:web and did:plc both go through the identity directory now.
8484+ // Without a real server, these should return errors.
8585+ tests := []struct {
8686+ name string
8787+ holdIdentifier string
8888+ }{
8989+ {"did:web nonexistent", "did:web:nonexistent.example.invalid"},
9090+ {"did:plc nonexistent", "did:plc:nonexistent000000000000"},
9191+ }
9292+9393+ for _, tt := range tests {
9494+ t.Run(tt.name, func(t *testing.T) {
9595+ _, err := ResolveHoldURL(ctx, tt.holdIdentifier)
9696+ if err == nil {
9797+ t.Errorf("ResolveHoldURL(%q) expected error for unresolvable DID, got nil", tt.holdIdentifier)
9898+ }
9999+ })
100100+ }
101101+}
102102+103103+// TestResolveHoldURLRoundTrip tests that URL passthrough is idempotent
153104func TestResolveHoldURLRoundTrip(t *testing.T) {
105105+ ctx := context.Background()
106106+154107 tests := []struct {
155108 name string
156109 input string
157110 wantHTTP bool // true if result should be http, false for https
158111 }{
159159- {"domain to https and idempotent", "did:web:hold.atcr.io", false},
160160- {"IP to http and idempotent", "did:web:192.168.1.1", true},
161161- {"port to http and idempotent", "did:web:example.com:8080", true},
112112+ {"http URL idempotent", "http://192.168.1.1", true},
113113+ {"https URL idempotent", "https://hold.atcr.io", false},
114114+ {"http URL with port idempotent", "http://example.com:8080", true},
162115 }
163116164117 for _, tt := range tests {
165118 t.Run(tt.name, func(t *testing.T) {
166166- // First conversion
167167- first := ResolveHoldURL(tt.input)
119119+ // First conversion (URL passthrough)
120120+ first, err := ResolveHoldURL(ctx, tt.input)
121121+ if err != nil {
122122+ t.Fatalf("First ResolveHoldURL(%q) error: %v", tt.input, err)
123123+ }
168124169125 // Second conversion (should be idempotent since output is URL)
170170- second := ResolveHoldURL(first)
126126+ second, err := ResolveHoldURL(ctx, first)
127127+ if err != nil {
128128+ t.Fatalf("Second ResolveHoldURL(%q) error: %v", first, err)
129129+ }
171130172131 if first != second {
173132 t.Errorf("ResolveHoldURL is not idempotent: first=%q, second=%q", first, second)
+8-2
pkg/auth/hold_remote.go
···222222// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
223223func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
224224 // Resolve DID to URL
225225- holdURL := atproto.ResolveHoldURL(holdDID)
225225+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
226226+ if err != nil {
227227+ return nil, fmt.Errorf("failed to resolve hold URL: %w", err)
228228+ }
226229227230 // Build XRPC request URL
228231 // GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
···327330// Uses O(1) lookup via getRecord with hash-based rkey instead of pagination
328331func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
329332 // Resolve DID to URL
330330- holdURL := atproto.ResolveHoldURL(holdDID)
333333+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
334334+ if err != nil {
335335+ return false, fmt.Errorf("failed to resolve hold URL: %w", err)
336336+ }
331337332338 // Generate deterministic rkey from member DID (hash-based)
333339 rkey := atproto.CrewRecordKey(userDID)