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

fix all the places where did used to be an endpoint

evan.jarrett.net 80b65ee6 606c8a84

verified
+169 -185
+6 -4
.env.appview.example
··· 26 26 # Storage Configuration 27 27 # ============================================================================== 28 28 29 - # Default hold service endpoint for users without their own storage (REQUIRED) 29 + # Default hold service DID for users without their own storage (REQUIRED) 30 30 # Users with a sailor profile defaultHold setting will override this 31 - # Docker: Use container name (http://atcr-hold:8080) 32 - # Local dev: Use localhost (http://127.0.0.1:8080) 33 - ATCR_DEFAULT_HOLD=http://127.0.0.1:8080 31 + # Format: did:web:hostname[:port] 32 + # Docker: did:web:atcr-hold:8080 33 + # Local dev: did:web:127.0.0.1:8080 34 + # Production: did:web:hold01.atcr.io 35 + ATCR_DEFAULT_HOLD_DID=did:web:127.0.0.1:8080 34 36 35 37 # ============================================================================== 36 38 # Authentication Configuration
+7 -7
cmd/appview/config.go
··· 34 34 config.Storage = buildStorageConfig() 35 35 36 36 // Middleware (ATProto resolver) 37 - defaultHold := os.Getenv("ATCR_DEFAULT_HOLD") 38 - if defaultHold == "" { 39 - return nil, fmt.Errorf("ATCR_DEFAULT_HOLD is required") 37 + defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID") 38 + if defaultHoldDID == "" { 39 + return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") 40 40 } 41 - config.Middleware = buildMiddlewareConfig(defaultHold) 41 + config.Middleware = buildMiddlewareConfig(defaultHoldDID) 42 42 43 43 // Auth 44 44 baseURL := getBaseURL(httpConfig.Addr) ··· 123 123 } 124 124 125 125 // buildMiddlewareConfig creates middleware configuration 126 - func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middleware { 126 + func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Middleware { 127 127 // Check test mode 128 128 testMode := os.Getenv("TEST_MODE") == "true" 129 129 ··· 132 132 { 133 133 Name: "atproto-resolver", 134 134 Options: configuration.Parameters{ 135 - "default_storage_endpoint": defaultHold, 136 - "test_mode": testMode, 135 + "default_hold_did": defaultHoldDID, 136 + "test_mode": testMode, 137 137 }, 138 138 }, 139 139 },
+16 -20
cmd/appview/serve.go
··· 20 20 "github.com/spf13/cobra" 21 21 22 22 "atcr.io/pkg/appview/middleware" 23 - "atcr.io/pkg/atproto" 24 23 "atcr.io/pkg/auth" 25 24 "atcr.io/pkg/auth/oauth" 26 25 "atcr.io/pkg/auth/token" ··· 110 109 // Initialize OAuth components 111 110 fmt.Println("Initializing OAuth components...") 112 111 113 - // 1. Create OAuth session storage (SQLite-backed) 112 + // Create OAuth session storage (SQLite-backed) 114 113 oauthStore := db.NewOAuthStore(uiDatabase) 115 114 fmt.Println("Using SQLite for OAuth session storage") 116 115 117 - // 2. Create device store (SQLite-backed) 116 + // Create device store (SQLite-backed) 118 117 deviceStore := db.NewDeviceStore(uiDatabase) 119 118 fmt.Println("Using SQLite for device storage") 120 119 121 - // 3. Get base URL from config or environment 120 + // Get base URL from config or environment 122 121 baseURL := os.Getenv("ATCR_BASE_URL") 123 122 if baseURL == "" { 124 123 // If addr is just a port (e.g., ":5000"), prepend localhost ··· 132 131 133 132 fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) 134 133 135 - // 4. Create OAuth app (indigo client) 134 + // Create OAuth app (indigo client) 136 135 oauthApp, err := oauth.NewApp(baseURL, oauthStore) 137 136 if err != nil { 138 137 return fmt.Errorf("failed to create OAuth app: %w", err) 139 138 } 140 139 fmt.Println("Using full OAuth scopes (including blob: scope)") 141 140 142 - // 5. Create refresher 141 + // Create oauth token refresher 143 142 refresher := oauth.NewRefresher(oauthApp) 144 143 145 - // 6. Set global refresher for middleware 144 + // Set global refresher for middleware 146 145 middleware.SetGlobalRefresher(refresher) 147 146 148 - // 6.5. Set global database for pull/push metrics tracking 147 + // Set global database for pull/push metrics tracking 149 148 metricsDB := db.NewMetricsDB(uiDatabase) 150 149 middleware.SetGlobalDatabase(metricsDB) 151 150 152 - // 6.6. Create RemoteHoldAuthorizer for hold authorization with caching 151 + // Create RemoteHoldAuthorizer for hold authorization with caching 153 152 holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase) 154 153 middleware.SetGlobalAuthorizer(holdAuthorizer) 155 154 fmt.Println("Hold authorizer initialized with database caching") ··· 161 160 // The extraction function normalizes URLs to DIDs for consistency 162 161 defaultHoldDID := extractDefaultHoldDID(config) 163 162 164 - // 7. Initialize UI routes with OAuth app, refresher, and device store 163 + // Initialize UI routes with OAuth app, refresher, and device store 165 164 uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID) 166 165 167 - // 8. Create OAuth server 166 + // Create OAuth server 168 167 oauthServer := oauth.NewServer(oauthApp) 169 168 // Connect server to refresher for cache invalidation 170 169 oauthServer.SetRefresher(refresher) ··· 175 174 // Connect database for user avatar management 176 175 oauthServer.SetDatabase(uiDatabase) 177 176 178 - // 8.5. Set default hold DID on OAuth server (extracted earlier) 177 + // Set default hold DID on OAuth server (extracted earlier) 179 178 // This is used to create sailor profiles on first login 180 179 if defaultHoldDID != "" { 181 180 oauthServer.SetDefaultHoldDID(defaultHoldDID) 182 181 fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID) 183 182 } 184 183 185 - // 9. Initialize auth keys and create token issuer 184 + // Initialize auth keys and create token issuer 186 185 var issuer *token.Issuer 187 186 if config.Auth["token"] != nil { 188 187 if err := initializeAuthKeys(config); err != nil { ··· 365 364 } 366 365 367 366 // extractDefaultHoldDID extracts the default hold DID from middleware config 368 - // Returns a DID (e.g., "did:web:hold01.atcr.io") for consistency 369 - // Accepts both DIDs and URLs in config for backward compatibility 367 + // Returns a DID (e.g., "did:web:hold01.atcr.io") 370 368 // To find a hold's DID, visit: https://hold-url/.well-known/did.json 371 369 func extractDefaultHoldDID(config *configuration.Configuration) string { 372 - // Navigate through: middleware.registry[].options.default_storage_endpoint 370 + // Navigate through: middleware.registry[].options.default_hold_did 373 371 registryMiddleware, ok := config.Middleware["registry"] 374 372 if !ok { 375 373 return "" ··· 384 382 385 383 // Extract options - options is configuration.Parameters which is map[string]any 386 384 if mw.Options != nil { 387 - if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok { 388 - // Normalize to DID (handles both URLs and DIDs) 389 - // This ensures we store DIDs consistently 390 - return atproto.ResolveHoldDIDFromURL(endpoint) 385 + if holdDID, ok := mw.Options["default_hold_did"].(string); ok { 386 + return holdDID 391 387 } 392 388 } 393 389 }
+7
deploy/.env.prod.template
··· 126 126 # AppView Configuration 127 127 # ============================================================================== 128 128 129 + # Default hold service DID (REQUIRED) 130 + # This is automatically set by docker-compose.prod.yml to did:web:${HOLD_DOMAIN} 131 + # Only override this if you want to use a different default hold 132 + # Format: did:web:hostname[:port] 133 + # Example: did:web:hold01.atcr.io 134 + # Note: This is set automatically - no need to configure manually 135 + 129 136 # JWT token expiration in seconds 130 137 # Default: 300 (5 minutes) 131 138 ATCR_TOKEN_EXPIRATION=300
+1 -1
deploy/docker-compose.prod.yml
··· 51 51 ATCR_SERVICE_NAME: ${APPVIEW_DOMAIN:-atcr.io} 52 52 53 53 # Storage configuration 54 - ATCR_DEFAULT_HOLD: https://${HOLD_DOMAIN:-hold01.atcr.io} 54 + ATCR_DEFAULT_HOLD_DID: did:web:${HOLD_DOMAIN:-hold01.atcr.io} 55 55 56 56 # Authentication 57 57 ATCR_AUTH_KEY_PATH: /var/lib/atcr/auth/private-key.pem
+1 -1
docker-compose.yml
··· 13 13 environment: 14 14 # Server configuration 15 15 ATCR_HTTP_ADDR: :5000 16 - ATCR_DEFAULT_HOLD: http://172.28.0.3:8080 16 + ATCR_DEFAULT_HOLD_DID: did:web:172.28.0.3:8080 17 17 # UI configuration 18 18 ATCR_UI_ENABLED: true 19 19 ATCR_BACKFILL_ENABLED: true
+31 -33
pkg/appview/middleware/registry.go
··· 58 58 // NamespaceResolver wraps a namespace and resolves names 59 59 type NamespaceResolver struct { 60 60 distribution.Namespace 61 - directory identity.Directory 62 - defaultStorageEndpoint string 63 - testMode bool // If true, fallback to default hold when user's hold is unreachable 64 - repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) 61 + directory identity.Directory 62 + defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 63 + testMode bool // If true, fallback to default hold when user's hold is unreachable 64 + repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) 65 65 } 66 66 67 67 // initATProtoResolver initializes the name resolution middleware ··· 69 69 // Use indigo's default directory (includes caching) 70 70 directory := identity.DefaultDirectory() 71 71 72 - // Get default storage endpoint from config (optional) 73 - // Normalize to DID format for consistency 74 - defaultStorageEndpoint := "" 75 - if endpoint, ok := options["default_storage_endpoint"].(string); ok { 76 - // Convert URL to DID if needed (or pass through if already a DID) 77 - defaultStorageEndpoint = atproto.ResolveHoldDIDFromURL(endpoint) 72 + // Get default hold DID from config (required) 73 + // Expected format: "did:web:hold01.atcr.io" 74 + defaultHoldDID := "" 75 + if holdDID, ok := options["default_hold_did"].(string); ok { 76 + defaultHoldDID = holdDID 78 77 } 79 78 80 79 // Check test mode from options (passed via env var) ··· 84 83 } 85 84 86 85 return &NamespaceResolver{ 87 - Namespace: ns, 88 - directory: directory, 89 - defaultStorageEndpoint: defaultStorageEndpoint, 90 - testMode: testMode, 86 + Namespace: ns, 87 + directory: directory, 88 + defaultHoldDID: defaultHoldDID, 89 + testMode: testMode, 91 90 }, nil 92 91 } 93 92 ··· 128 127 129 128 fmt.Printf("DEBUG [registry/middleware]: Resolved identity: did=%s, pds=%s, handle=%s\n", did, pdsEndpoint, ident.Handle.String()) 130 129 131 - // Query for storage endpoint - either user's hold or default hold service 132 - storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint) 133 - if storageEndpoint == "" { 130 + // Query for hold DID - either user's hold or default hold service 131 + holdDID := nr.findHoldDID(ctx, did, pdsEndpoint) 132 + if holdDID == "" { 134 133 // This is a fatal configuration error - registry cannot function without a hold service 135 - return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config") 134 + return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config") 136 135 } 137 - ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint) 136 + ctx = context.WithValue(ctx, "hold.did", holdDID) 138 137 139 138 // Create a new reference with identity/image format 140 139 // Use the identity (or DID) as the namespace to ensure canonical format ··· 195 194 196 195 // Create routing repository - routes manifests to ATProto, blobs to hold service 197 196 // The registry is stateless - no local storage is used 198 - // Pass storage endpoint, DID, and authorizer as parameters (can't use context as it gets lost) 199 - routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase, globalAuthorizer) 197 + // Pass hold DID, user DID, and authorizer as parameters (can't use context as it gets lost) 198 + routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer) 200 199 201 200 // Cache the repository 202 201 nr.repositories.Store(cacheKey, routingRepo) ··· 219 218 return nr.Namespace.BlobStatter() 220 219 } 221 220 222 - // findStorageEndpoint determines which hold endpoint to use for blob storage 221 + // findHoldDID determines which hold DID to use for blob storage 223 222 // Priority order: 224 223 // 1. User's sailor profile defaultHold (if set) 225 224 // 2. User's own hold record (io.atcr.hold) 226 - // 3. AppView's default hold endpoint 225 + // 3. AppView's default hold DID 227 226 // Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured 228 - // Note: Despite returning a DID, this is used as the "storage endpoint" throughout the code 229 - func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string { 227 + func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string { 230 228 // Create ATProto client (without auth - reading public records) 231 229 client := atproto.NewClient(pdsEndpoint, did, "") 232 230 233 - // 1. Check for sailor profile 231 + // Check for sailor profile 234 232 profile, err := atproto.GetProfile(ctx, client) 235 233 if err != nil { 236 234 // Error reading profile (not a 404) - log and continue ··· 245 243 return profile.DefaultHold 246 244 } 247 245 fmt.Printf("DEBUG [registry/middleware/testmode]: User's defaultHold %s unreachable, falling back to default\n", profile.DefaultHold) 248 - return nr.defaultStorageEndpoint 246 + return nr.defaultHoldDID 249 247 } 250 248 return profile.DefaultHold 251 249 } 252 250 253 - // 2. Profile doesn't exist or defaultHold is null/empty 251 + // Profile doesn't exist or defaultHold is null/empty 254 252 // Check for user's own hold records 255 253 records, err := client.ListRecords(ctx, atproto.HoldCollection, 10) 256 254 if err != nil { 257 255 // Failed to query holds, use default 258 - return nr.defaultStorageEndpoint 256 + return nr.defaultHoldDID 259 257 } 260 258 261 259 // Find the first hold record ··· 265 263 continue 266 264 } 267 265 268 - // Return the endpoint from the first hold 266 + // Return the endpoint from the first hold (normalize to DID if URL) 269 267 if holdRecord.Endpoint != "" { 270 - return holdRecord.Endpoint 268 + return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint) 271 269 } 272 270 } 273 271 274 - // 3. No profile defaultHold and no own hold records - use AppView default 275 - return nr.defaultStorageEndpoint 272 + // No profile defaultHold and no own hold records - use AppView default 273 + return nr.defaultHoldDID 276 274 } 277 275 278 276 // isHoldReachable checks if a hold service is reachable
+9 -9
pkg/appview/storage/hold_cache.go
··· 5 5 "time" 6 6 ) 7 7 8 - // HoldCache caches hold endpoints for (DID, repository) pairs 8 + // HoldCache caches hold DIDs for (DID, repository) pairs 9 9 // This avoids expensive ATProto lookups on every blob request during pulls 10 10 // 11 11 // NOTE: This is a simple in-memory cache for MVP. For production deployments: ··· 18 18 } 19 19 20 20 type holdCacheEntry struct { 21 - holdEndpoint string 22 - expiresAt time.Time 21 + holdDID string 22 + expiresAt time.Time 23 23 } 24 24 25 25 var globalHoldCache = &HoldCache{ ··· 42 42 return globalHoldCache 43 43 } 44 44 45 - // Set stores a hold endpoint for a (DID, repository) pair with a TTL 46 - func (c *HoldCache) Set(did, repository, holdEndpoint string, ttl time.Duration) { 45 + // Set stores a hold DID for a (DID, repository) pair with a TTL 46 + func (c *HoldCache) Set(did, repository, holdDID string, ttl time.Duration) { 47 47 c.mu.Lock() 48 48 defer c.mu.Unlock() 49 49 50 50 key := did + ":" + repository 51 51 c.cache[key] = &holdCacheEntry{ 52 - holdEndpoint: holdEndpoint, 53 - expiresAt: time.Now().Add(ttl), 52 + holdDID: holdDID, 53 + expiresAt: time.Now().Add(ttl), 54 54 } 55 55 } 56 56 57 - // Get retrieves a hold endpoint for a (DID, repository) pair 57 + // Get retrieves a hold DID for a (DID, repository) pair 58 58 // Returns empty string and false if not found or expired 59 59 func (c *HoldCache) Get(did, repository string) (string, bool) { 60 60 c.mu.RLock() ··· 72 72 return "", false 73 73 } 74 74 75 - return entry.holdEndpoint, true 75 + return entry.holdDID, true 76 76 } 77 77 78 78 // Cleanup removes expired entries (called automatically every 5 minutes)
+39 -21
pkg/appview/storage/proxy_blob_store.go
··· 7 7 "fmt" 8 8 "io" 9 9 "net/http" 10 + "strings" 10 11 "sync" 11 12 "time" 12 13 13 - "atcr.io/pkg/atproto" 14 14 "atcr.io/pkg/auth" 15 15 "github.com/distribution/distribution/v3" 16 16 "github.com/opencontainers/go-digest" ··· 31 31 32 32 // ProxyBlobStore proxies blob requests to an external storage service 33 33 type ProxyBlobStore struct { 34 - storageEndpoint string 35 - httpClient *http.Client 36 - did string 37 - database DatabaseMetrics 38 - repository string 39 - authorizer auth.HoldAuthorizer 40 - holdDID string 34 + holdDID string // Hold DID (e.g., "did:web:hold01.atcr.io") 35 + holdURL string // Resolved HTTP URL for XRPC requests 36 + httpClient *http.Client 37 + did string 38 + database DatabaseMetrics 39 + repository string 40 + authorizer auth.HoldAuthorizer 41 41 } 42 42 43 43 // NewProxyBlobStore creates a new proxy blob store 44 - func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore { 45 - // Convert storage endpoint URL to did:web DID for authorization 46 - holdDID := atproto.ResolveHoldDIDFromURL(storageEndpoint) 47 - fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, holdDID=%s, userDID=%s, repo=%s\n", 48 - storageEndpoint, holdDID, did, repository) 44 + func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore { 45 + // Resolve DID to URL once at construction time 46 + holdURL := resolveHoldURL(holdDID) 47 + 48 + fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n", 49 + holdDID, holdURL, did, repository) 49 50 50 51 return &ProxyBlobStore{ 51 - storageEndpoint: storageEndpoint, 52 + holdDID: holdDID, 53 + holdURL: holdURL, 52 54 httpClient: &http.Client{ 53 55 Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads 54 56 Transport: &http.Transport{ ··· 63 65 database: database, 64 66 repository: repository, 65 67 authorizer: authorizer, 66 - holdDID: holdDID, 67 68 } 69 + } 70 + 71 + // resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests 72 + // did:web:hold01.atcr.io → https://hold01.atcr.io 73 + // did:web:172.28.0.3:8080 → http://172.28.0.3:8080 74 + func resolveHoldURL(holdDID string) string { 75 + hostname := strings.TrimPrefix(holdDID, "did:web:") 76 + 77 + // Use HTTP for localhost/IP addresses with ports, HTTPS for domains 78 + if strings.Contains(hostname, ":") || 79 + strings.Contains(hostname, "127.0.0.1") || 80 + strings.Contains(hostname, "localhost") || 81 + // Check if it's an IP address (contains only digits and dots) 82 + (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { 83 + return "http://" + hostname 84 + } 85 + return "https://" + hostname 68 86 } 69 87 70 88 // checkReadAccess verifies the user has read access to the hold ··· 347 365 // Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid={digest} 348 366 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix) 349 367 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 350 - p.storageEndpoint, p.holdDID, dgst.String()) 368 + p.holdURL, p.holdDID, dgst.String()) 351 369 return url, nil 352 370 } 353 371 ··· 356 374 func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) { 357 375 // Same as GET - hold service handles HEAD method on getBlob endpoint 358 376 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 359 - p.storageEndpoint, p.holdDID, dgst.String()) 377 + p.holdURL, p.holdDID, dgst.String()) 360 378 return url, nil 361 379 } 362 380 ··· 378 396 return "", err 379 397 } 380 398 381 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) 399 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) 382 400 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 383 401 if err != nil { 384 402 return "", err ··· 428 446 return nil, err 429 447 } 430 448 431 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) 449 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) 432 450 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 433 451 if err != nil { 434 452 return nil, err ··· 478 496 return err 479 497 } 480 498 481 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) 499 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) 482 500 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 483 501 if err != nil { 484 502 return err ··· 512 530 return err 513 531 } 514 532 515 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) 533 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) 516 534 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 517 535 if err != nil { 518 536 return err
+35 -36
pkg/appview/storage/routing_repository.go
··· 20 20 // The registry (AppView) is stateless and NEVER stores blobs locally 21 21 type RoutingRepository struct { 22 22 distribution.Repository 23 - atprotoClient *atproto.Client 24 - repositoryName string 25 - storageEndpoint string // Hold service endpoint for blobs (from discovery for push) 26 - did string // User's DID for authorization 27 - manifestStore *atproto.ManifestStore // Cached manifest store instance 28 - blobStore *ProxyBlobStore // Cached blob store instance 29 - database DatabaseMetrics // Database for metrics tracking 30 - authorizer auth.HoldAuthorizer // Authorization for hold access 23 + atprotoClient *atproto.Client 24 + repositoryName string 25 + holdDID string // Hold service DID for blobs (from discovery for push), e.g., "did:web:hold01.atcr.io" 26 + did string // User's DID for authorization 27 + manifestStore *atproto.ManifestStore // Cached manifest store instance 28 + blobStore *ProxyBlobStore // Cached blob store instance 29 + database DatabaseMetrics // Database for metrics tracking 30 + authorizer auth.HoldAuthorizer // Authorization for hold access 31 31 } 32 32 33 33 // NewRoutingRepository creates a new routing repository ··· 35 35 baseRepo distribution.Repository, 36 36 atprotoClient *atproto.Client, 37 37 repoName string, 38 - storageEndpoint string, 38 + holdDID string, 39 39 did string, 40 40 database DatabaseMetrics, 41 41 authorizer auth.HoldAuthorizer, 42 42 ) *RoutingRepository { 43 43 return &RoutingRepository{ 44 - Repository: baseRepo, 45 - atprotoClient: atprotoClient, 46 - repositoryName: repoName, 47 - storageEndpoint: storageEndpoint, 48 - did: did, 49 - database: database, 50 - authorizer: authorizer, 44 + Repository: baseRepo, 45 + atprotoClient: atprotoClient, 46 + repositoryName: repoName, 47 + holdDID: holdDID, 48 + did: did, 49 + database: database, 50 + authorizer: authorizer, 51 51 } 52 52 } 53 53 ··· 58 58 // Ensure blob store is created first (needed for label extraction during push) 59 59 blobStore := r.Blobs(ctx) 60 60 61 - // Resolve hold endpoint URL to DID 62 - holdDID := atproto.ResolveHoldDIDFromURL(r.storageEndpoint) 63 - 64 - r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, holdDID, r.did, blobStore, r.database) 61 + // ManifestStore needs both DID and URL for backward compat (legacy holdEndpoint field) 62 + // For now, pass holdDID twice (will be cleaned up in manifest_store.go later) 63 + r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.holdDID, r.holdDID, r.did, blobStore, r.database) 65 64 } 66 65 67 - // After any manifest operation, cache the hold endpoint for blob fetches 66 + // After any manifest operation, cache the hold DID for blob fetches 68 67 // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete 69 68 go func() { 70 69 time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete 71 - if holdEndpoint := r.manifestStore.GetLastFetchedHoldEndpoint(); holdEndpoint != "" { 70 + if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" { 72 71 // Cache for 10 minutes - should cover typical pull operations 73 - GetGlobalHoldCache().Set(r.did, r.repositoryName, holdEndpoint, 10*time.Minute) 74 - fmt.Printf("DEBUG [storage/routing]: Cached hold endpoint: did=%s, repo=%s, hold=%s\n", 75 - r.did, r.repositoryName, holdEndpoint) 72 + GetGlobalHoldCache().Set(r.did, r.repositoryName, holdDID, 10*time.Minute) 73 + fmt.Printf("DEBUG [storage/routing]: Cached hold DID: did=%s, repo=%s, hold=%s\n", 74 + r.did, r.repositoryName, holdDID) 76 75 } 77 76 }() 78 77 ··· 89 88 return r.blobStore 90 89 } 91 90 92 - // For pull operations, check if we have a cached hold endpoint from a recent manifest fetch 91 + // For pull operations, check if we have a cached hold DID from a recent manifest fetch 93 92 // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered 94 - holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint 93 + holdDID := r.holdDID // Default to discovery-based DID 95 94 96 - if cachedHold, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok { 97 - // Use cached hold from manifest 98 - holdEndpoint = cachedHold 95 + if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok { 96 + // Use cached hold DID from manifest 97 + holdDID = cachedHoldDID 99 98 fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n", 100 - r.did, r.repositoryName, cachedHold) 99 + r.did, r.repositoryName, cachedHoldDID) 101 100 } else { 102 - // No cached hold, use discovery-based endpoint (for push or first pull) 101 + // No cached hold, use discovery-based DID (for push or first pull) 103 102 fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n", 104 - r.did, r.repositoryName, holdEndpoint) 103 + r.did, r.repositoryName, holdDID) 105 104 } 106 105 107 - if holdEndpoint == "" { 106 + if holdDID == "" { 108 107 // This should never happen if middleware is configured correctly 109 - panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware") 108 + panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware") 110 109 } 111 110 112 111 // Create and cache proxy blob store with authorization 113 - r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName, r.authorizer) 112 + r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer) 114 113 return r.blobStore 115 114 } 116 115
+16 -35
pkg/atproto/manifest_store.go
··· 21 21 // ManifestStore implements distribution.ManifestService 22 22 // It stores manifests in ATProto as records 23 23 type ManifestStore struct { 24 - client *Client 25 - repository string 26 - holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 - holdDID string // Hold service DID (primary reference) 28 - did string // User's DID for cache key 29 - lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) 30 - blobStore distribution.BlobStore // Blob store for fetching config during push 31 - database DatabaseMetrics // Database for metrics tracking 24 + client *Client 25 + repository string 26 + holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 + holdDID string // Hold service DID (primary reference) 28 + did string // User's DID for cache key 29 + lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 30 + blobStore distribution.BlobStore // Blob store for fetching config during push 31 + database DatabaseMetrics // Database for metrics tracking 32 32 } 33 33 34 34 // NewManifestStore creates a new ATProto-backed manifest store ··· 74 74 return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err) 75 75 } 76 76 77 - // Store the hold endpoint for subsequent blob requests during pull 77 + // Store the hold DID for subsequent blob requests during pull 78 78 // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format) 79 79 // The routing repository will cache this for concurrent blob fetches 80 80 if manifestRecord.HoldDID != "" { 81 - // New format: DID reference 82 - // Convert did:web back to URL for blob fetching 83 - // TODO: Routing repository should handle DID→URL conversion 84 - // For now, fall back to HoldEndpoint if available 85 - if manifestRecord.HoldEndpoint != "" { 86 - s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint 87 - } else { 88 - // Convert did:web:hold.example.com → https://hold.example.com 89 - s.lastFetchedHoldEndpoint = didToURL(manifestRecord.HoldDID) 90 - } 81 + // New format: DID reference (preferred) 82 + s.lastFetchedHoldDID = manifestRecord.HoldDID 91 83 } else if manifestRecord.HoldEndpoint != "" { 92 - // Legacy format: URL reference 93 - s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint 84 + // Legacy format: URL reference - convert to DID 85 + s.lastFetchedHoldDID = ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 94 86 } 95 87 96 88 var ociManifest []byte ··· 246 238 return repository, tag 247 239 } 248 240 249 - // GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest 241 + // GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest 250 242 // This is used by the routing repository to cache the hold for blob requests 251 - func (s *ManifestStore) GetLastFetchedHoldEndpoint() string { 252 - return s.lastFetchedHoldEndpoint 243 + func (s *ManifestStore) GetLastFetchedHoldDID() string { 244 + return s.lastFetchedHoldDID 253 245 } 254 246 255 247 // rawManifest is a simple implementation of distribution.Manifest ··· 294 286 295 287 return configJSON.Config.Labels, nil 296 288 } 297 - 298 - // didToURL converts a did:web DID to an HTTPS URL 299 - // e.g., did:web:hold.example.com → https://hold.example.com 300 - func didToURL(didWeb string) string { 301 - if !strings.HasPrefix(didWeb, "did:web:") { 302 - return didWeb // Not a did:web, return as-is 303 - } 304 - 305 - hostname := strings.TrimPrefix(didWeb, "did:web:") 306 - return "https://" + hostname 307 - }
+1 -18
pkg/auth/oauth/server.go
··· 342 342 fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold) 343 343 344 344 // Resolve URL to DID 345 - holdDID = resolveHoldDIDFromURL(profile.DefaultHold) 345 + holdDID = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 346 346 347 347 // Update profile with DID 348 348 profile.DefaultHold = holdDID ··· 362 362 // For now, crew registration will happen on first push when appview validates access 363 363 fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID) 364 364 _ = session // TODO: use session for crew registration 365 - } 366 - 367 - // resolveHoldDIDFromURL converts a hold endpoint URL to a DID 368 - // For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io 369 - func resolveHoldDIDFromURL(holdURL string) string { 370 - // Parse URL to get hostname 371 - holdURL = strings.TrimPrefix(holdURL, "http://") 372 - holdURL = strings.TrimPrefix(holdURL, "https://") 373 - holdURL = strings.TrimSuffix(holdURL, "/") 374 - 375 - // Extract hostname (remove path if present) 376 - parts := strings.Split(holdURL, "/") 377 - hostname := parts[0] 378 - 379 - // Convert to did:web 380 - // did:web uses hostname directly (port included if non-standard) 381 - return "did:web:" + hostname 382 365 } 383 366 384 367 // HTML templates