···27272828// ClientIDConfig helps construct appropriate client IDs for different environments
2929type ClientIDConfig struct {
3030- BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com")
3030+ BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com")
3131 CallbackPath string // Callback path (e.g., "/oauth/callback")
3232- Scopes []string // OAuth scopes
3232+ Scopes []string // OAuth scopes
3333}
34343535// MakeClientID creates the appropriate client ID based on the environment
+3-2
pkg/auth/oauth/flow.go
···24242525// RunInteractiveFlow executes an interactive OAuth authorization code flow
2626// The setupCallback function is called TWICE:
2727-// 1. First with authURL="" to start the server (before PAR)
2828-// 2. Then with the actual authURL to display it to the user (after PAR)
2727+// 1. First with authURL="" to start the server (before PAR)
2828+// 2. Then with the actual authURL to display it to the user (after PAR)
2929+//
2930// This two-phase approach ensures the server is running before PAR tries to fetch client metadata
3031func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
3132 setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) {
+59-17
pkg/auth/oauth/refresher.go
···1515type AccessTokenEntry struct {
1616 Token string
1717 DPoPKey *ecdsa.PrivateKey
1818+ Transport *DPoPTransport // Cache the transport to preserve nonce across requests
1819 ExpiresAt time.Time
1920}
20212122// Refresher manages OAuth token refresh for AppView
2223type Refresher struct {
2323- storage *RefreshTokenStorage
2424- accessTokens map[string]*AccessTokenEntry
2525- mu sync.RWMutex
2626- clientID string
2727- redirectURI string
2424+ storage *RefreshTokenStorage
2525+ accessTokens map[string]*AccessTokenEntry
2626+ mu sync.RWMutex
2727+ refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
2828+ refreshLockMu sync.Mutex // Protects refreshLocks map
2929+ clientID string
3030+ redirectURI string
2831}
29323033// NewRefresher creates a new token refresher
···3235 return &Refresher{
3336 storage: storage,
3437 accessTokens: make(map[string]*AccessTokenEntry),
3838+ refreshLocks: make(map[string]*sync.Mutex),
3539 clientID: clientID,
3640 redirectURI: redirectURI,
3741 }
···39434044// GetAccessToken gets a fresh access token for a DID
4145// Returns cached token if still valid, otherwise refreshes
4242-func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) {
4343- // Check cache first
4646+// Returns: accessToken, dpopKey, dpopTransport, error
4747+func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, *DPoPTransport, error) {
4848+ // Check cache first (fast path)
4449 r.mu.RLock()
4550 entry, ok := r.accessTokens[did]
4651 r.mu.RUnlock()
47524853 if ok && time.Now().Before(entry.ExpiresAt) {
4954 // Token still valid
5050- return entry.Token, entry.DPoPKey, nil
5555+ return entry.Token, entry.DPoPKey, entry.Transport, nil
5656+ }
5757+5858+ // Token expired or not cached, need to refresh
5959+ // Get or create per-DID lock to prevent concurrent refreshes
6060+ r.refreshLockMu.Lock()
6161+ didLock, ok := r.refreshLocks[did]
6262+ if !ok {
6363+ didLock = &sync.Mutex{}
6464+ r.refreshLocks[did] = didLock
5165 }
6666+ r.refreshLockMu.Unlock()
52675353- // Token expired or not cached, refresh it
6868+ // Acquire DID-specific lock
6969+ didLock.Lock()
7070+ defer didLock.Unlock()
7171+7272+ // Double-check cache after acquiring lock (another goroutine might have refreshed)
7373+ r.mu.RLock()
7474+ entry, ok = r.accessTokens[did]
7575+ r.mu.RUnlock()
7676+7777+ if ok && time.Now().Before(entry.ExpiresAt) {
7878+ // Token was refreshed while we waited for the lock
7979+ return entry.Token, entry.DPoPKey, entry.Transport, nil
8080+ }
8181+8282+ // Actually refresh the token
5483 return r.RefreshToken(ctx, did)
5584}
56855786// RefreshToken forces a token refresh for a DID
5858-func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) {
8787+// Returns: accessToken, dpopKey, dpopTransport, error
8888+func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, *DPoPTransport, error) {
5989 // Get stored refresh token
6090 entry, err := r.storage.Get(did)
6191 if err != nil {
6262- return "", nil, fmt.Errorf("failed to get stored refresh token: %w", err)
9292+ return "", nil, nil, fmt.Errorf("failed to get stored refresh token: %w", err)
6393 }
64946595 // Parse DPoP key
6696 dpopKey, err := r.storage.GetDPoPKey(did)
6797 if err != nil {
6868- return "", nil, fmt.Errorf("failed to get DPoP key: %w", err)
9898+ return "", nil, nil, fmt.Errorf("failed to get DPoP key: %w", err)
6999 }
7010071101 // Create OAuth client with DPoP transport
···75105 // Discover PDS OAuth metadata
76106 metadata, err := DiscoverAuthServer(ctx, entry.PDS)
77107 if err != nil {
7878- return "", nil, fmt.Errorf("failed to discover auth server: %w", err)
108108+ return "", nil, nil, fmt.Errorf("failed to discover auth server: %w", err)
79109 }
8011081111 // Configure OAuth2 client
···87117 PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
88118 },
89119 RedirectURL: r.redirectURI,
9090- Scopes: []string{"atproto"},
120120+ Scopes: GetDefaultScopes(),
91121 }
9212293123 // Create context with custom HTTP client
···98128 RefreshToken: entry.RefreshToken,
99129 }).Token()
100130 if err != nil {
101101- return "", nil, fmt.Errorf("failed to refresh token: %w", err)
131131+ return "", nil, nil, fmt.Errorf("failed to refresh token: %w", err)
102132 }
103133104134 // Update last refresh timestamp
···116146 }
117147 }
118148119119- // Cache the access token
149149+ // Set access token on transport for "ath" claim in future DPoP proofs
150150+ dpopTransport.SetAccessToken(token.AccessToken)
151151+152152+ // Cache the access token and transport
120153 // Expire 1 minute early to avoid edge cases
121154 expiresAt := token.Expiry.Add(-1 * time.Minute)
122155···124157 r.accessTokens[did] = &AccessTokenEntry{
125158 Token: token.AccessToken,
126159 DPoPKey: dpopKey,
160160+ Transport: dpopTransport, // Cache transport to preserve nonce
127161 ExpiresAt: expiresAt,
128162 }
129163 r.mu.Unlock()
130164131131- return token.AccessToken, dpopKey, nil
165165+ return token.AccessToken, dpopKey, dpopTransport, nil
166166+}
167167+168168+// InvalidateAccessToken removes a cached access token for a DID
169169+// This is useful when a new refresh token is obtained (e.g., after re-authorization)
170170+func (r *Refresher) InvalidateAccessToken(did string) {
171171+ r.mu.Lock()
172172+ delete(r.accessTokens, did)
173173+ r.mu.Unlock()
132174}
133175134176// RevokeToken removes stored refresh token and cached access token
···55 "encoding/json"
66 "fmt"
77 "strings"
88+ "sync"
89910 "github.com/distribution/distribution/v3"
1011 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
···3536 distribution.Namespace
3637 resolver *atproto.Resolver
3738 defaultStorageEndpoint string
3939+ repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
3840}
39414042// initATProtoResolver initializes the name resolution middleware
···115117116118 if globalRefresher != nil {
117119 // Try OAuth flow first
118118- accessToken, dpopKey, err := globalRefresher.GetAccessToken(ctx, did)
120120+ accessToken, dpopKey, dpopTransport, err := globalRefresher.GetAccessToken(ctx, did)
119121 if err == nil {
120120- // OAuth token available - create client with DPoP support
121121- fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s\n", did)
122122- dpopTransport := oauth.NewDPoPTransport(nil, dpopKey)
122122+ // OAuth token available - use cached DPoP transport (preserves nonce)
123123+ fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s (length=%d, first_20=%q)\n", did, len(accessToken), accessToken[:min(20, len(accessToken))])
123124 atprotoClient = atproto.NewClientWithDPoP(pdsEndpoint, did, accessToken, dpopKey, dpopTransport)
124125 } else {
125126 fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err)
···143144 // Example: "evan.jarrett.net/debian" -> store as "debian"
144145 repositoryName := imageName
145146146146- fmt.Printf("DEBUG [registry/middleware]: Creating RoutingRepository for image=%s (ATProto repo name)\n", repositoryName)
147147+ // Cache key is DID + repository name
148148+ cacheKey := did + ":" + repositoryName
149149+150150+ // Check cache first
151151+ if cached, ok := nr.repositories.Load(cacheKey); ok {
152152+ fmt.Printf("DEBUG [registry/middleware]: Using cached RoutingRepository for %s\n", cacheKey)
153153+ return cached.(*storage.RoutingRepository), nil
154154+ }
155155+156156+ fmt.Printf("DEBUG [registry/middleware]: Creating new RoutingRepository for image=%s (ATProto repo name)\n", repositoryName)
147157148158 // Create routing repository - routes manifests to ATProto, blobs to hold service
149159 // The registry is stateless - no local storage is used
150160 // Pass storage endpoint and DID as parameters (can't use context as it gets lost)
151161 routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did)
162162+163163+ // Cache the repository
164164+ nr.repositories.Store(cacheKey, routingRepo)
152165153166 return routingRepo, nil
154167}
+193-48
pkg/storage/proxy_blob_store.go
···1414 "github.com/opencontainers/go-digest"
1515)
16161717+const (
1818+ // maxChunkSize is the maximum buffer size before flushing to hold service
1919+ // Matches S3's minimum multipart upload size
2020+ maxChunkSize = 5 * 1024 * 1024 // 5MB
2121+)
2222+1723// Global upload tracking (shared across all ProxyBlobStore instances)
1824// This is necessary because distribution creates new repository/blob store instances per request
1925var (
···3541 storageEndpoint: storageEndpoint,
3642 httpClient: &http.Client{
3743 Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads
4444+ Transport: &http.Transport{
4545+ DisableKeepAlives: false, // Re-enable keep-alive
4646+ MaxIdleConns: 100,
4747+ MaxIdleConnsPerHost: 100,
4848+ MaxConnsPerHost: 0, // unlimited
4949+ IdleConnTimeout: 90 * time.Second,
5050+ },
3851 },
3952 did: did,
4053 }
···42554356// Stat returns the descriptor for a blob
4457func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
4545- // For simplicity, we'll just check if we can get a download URL
4646- // In production, you'd want a dedicated stat endpoint
4747- url, err := p.getDownloadURL(ctx, dgst)
5858+ // Quick HEAD request to hold service to check if blob exists
5959+ url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, dgst.String(), p.did)
6060+ req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
6161+ if err != nil {
6262+ return distribution.Descriptor{}, distribution.ErrBlobUnknown
6363+ }
6464+6565+ resp, err := p.httpClient.Do(req)
4866 if err != nil {
4967 return distribution.Descriptor{}, distribution.ErrBlobUnknown
5068 }
6969+ defer resp.Body.Close()
51705252- // We don't have size info from the storage service
5353- // Return a minimal descriptor
7171+ if resp.StatusCode != http.StatusOK {
7272+ return distribution.Descriptor{}, distribution.ErrBlobUnknown
7373+ }
7474+7575+ // Return a minimal descriptor with size from Content-Length if available
7676+ size := int64(0)
7777+ if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
7878+ fmt.Sscanf(contentLength, "%d", &size)
7979+ }
8080+5481 return distribution.Descriptor{
5582 Digest: dgst,
8383+ Size: size,
5684 MediaType: "application/octet-stream",
5757- URLs: []string{url},
5885 }, nil
5986}
6087···167194 }
168195 }
169196170170- // Create proxy blob writer
197197+ // Create pipe for streaming upload
198198+ pipeReader, pipeWriter := io.Pipe()
199199+ uploadErr := make(chan error, 1)
200200+ digestChan := make(chan string, 1)
201201+202202+ // Create writer
171203 writer := &ProxyBlobWriter{
172172- store: p,
173173- ctx: ctx,
174174- options: opts,
175175- id: fmt.Sprintf("upload-%d", time.Now().UnixNano()),
176176- startedAt: time.Now(),
204204+ store: p,
205205+ options: opts,
206206+ pipeWriter: pipeWriter,
207207+ pipeReader: pipeReader,
208208+ digestChan: digestChan,
209209+ uploadErr: uploadErr,
210210+ id: fmt.Sprintf("upload-%d", time.Now().UnixNano()),
211211+ startedAt: time.Now(),
177212 }
178213179214 // Store in global uploads map for resume support
···181216 globalUploads[writer.id] = writer
182217 globalUploadsMu.Unlock()
183218219219+ // Start background goroutine that streams to temp location immediately
220220+ go func() {
221221+ defer pipeReader.Close()
222222+223223+ // Stream to temp location immediately to avoid pipe deadlock
224224+ tempPath := fmt.Sprintf("uploads/temp-%s", writer.id) // No leading slash
225225+ url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, tempPath, p.did)
226226+227227+ fmt.Printf("DEBUG [goroutine]: Starting upload to temp: url=%s\n", url)
228228+229229+ // Use context with timeout to prevent hanging forever
230230+ uploadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
231231+ defer cancel()
232232+233233+ req, err := http.NewRequestWithContext(uploadCtx, "PUT", url, pipeReader)
234234+ if err != nil {
235235+ fmt.Printf("DEBUG [goroutine]: Failed to create request: %v\n", err)
236236+ // Consume digest channel even on error
237237+ <-digestChan
238238+ uploadErr <- fmt.Errorf("failed to create request: %w", err)
239239+ return
240240+ }
241241+ req.Header.Set("Content-Type", "application/octet-stream")
242242+243243+ fmt.Printf("DEBUG [goroutine]: Sending PUT request...\n")
244244+ // Stream to temp location (this will block until all data is written)
245245+ resp, err := p.httpClient.Do(req)
246246+ if err != nil {
247247+ fmt.Printf("DEBUG [goroutine]: PUT failed: %v\n", err)
248248+ <-digestChan
249249+ uploadErr <- fmt.Errorf("failed to upload to temp: %w", err)
250250+ return
251251+ }
252252+ defer resp.Body.Close()
253253+254254+ fmt.Printf("DEBUG [goroutine]: Got response status=%d\n", resp.StatusCode)
255255+256256+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
257257+ bodyBytes, _ := io.ReadAll(resp.Body)
258258+ fmt.Printf("DEBUG [goroutine]: Upload failed with status %d, body=%s\n", resp.StatusCode, string(bodyBytes))
259259+ <-digestChan
260260+ uploadErr <- fmt.Errorf("upload to temp failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
261261+ return
262262+ }
263263+264264+ fmt.Printf("DEBUG [goroutine]: Upload to temp succeeded, waiting for digest...\n")
265265+ // Upload to temp succeeded, now wait for digest from Commit()
266266+ digest, ok := <-digestChan
267267+ if !ok {
268268+ uploadErr <- fmt.Errorf("upload cancelled after streaming to temp")
269269+ return
270270+ }
271271+272272+ fmt.Printf("DEBUG [goroutine]: Got digest=%s, signaling completion\n", digest)
273273+ // Store digest for Commit() to use in move operation
274274+ writer.finalDigest = digest
275275+ uploadErr <- nil
276276+ }()
277277+184278 return writer, nil
185279}
186280···195289 return nil, distribution.ErrBlobUploadUnknown
196290 }
197291292292+ // With streaming, no flush needed - just return the writer
198293 return writer, nil
199294}
200295···283378284379// ProxyBlobWriter implements distribution.BlobWriter for proxy uploads
285380type ProxyBlobWriter struct {
286286- store *ProxyBlobStore
287287- ctx context.Context
288288- options distribution.CreateOptions
289289- buffer bytes.Buffer
290290- size int64
291291- closed bool
292292- id string
293293- startedAt time.Time
381381+ store *ProxyBlobStore
382382+ options distribution.CreateOptions
383383+ pipeWriter *io.PipeWriter // Streams directly to hold service
384384+ pipeReader *io.PipeReader
385385+ digestChan chan string // Sends digest to upload goroutine
386386+ uploadErr chan error // Receives upload result from goroutine
387387+ finalDigest string // Final digest for move operation
388388+ size int64
389389+ closed bool
390390+ id string // Distribution's upload ID
391391+ startedAt time.Time
294392}
295393296394// ID returns the upload ID
···304402}
305403306404// Write writes data to the upload
405405+// Streams directly to hold service via pipe
307406func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
308407 if w.closed {
309408 return 0, fmt.Errorf("writer closed")
310409 }
311311- n, err := w.buffer.Write(p)
410410+411411+ // Write to pipe - streams immediately to hold service
412412+ n, err := w.pipeWriter.Write(p)
413413+ if err != nil {
414414+ // If write fails (client disconnected), close pipe to unblock goroutine
415415+ w.pipeWriter.CloseWithError(err)
416416+ return n, err
417417+ }
312418 w.size += int64(n)
313313- return n, err
419419+420420+ return n, nil
314421}
315422316423// ReadFrom reads from a reader
···318425 if w.closed {
319426 return 0, fmt.Errorf("writer closed")
320427 }
321321- n, err := w.buffer.ReadFrom(r)
322322- w.size += n
323323- return n, err
428428+429429+ // Read in chunks and flush when needed
430430+ buf := make([]byte, 32*1024) // 32KB read buffer
431431+ var total int64
432432+433433+ for {
434434+ nr, err := r.Read(buf)
435435+ if nr > 0 {
436436+ nw, werr := w.Write(buf[:nr])
437437+ total += int64(nw)
438438+ if werr != nil {
439439+ return total, werr
440440+ }
441441+ }
442442+ if err == io.EOF {
443443+ break
444444+ }
445445+ if err != nil {
446446+ return total, err
447447+ }
448448+ }
449449+450450+ return total, nil
324451}
325452326453// Size returns the current size
···340467 delete(globalUploads, w.id)
341468 globalUploadsMu.Unlock()
342469343343- // Upload the buffered content
344344- content := w.buffer.Bytes()
345345- dgst := digest.FromBytes(content)
346346-347347- // Verify digest matches
348348- if desc.Digest != "" && dgst != desc.Digest {
349349- return distribution.Descriptor{}, fmt.Errorf("digest mismatch")
470470+ // Close pipe to signal EOF to upload goroutine
471471+ if err := w.pipeWriter.Close(); err != nil {
472472+ return distribution.Descriptor{}, fmt.Errorf("failed to close pipe: %w", err)
350473 }
351474352352- // Get upload URL
353353- url, err := w.store.getUploadURL(ctx, dgst, int64(len(content)))
354354- if err != nil {
355355- return distribution.Descriptor{}, err
475475+ // Send digest to upload goroutine (it's waiting after temp upload completes)
476476+ w.digestChan <- desc.Digest.String()
477477+ close(w.digestChan)
478478+479479+ // Wait for upload goroutine to complete
480480+ if err := <-w.uploadErr; err != nil {
481481+ return distribution.Descriptor{}, fmt.Errorf("upload to temp failed: %w", err)
356482 }
357483358358- // Upload
359359- req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
484484+ // Now move temp → final location
485485+ tempPath := fmt.Sprintf("uploads/temp-%s", w.id) // No leading slash
486486+ finalPath := desc.Digest.String()
487487+488488+ moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
489489+ w.store.storageEndpoint, tempPath, finalPath, w.store.did)
490490+491491+ req, err := http.NewRequestWithContext(context.Background(), "POST", moveURL, nil)
360492 if err != nil {
361361- return distribution.Descriptor{}, err
493493+ return distribution.Descriptor{}, fmt.Errorf("failed to create move request: %w", err)
362494 }
363363- req.Header.Set("Content-Type", "application/octet-stream")
364495365496 resp, err := w.store.httpClient.Do(req)
366497 if err != nil {
367367- return distribution.Descriptor{}, err
498498+ return distribution.Descriptor{}, fmt.Errorf("failed to move blob: %w", err)
368499 }
369500 defer resp.Body.Close()
370501371502 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
372372- return distribution.Descriptor{}, fmt.Errorf("upload failed: status %d", resp.StatusCode)
503503+ bodyBytes, _ := io.ReadAll(resp.Body)
504504+ return distribution.Descriptor{}, fmt.Errorf("move blob failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
373505 }
374506507507+ fmt.Printf("DEBUG [proxy_blob_store]: Committed upload: digest=%s, size=%d (moved from temp)\n", desc.Digest, w.size)
508508+375509 return distribution.Descriptor{
376376- Digest: dgst,
377377- Size: int64(len(content)),
510510+ Digest: desc.Digest,
511511+ Size: w.size,
378512 MediaType: desc.MediaType,
379513 }, nil
380514}
···388522 delete(globalUploads, w.id)
389523 globalUploadsMu.Unlock()
390524525525+ // Close digest channel without sending digest
526526+ close(w.digestChan)
527527+528528+ // Close pipe with error to stop streaming
529529+ if w.pipeWriter != nil {
530530+ w.pipeWriter.CloseWithError(fmt.Errorf("upload cancelled"))
531531+ }
532532+533533+ // Wait for goroutine to finish
534534+ <-w.uploadErr
535535+536536+ fmt.Printf("DEBUG [proxy_blob_store]: Cancelled upload: id=%s\n", w.id)
391537 return nil
392538}
393539394540// Close closes the writer
395395-// NOTE: For resumable uploads, we don't mark as closed here
396396-// Distribution calls Close() after each PATCH, but the upload may continue
397397-// Only Commit() and Cancel() actually finalize the upload
541541+// Just returns - streaming continues via pipe
398542func (w *ProxyBlobWriter) Close() error {
399399- // Don't set w.closed = true here - allow resuming
543543+ // Don't close pipe here - that happens in Commit() or Cancel()
544544+ // Don't set w.closed = true - allow resuming for next PATCH
400545 return nil
401546}
402547
+11-2
pkg/storage/routing_repository.go
···1818 storageEndpoint string // Hold service endpoint for blobs (from discovery for push)
1919 did string // User's DID for authorization
2020 manifestStore *atproto.ManifestStore // Cached manifest store instance
2121+ blobStore *ProxyBlobStore // Cached blob store instance
2122}
22232324// NewRoutingRepository creates a new routing repository
···6263// Blobs returns a proxy blob store that routes to external hold service
6364// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
6465func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
6666+ // Return cached blob store if available
6767+ if r.blobStore != nil {
6868+ fmt.Printf("DEBUG [storage/blobs]: Returning cached blob store for did=%s, repo=%s\n",
6969+ r.did, r.repositoryName)
7070+ return r.blobStore
7171+ }
7272+6573 // For pull operations, check if we have a cached hold endpoint from a recent manifest fetch
6674 // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
6775 holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint
···8290 panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware")
8391 }
84928585- // Always use proxy blob store - routes to external hold service
8686- return NewProxyBlobStore(holdEndpoint, r.did)
9393+ // Create and cache proxy blob store
9494+ r.blobStore = NewProxyBlobStore(holdEndpoint, r.did)
9595+ return r.blobStore
8796}
88978998// Tags returns the tag service