···2626# Storage Configuration
2727# ==============================================================================
28282929-# Default hold service endpoint for users without their own storage (REQUIRED)
2929+# Default hold service DID for users without their own storage (REQUIRED)
3030# Users with a sailor profile defaultHold setting will override this
3131-# Docker: Use container name (http://atcr-hold:8080)
3232-# Local dev: Use localhost (http://127.0.0.1:8080)
3333-ATCR_DEFAULT_HOLD=http://127.0.0.1:8080
3131+# Format: did:web:hostname[:port]
3232+# Docker: did:web:atcr-hold:8080
3333+# Local dev: did:web:127.0.0.1:8080
3434+# Production: did:web:hold01.atcr.io
3535+ATCR_DEFAULT_HOLD_DID=did:web:127.0.0.1:8080
34363537# ==============================================================================
3638# Authentication Configuration
···2020 "github.com/spf13/cobra"
21212222 "atcr.io/pkg/appview/middleware"
2323- "atcr.io/pkg/atproto"
2423 "atcr.io/pkg/auth"
2524 "atcr.io/pkg/auth/oauth"
2625 "atcr.io/pkg/auth/token"
···110109 // Initialize OAuth components
111110 fmt.Println("Initializing OAuth components...")
112111113113- // 1. Create OAuth session storage (SQLite-backed)
112112+ // Create OAuth session storage (SQLite-backed)
114113 oauthStore := db.NewOAuthStore(uiDatabase)
115114 fmt.Println("Using SQLite for OAuth session storage")
116115117117- // 2. Create device store (SQLite-backed)
116116+ // Create device store (SQLite-backed)
118117 deviceStore := db.NewDeviceStore(uiDatabase)
119118 fmt.Println("Using SQLite for device storage")
120119121121- // 3. Get base URL from config or environment
120120+ // Get base URL from config or environment
122121 baseURL := os.Getenv("ATCR_BASE_URL")
123122 if baseURL == "" {
124123 // If addr is just a port (e.g., ":5000"), prepend localhost
···132131133132 fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL)
134133135135- // 4. Create OAuth app (indigo client)
134134+ // Create OAuth app (indigo client)
136135 oauthApp, err := oauth.NewApp(baseURL, oauthStore)
137136 if err != nil {
138137 return fmt.Errorf("failed to create OAuth app: %w", err)
139138 }
140139 fmt.Println("Using full OAuth scopes (including blob: scope)")
141140142142- // 5. Create refresher
141141+ // Create oauth token refresher
143142 refresher := oauth.NewRefresher(oauthApp)
144143145145- // 6. Set global refresher for middleware
144144+ // Set global refresher for middleware
146145 middleware.SetGlobalRefresher(refresher)
147146148148- // 6.5. Set global database for pull/push metrics tracking
147147+ // Set global database for pull/push metrics tracking
149148 metricsDB := db.NewMetricsDB(uiDatabase)
150149 middleware.SetGlobalDatabase(metricsDB)
151150152152- // 6.6. Create RemoteHoldAuthorizer for hold authorization with caching
151151+ // Create RemoteHoldAuthorizer for hold authorization with caching
153152 holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase)
154153 middleware.SetGlobalAuthorizer(holdAuthorizer)
155154 fmt.Println("Hold authorizer initialized with database caching")
···161160 // The extraction function normalizes URLs to DIDs for consistency
162161 defaultHoldDID := extractDefaultHoldDID(config)
163162164164- // 7. Initialize UI routes with OAuth app, refresher, and device store
163163+ // Initialize UI routes with OAuth app, refresher, and device store
165164 uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
166165167167- // 8. Create OAuth server
166166+ // Create OAuth server
168167 oauthServer := oauth.NewServer(oauthApp)
169168 // Connect server to refresher for cache invalidation
170169 oauthServer.SetRefresher(refresher)
···175174 // Connect database for user avatar management
176175 oauthServer.SetDatabase(uiDatabase)
177176178178- // 8.5. Set default hold DID on OAuth server (extracted earlier)
177177+ // Set default hold DID on OAuth server (extracted earlier)
179178 // This is used to create sailor profiles on first login
180179 if defaultHoldDID != "" {
181180 oauthServer.SetDefaultHoldDID(defaultHoldDID)
182181 fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID)
183182 }
184183185185- // 9. Initialize auth keys and create token issuer
184184+ // Initialize auth keys and create token issuer
186185 var issuer *token.Issuer
187186 if config.Auth["token"] != nil {
188187 if err := initializeAuthKeys(config); err != nil {
···365364}
366365367366// extractDefaultHoldDID extracts the default hold DID from middleware config
368368-// Returns a DID (e.g., "did:web:hold01.atcr.io") for consistency
369369-// Accepts both DIDs and URLs in config for backward compatibility
367367+// Returns a DID (e.g., "did:web:hold01.atcr.io")
370368// To find a hold's DID, visit: https://hold-url/.well-known/did.json
371369func extractDefaultHoldDID(config *configuration.Configuration) string {
372372- // Navigate through: middleware.registry[].options.default_storage_endpoint
370370+ // Navigate through: middleware.registry[].options.default_hold_did
373371 registryMiddleware, ok := config.Middleware["registry"]
374372 if !ok {
375373 return ""
···384382385383 // Extract options - options is configuration.Parameters which is map[string]any
386384 if mw.Options != nil {
387387- if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok {
388388- // Normalize to DID (handles both URLs and DIDs)
389389- // This ensures we store DIDs consistently
390390- return atproto.ResolveHoldDIDFromURL(endpoint)
385385+ if holdDID, ok := mw.Options["default_hold_did"].(string); ok {
386386+ return holdDID
391387 }
392388 }
393389 }
+7
deploy/.env.prod.template
···126126# AppView Configuration
127127# ==============================================================================
128128129129+# Default hold service DID (REQUIRED)
130130+# This is automatically set by docker-compose.prod.yml to did:web:${HOLD_DOMAIN}
131131+# Only override this if you want to use a different default hold
132132+# Format: did:web:hostname[:port]
133133+# Example: did:web:hold01.atcr.io
134134+# Note: This is set automatically - no need to configure manually
135135+129136# JWT token expiration in seconds
130137# Default: 300 (5 minutes)
131138ATCR_TOKEN_EXPIRATION=300
···5858// NamespaceResolver wraps a namespace and resolves names
5959type NamespaceResolver struct {
6060 distribution.Namespace
6161- directory identity.Directory
6262- defaultStorageEndpoint string
6363- testMode bool // If true, fallback to default hold when user's hold is unreachable
6464- repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
6161+ directory identity.Directory
6262+ defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
6363+ testMode bool // If true, fallback to default hold when user's hold is unreachable
6464+ repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
6565}
66666767// initATProtoResolver initializes the name resolution middleware
···6969 // Use indigo's default directory (includes caching)
7070 directory := identity.DefaultDirectory()
71717272- // Get default storage endpoint from config (optional)
7373- // Normalize to DID format for consistency
7474- defaultStorageEndpoint := ""
7575- if endpoint, ok := options["default_storage_endpoint"].(string); ok {
7676- // Convert URL to DID if needed (or pass through if already a DID)
7777- defaultStorageEndpoint = atproto.ResolveHoldDIDFromURL(endpoint)
7272+ // Get default hold DID from config (required)
7373+ // Expected format: "did:web:hold01.atcr.io"
7474+ defaultHoldDID := ""
7575+ if holdDID, ok := options["default_hold_did"].(string); ok {
7676+ defaultHoldDID = holdDID
7877 }
79788079 // Check test mode from options (passed via env var)
···8483 }
85848685 return &NamespaceResolver{
8787- Namespace: ns,
8888- directory: directory,
8989- defaultStorageEndpoint: defaultStorageEndpoint,
9090- testMode: testMode,
8686+ Namespace: ns,
8787+ directory: directory,
8888+ defaultHoldDID: defaultHoldDID,
8989+ testMode: testMode,
9190 }, nil
9291}
9392···128127129128 fmt.Printf("DEBUG [registry/middleware]: Resolved identity: did=%s, pds=%s, handle=%s\n", did, pdsEndpoint, ident.Handle.String())
130129131131- // Query for storage endpoint - either user's hold or default hold service
132132- storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint)
133133- if storageEndpoint == "" {
130130+ // Query for hold DID - either user's hold or default hold service
131131+ holdDID := nr.findHoldDID(ctx, did, pdsEndpoint)
132132+ if holdDID == "" {
134133 // This is a fatal configuration error - registry cannot function without a hold service
135135- return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config")
134134+ return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config")
136135 }
137137- ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint)
136136+ ctx = context.WithValue(ctx, "hold.did", holdDID)
138137139138 // Create a new reference with identity/image format
140139 // Use the identity (or DID) as the namespace to ensure canonical format
···195194196195 // Create routing repository - routes manifests to ATProto, blobs to hold service
197196 // The registry is stateless - no local storage is used
198198- // Pass storage endpoint, DID, and authorizer as parameters (can't use context as it gets lost)
199199- routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase, globalAuthorizer)
197197+ // Pass hold DID, user DID, and authorizer as parameters (can't use context as it gets lost)
198198+ routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer)
200199201200 // Cache the repository
202201 nr.repositories.Store(cacheKey, routingRepo)
···219218 return nr.Namespace.BlobStatter()
220219}
221220222222-// findStorageEndpoint determines which hold endpoint to use for blob storage
221221+// findHoldDID determines which hold DID to use for blob storage
223222// Priority order:
224223// 1. User's sailor profile defaultHold (if set)
225224// 2. User's own hold record (io.atcr.hold)
226226-// 3. AppView's default hold endpoint
225225+// 3. AppView's default hold DID
227226// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
228228-// Note: Despite returning a DID, this is used as the "storage endpoint" throughout the code
229229-func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string {
227227+func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
230228 // Create ATProto client (without auth - reading public records)
231229 client := atproto.NewClient(pdsEndpoint, did, "")
232230233233- // 1. Check for sailor profile
231231+ // Check for sailor profile
234232 profile, err := atproto.GetProfile(ctx, client)
235233 if err != nil {
236234 // Error reading profile (not a 404) - log and continue
···245243 return profile.DefaultHold
246244 }
247245 fmt.Printf("DEBUG [registry/middleware/testmode]: User's defaultHold %s unreachable, falling back to default\n", profile.DefaultHold)
248248- return nr.defaultStorageEndpoint
246246+ return nr.defaultHoldDID
249247 }
250248 return profile.DefaultHold
251249 }
252250253253- // 2. Profile doesn't exist or defaultHold is null/empty
251251+ // Profile doesn't exist or defaultHold is null/empty
254252 // Check for user's own hold records
255253 records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
256254 if err != nil {
257255 // Failed to query holds, use default
258258- return nr.defaultStorageEndpoint
256256+ return nr.defaultHoldDID
259257 }
260258261259 // Find the first hold record
···265263 continue
266264 }
267265268268- // Return the endpoint from the first hold
266266+ // Return the endpoint from the first hold (normalize to DID if URL)
269267 if holdRecord.Endpoint != "" {
270270- return holdRecord.Endpoint
268268+ return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
271269 }
272270 }
273271274274- // 3. No profile defaultHold and no own hold records - use AppView default
275275- return nr.defaultStorageEndpoint
272272+ // No profile defaultHold and no own hold records - use AppView default
273273+ return nr.defaultHoldDID
276274}
277275278276// isHoldReachable checks if a hold service is reachable
+9-9
pkg/appview/storage/hold_cache.go
···55 "time"
66)
7788-// HoldCache caches hold endpoints for (DID, repository) pairs
88+// HoldCache caches hold DIDs for (DID, repository) pairs
99// This avoids expensive ATProto lookups on every blob request during pulls
1010//
1111// NOTE: This is a simple in-memory cache for MVP. For production deployments:
···1818}
19192020type holdCacheEntry struct {
2121- holdEndpoint string
2222- expiresAt time.Time
2121+ holdDID string
2222+ expiresAt time.Time
2323}
24242525var globalHoldCache = &HoldCache{
···4242 return globalHoldCache
4343}
44444545-// Set stores a hold endpoint for a (DID, repository) pair with a TTL
4646-func (c *HoldCache) Set(did, repository, holdEndpoint string, ttl time.Duration) {
4545+// Set stores a hold DID for a (DID, repository) pair with a TTL
4646+func (c *HoldCache) Set(did, repository, holdDID string, ttl time.Duration) {
4747 c.mu.Lock()
4848 defer c.mu.Unlock()
49495050 key := did + ":" + repository
5151 c.cache[key] = &holdCacheEntry{
5252- holdEndpoint: holdEndpoint,
5353- expiresAt: time.Now().Add(ttl),
5252+ holdDID: holdDID,
5353+ expiresAt: time.Now().Add(ttl),
5454 }
5555}
56565757-// Get retrieves a hold endpoint for a (DID, repository) pair
5757+// Get retrieves a hold DID for a (DID, repository) pair
5858// Returns empty string and false if not found or expired
5959func (c *HoldCache) Get(did, repository string) (string, bool) {
6060 c.mu.RLock()
···7272 return "", false
7373 }
74747575- return entry.holdEndpoint, true
7575+ return entry.holdDID, true
7676}
77777878// Cleanup removes expired entries (called automatically every 5 minutes)
+39-21
pkg/appview/storage/proxy_blob_store.go
···77 "fmt"
88 "io"
99 "net/http"
1010+ "strings"
1011 "sync"
1112 "time"
12131313- "atcr.io/pkg/atproto"
1414 "atcr.io/pkg/auth"
1515 "github.com/distribution/distribution/v3"
1616 "github.com/opencontainers/go-digest"
···31313232// ProxyBlobStore proxies blob requests to an external storage service
3333type ProxyBlobStore struct {
3434- storageEndpoint string
3535- httpClient *http.Client
3636- did string
3737- database DatabaseMetrics
3838- repository string
3939- authorizer auth.HoldAuthorizer
4040- holdDID string
3434+ holdDID string // Hold DID (e.g., "did:web:hold01.atcr.io")
3535+ holdURL string // Resolved HTTP URL for XRPC requests
3636+ httpClient *http.Client
3737+ did string
3838+ database DatabaseMetrics
3939+ repository string
4040+ authorizer auth.HoldAuthorizer
4141}
42424343// NewProxyBlobStore creates a new proxy blob store
4444-func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore {
4545- // Convert storage endpoint URL to did:web DID for authorization
4646- holdDID := atproto.ResolveHoldDIDFromURL(storageEndpoint)
4747- fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, holdDID=%s, userDID=%s, repo=%s\n",
4848- storageEndpoint, holdDID, did, repository)
4444+func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore {
4545+ // Resolve DID to URL once at construction time
4646+ holdURL := resolveHoldURL(holdDID)
4747+4848+ fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n",
4949+ holdDID, holdURL, did, repository)
49505051 return &ProxyBlobStore{
5151- storageEndpoint: storageEndpoint,
5252+ holdDID: holdDID,
5353+ holdURL: holdURL,
5254 httpClient: &http.Client{
5355 Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads
5456 Transport: &http.Transport{
···6365 database: database,
6466 repository: repository,
6567 authorizer: authorizer,
6666- holdDID: holdDID,
6768 }
6969+}
7070+7171+// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
7272+// did:web:hold01.atcr.io → https://hold01.atcr.io
7373+// did:web:172.28.0.3:8080 → http://172.28.0.3:8080
7474+func resolveHoldURL(holdDID string) string {
7575+ hostname := strings.TrimPrefix(holdDID, "did:web:")
7676+7777+ // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
7878+ if strings.Contains(hostname, ":") ||
7979+ strings.Contains(hostname, "127.0.0.1") ||
8080+ strings.Contains(hostname, "localhost") ||
8181+ // Check if it's an IP address (contains only digits and dots)
8282+ (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
8383+ return "http://" + hostname
8484+ }
8585+ return "https://" + hostname
6886}
69877088// checkReadAccess verifies the user has read access to the hold
···347365 // Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid={digest}
348366 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
349367 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
350350- p.storageEndpoint, p.holdDID, dgst.String())
368368+ p.holdURL, p.holdDID, dgst.String())
351369 return url, nil
352370}
353371···356374func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
357375 // Same as GET - hold service handles HEAD method on getBlob endpoint
358376 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
359359- p.storageEndpoint, p.holdDID, dgst.String())
377377+ p.holdURL, p.holdDID, dgst.String())
360378 return url, nil
361379}
362380···378396 return "", err
379397 }
380398381381- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
399399+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL)
382400 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
383401 if err != nil {
384402 return "", err
···428446 return nil, err
429447 }
430448431431- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
449449+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL)
432450 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
433451 if err != nil {
434452 return nil, err
···478496 return err
479497 }
480498481481- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
499499+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL)
482500 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
483501 if err != nil {
484502 return err
···512530 return err
513531 }
514532515515- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
533533+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL)
516534 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
517535 if err != nil {
518536 return err
+35-36
pkg/appview/storage/routing_repository.go
···2020// The registry (AppView) is stateless and NEVER stores blobs locally
2121type RoutingRepository struct {
2222 distribution.Repository
2323- atprotoClient *atproto.Client
2424- repositoryName string
2525- storageEndpoint string // Hold service endpoint for blobs (from discovery for push)
2626- did string // User's DID for authorization
2727- manifestStore *atproto.ManifestStore // Cached manifest store instance
2828- blobStore *ProxyBlobStore // Cached blob store instance
2929- database DatabaseMetrics // Database for metrics tracking
3030- authorizer auth.HoldAuthorizer // Authorization for hold access
2323+ atprotoClient *atproto.Client
2424+ repositoryName string
2525+ holdDID string // Hold service DID for blobs (from discovery for push), e.g., "did:web:hold01.atcr.io"
2626+ did string // User's DID for authorization
2727+ manifestStore *atproto.ManifestStore // Cached manifest store instance
2828+ blobStore *ProxyBlobStore // Cached blob store instance
2929+ database DatabaseMetrics // Database for metrics tracking
3030+ authorizer auth.HoldAuthorizer // Authorization for hold access
3131}
32323333// NewRoutingRepository creates a new routing repository
···3535 baseRepo distribution.Repository,
3636 atprotoClient *atproto.Client,
3737 repoName string,
3838- storageEndpoint string,
3838+ holdDID string,
3939 did string,
4040 database DatabaseMetrics,
4141 authorizer auth.HoldAuthorizer,
4242) *RoutingRepository {
4343 return &RoutingRepository{
4444- Repository: baseRepo,
4545- atprotoClient: atprotoClient,
4646- repositoryName: repoName,
4747- storageEndpoint: storageEndpoint,
4848- did: did,
4949- database: database,
5050- authorizer: authorizer,
4444+ Repository: baseRepo,
4545+ atprotoClient: atprotoClient,
4646+ repositoryName: repoName,
4747+ holdDID: holdDID,
4848+ did: did,
4949+ database: database,
5050+ authorizer: authorizer,
5151 }
5252}
5353···5858 // Ensure blob store is created first (needed for label extraction during push)
5959 blobStore := r.Blobs(ctx)
60606161- // Resolve hold endpoint URL to DID
6262- holdDID := atproto.ResolveHoldDIDFromURL(r.storageEndpoint)
6363-6464- r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, holdDID, r.did, blobStore, r.database)
6161+ // ManifestStore needs both DID and URL for backward compat (legacy holdEndpoint field)
6262+ // For now, pass holdDID twice (will be cleaned up in manifest_store.go later)
6363+ r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.holdDID, r.holdDID, r.did, blobStore, r.database)
6564 }
66656767- // After any manifest operation, cache the hold endpoint for blob fetches
6666+ // After any manifest operation, cache the hold DID for blob fetches
6867 // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
6968 go func() {
7069 time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
7171- if holdEndpoint := r.manifestStore.GetLastFetchedHoldEndpoint(); holdEndpoint != "" {
7070+ if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
7271 // Cache for 10 minutes - should cover typical pull operations
7373- GetGlobalHoldCache().Set(r.did, r.repositoryName, holdEndpoint, 10*time.Minute)
7474- fmt.Printf("DEBUG [storage/routing]: Cached hold endpoint: did=%s, repo=%s, hold=%s\n",
7575- r.did, r.repositoryName, holdEndpoint)
7272+ GetGlobalHoldCache().Set(r.did, r.repositoryName, holdDID, 10*time.Minute)
7373+ fmt.Printf("DEBUG [storage/routing]: Cached hold DID: did=%s, repo=%s, hold=%s\n",
7474+ r.did, r.repositoryName, holdDID)
7675 }
7776 }()
7877···8988 return r.blobStore
9089 }
91909292- // For pull operations, check if we have a cached hold endpoint from a recent manifest fetch
9191+ // For pull operations, check if we have a cached hold DID from a recent manifest fetch
9392 // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
9494- holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint
9393+ holdDID := r.holdDID // Default to discovery-based DID
95949696- if cachedHold, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok {
9797- // Use cached hold from manifest
9898- holdEndpoint = cachedHold
9595+ if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok {
9696+ // Use cached hold DID from manifest
9797+ holdDID = cachedHoldDID
9998 fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n",
100100- r.did, r.repositoryName, cachedHold)
9999+ r.did, r.repositoryName, cachedHoldDID)
101100 } else {
102102- // No cached hold, use discovery-based endpoint (for push or first pull)
101101+ // No cached hold, use discovery-based DID (for push or first pull)
103102 fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n",
104104- r.did, r.repositoryName, holdEndpoint)
103103+ r.did, r.repositoryName, holdDID)
105104 }
106105107107- if holdEndpoint == "" {
106106+ if holdDID == "" {
108107 // This should never happen if middleware is configured correctly
109109- panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware")
108108+ panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware")
110109 }
111110112111 // Create and cache proxy blob store with authorization
113113- r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName, r.authorizer)
112112+ r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer)
114113 return r.blobStore
115114}
116115
+16-35
pkg/atproto/manifest_store.go
···2121// ManifestStore implements distribution.ManifestService
2222// It stores manifests in ATProto as records
2323type ManifestStore struct {
2424- client *Client
2525- repository string
2626- holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated)
2727- holdDID string // Hold service DID (primary reference)
2828- did string // User's DID for cache key
2929- lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull)
3030- blobStore distribution.BlobStore // Blob store for fetching config during push
3131- database DatabaseMetrics // Database for metrics tracking
2424+ client *Client
2525+ repository string
2626+ holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated)
2727+ holdDID string // Hold service DID (primary reference)
2828+ did string // User's DID for cache key
2929+ lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
3030+ blobStore distribution.BlobStore // Blob store for fetching config during push
3131+ database DatabaseMetrics // Database for metrics tracking
3232}
33333434// NewManifestStore creates a new ATProto-backed manifest store
···7474 return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
7575 }
76767777- // Store the hold endpoint for subsequent blob requests during pull
7777+ // Store the hold DID for subsequent blob requests during pull
7878 // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
7979 // The routing repository will cache this for concurrent blob fetches
8080 if manifestRecord.HoldDID != "" {
8181- // New format: DID reference
8282- // Convert did:web back to URL for blob fetching
8383- // TODO: Routing repository should handle DID→URL conversion
8484- // For now, fall back to HoldEndpoint if available
8585- if manifestRecord.HoldEndpoint != "" {
8686- s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
8787- } else {
8888- // Convert did:web:hold.example.com → https://hold.example.com
8989- s.lastFetchedHoldEndpoint = didToURL(manifestRecord.HoldDID)
9090- }
8181+ // New format: DID reference (preferred)
8282+ s.lastFetchedHoldDID = manifestRecord.HoldDID
9183 } else if manifestRecord.HoldEndpoint != "" {
9292- // Legacy format: URL reference
9393- s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
8484+ // Legacy format: URL reference - convert to DID
8585+ s.lastFetchedHoldDID = ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
9486 }
95879688 var ociManifest []byte
···246238 return repository, tag
247239}
248240249249-// GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest
241241+// GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest
250242// This is used by the routing repository to cache the hold for blob requests
251251-func (s *ManifestStore) GetLastFetchedHoldEndpoint() string {
252252- return s.lastFetchedHoldEndpoint
243243+func (s *ManifestStore) GetLastFetchedHoldDID() string {
244244+ return s.lastFetchedHoldDID
253245}
254246255247// rawManifest is a simple implementation of distribution.Manifest
···294286295287 return configJSON.Config.Labels, nil
296288}
297297-298298-// didToURL converts a did:web DID to an HTTPS URL
299299-// e.g., did:web:hold.example.com → https://hold.example.com
300300-func didToURL(didWeb string) string {
301301- if !strings.HasPrefix(didWeb, "did:web:") {
302302- return didWeb // Not a did:web, return as-is
303303- }
304304-305305- hostname := strings.TrimPrefix(didWeb, "did:web:")
306306- return "https://" + hostname
307307-}
+1-18
pkg/auth/oauth/server.go
···342342 fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold)
343343344344 // Resolve URL to DID
345345- holdDID = resolveHoldDIDFromURL(profile.DefaultHold)
345345+ holdDID = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
346346347347 // Update profile with DID
348348 profile.DefaultHold = holdDID
···362362 // For now, crew registration will happen on first push when appview validates access
363363 fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID)
364364 _ = session // TODO: use session for crew registration
365365-}
366366-367367-// resolveHoldDIDFromURL converts a hold endpoint URL to a DID
368368-// For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io
369369-func resolveHoldDIDFromURL(holdURL string) string {
370370- // Parse URL to get hostname
371371- holdURL = strings.TrimPrefix(holdURL, "http://")
372372- holdURL = strings.TrimPrefix(holdURL, "https://")
373373- holdURL = strings.TrimSuffix(holdURL, "/")
374374-375375- // Extract hostname (remove path if present)
376376- parts := strings.Split(holdURL, "/")
377377- hostname := parts[0]
378378-379379- // Convert to did:web
380380- // did:web uses hostname directly (port included if non-standard)
381381- return "did:web:" + hostname
382365}
383366384367// HTML templates