A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix

+852 -462
+1 -1
.env.hold.example
··· 89 89 90 90 # Enable Bluesky posts when users push container images (default: false) 91 91 # When enabled, the hold's embedded PDS will create posts announcing image pushes 92 - # Can be overridden per-hold via the captain record's enableManifestPosts field 92 + # Synced to captain record's enableBlueskyPosts field on startup 93 93 # HOLD_BLUESKY_POSTS_ENABLED=false 94 94 95 95 # ==============================================================================
+146 -18
cmd/appview/serve.go
··· 9 9 "net/http" 10 10 "os" 11 11 "os/signal" 12 + "strings" 12 13 "syscall" 13 14 "time" 14 15 16 + "github.com/bluesky-social/indigo/atproto/syntax" 15 17 "github.com/distribution/distribution/v3/configuration" 16 18 "github.com/distribution/distribution/v3/registry" 17 19 "github.com/distribution/distribution/v3/registry/handlers" 18 20 "github.com/spf13/cobra" 19 21 20 22 "atcr.io/pkg/appview/middleware" 23 + "atcr.io/pkg/appview/storage" 24 + "atcr.io/pkg/atproto" 21 25 "atcr.io/pkg/auth" 22 26 "atcr.io/pkg/auth/oauth" 23 27 "atcr.io/pkg/auth/token" ··· 206 210 fmt.Println("README cache initialized for manifest push refresh") 207 211 208 212 // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache 209 - uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache) 213 + uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache) 210 214 211 215 // Create OAuth server 212 216 oauthServer := oauth.NewServer(oauthApp) ··· 216 220 if uiSessionStore != nil { 217 221 oauthServer.SetUISessionStore(uiSessionStore) 218 222 } 219 - // Connect database for user avatar management 220 - oauthServer.SetDatabase(uiDatabase) 223 + 224 + // Register OAuth post-auth callback for AppView business logic 225 + // This decouples the OAuth package from AppView-specific dependencies 226 + oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error { 227 + fmt.Printf("DEBUG [appview/callback]: OAuth post-auth callback for DID=%s\n", did) 228 + 229 + // Parse DID for session resume 230 + didParsed, err := syntax.ParseDID(did) 231 + if err != nil { 232 + fmt.Printf("WARNING [appview/callback]: Failed to parse DID %s: %v\n", did, err) 233 + return nil // Non-fatal 234 + } 235 + 236 + // Resume OAuth session to get authenticated client 237 + session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID) 238 + if err != nil { 239 + fmt.Printf("WARNING [appview/callback]: Failed to resume session for DID=%s: %v\n", did, err) 240 + // Fallback: update user without avatar 241 + _ = db.UpsertUser(uiDatabase, &db.User{ 242 + DID: did, 243 + Handle: handle, 244 + PDSEndpoint: pdsEndpoint, 245 + Avatar: "", 246 + LastSeen: time.Now(), 247 + }) 248 + return nil // Non-fatal 249 + } 221 250 222 - // Set default hold DID on OAuth server (extracted earlier) 223 - // This is used to create sailor profiles on first login 224 - if defaultHoldDID != "" { 225 - oauthServer.SetDefaultHoldDID(defaultHoldDID) 226 - fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID) 227 - } 251 + // Create authenticated atproto client using the indigo session's API client 252 + client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient()) 253 + 254 + // Ensure sailor profile exists (creates with default hold if configured) 255 + fmt.Printf("DEBUG [appview/callback]: Ensuring profile exists for %s (defaultHold=%s)\n", did, defaultHoldDID) 256 + if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil { 257 + fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err) 258 + // Continue anyway - profile creation is not critical for avatar fetch 259 + } else { 260 + fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s\n", did) 261 + } 262 + 263 + // Fetch user's profile record from PDS (contains blob references) 264 + profileRecord, err := client.GetProfileRecord(ctx, did) 265 + if err != nil { 266 + fmt.Printf("WARNING [appview/callback]: Failed to fetch profile record for DID=%s: %v\n", did, err) 267 + // Still update user without avatar 268 + _ = db.UpsertUser(uiDatabase, &db.User{ 269 + DID: did, 270 + Handle: handle, 271 + PDSEndpoint: pdsEndpoint, 272 + Avatar: "", 273 + LastSeen: time.Now(), 274 + }) 275 + return nil // Non-fatal 276 + } 277 + 278 + // Construct avatar URL from blob CID using imgs.blue CDN 279 + var avatarURL string 280 + if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 281 + avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link) 282 + fmt.Printf("DEBUG [appview/callback]: Constructed avatar URL: %s\n", avatarURL) 283 + } 284 + 285 + // Store user with avatar in database 286 + err = db.UpsertUser(uiDatabase, &db.User{ 287 + DID: did, 288 + Handle: handle, 289 + PDSEndpoint: pdsEndpoint, 290 + Avatar: avatarURL, 291 + LastSeen: time.Now(), 292 + }) 293 + if err != nil { 294 + fmt.Printf("WARNING [appview/callback]: Failed to store user in database: %v\n", err) 295 + return nil // Non-fatal 296 + } 297 + 298 + fmt.Printf("DEBUG [appview/callback]: Stored user with avatar for DID=%s\n", did) 299 + 300 + // Migrate profile URL→DID if needed 301 + profile, err := storage.GetProfile(ctx, client) 302 + if err != nil { 303 + fmt.Printf("WARNING [appview/callback]: Failed to get profile for %s: %v\n", did, err) 304 + return nil // Non-fatal 305 + } 306 + 307 + var holdDID string 308 + if profile != nil && profile.DefaultHold != "" { 309 + // Check if defaultHold is a URL (needs migration) 310 + if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { 311 + fmt.Printf("DEBUG [appview/callback]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold) 312 + 313 + // Resolve URL to DID 314 + holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 315 + 316 + // Update profile with DID 317 + profile.DefaultHold = holdDID 318 + if err := storage.UpdateProfile(ctx, client, profile); err != nil { 319 + fmt.Printf("WARNING [appview/callback]: Failed to update profile with hold DID for %s: %v\n", did, err) 320 + } else { 321 + fmt.Printf("DEBUG [appview/callback]: Updated profile with hold DID: %s\n", holdDID) 322 + } 323 + fmt.Printf("DEBUG [oauth/server]: Attempting crew registration for %s at hold %s\n", did, holdDID) 324 + storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 325 + } else { 326 + // Already a DID - use it 327 + holdDID = profile.DefaultHold 328 + } 329 + // Register crew regardless of migration (outside the migration block) 330 + fmt.Printf("DEBUG [appview/callback]: Attempting crew registration for %s at hold %s\n", did, holdDID) 331 + storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 332 + 333 + } 334 + 335 + return nil // All errors are non-fatal, logged for debugging 336 + }) 228 337 229 338 // Initialize auth keys and create token issuer 230 339 var issuer *token.Issuer ··· 284 393 // Mount auth endpoints if enabled 285 394 if issuer != nil { 286 395 // Basic Auth token endpoint (supports device secrets and app passwords) 287 - // Reuse defaultHoldDID extracted earlier 288 - tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldDID) 396 + tokenHandler := token.NewHandler(issuer, deviceStore) 397 + 398 + // Register token post-auth callback for profile management 399 + // This decouples the token package from AppView-specific dependencies 400 + tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error { 401 + fmt.Printf("DEBUG [appview/callback]: Token post-auth callback for DID=%s\n", did) 402 + 403 + // Create ATProto client with validated token 404 + atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) 405 + 406 + // Ensure profile exists (will create with default hold if not exists and default is configured) 407 + if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil { 408 + // Log error but don't fail auth - profile management is not critical 409 + fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err) 410 + } else { 411 + fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s with default hold %s\n", did, defaultHoldDID) 412 + } 413 + 414 + return nil // All errors are non-fatal 415 + }) 416 + 289 417 tokenHandler.RegisterRoutes(mux) 290 418 291 419 // Device authorization endpoints (public) ··· 401 529 // readOnlyDB: read-only connection for public queries (search, user pages, etc.) 402 530 // defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io") 403 531 // healthChecker: hold endpoint health checker 404 - func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) { 532 + func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, oauthStore *db.OAuthStore, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) { 405 533 // Check if UI is enabled 406 534 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 407 535 if uiEnabled == "false" { ··· 582 710 }).Methods("DELETE") 583 711 584 712 // Logout endpoint (supports both GET and POST) 585 - router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { 586 - if sessionID, ok := db.GetSessionID(r); ok { 587 - sessionStore.Delete(sessionID) 588 - } 589 - db.ClearCookie(w) 590 - http.Redirect(w, r, "/", http.StatusFound) 713 + // Properly revokes OAuth tokens on PDS side before clearing local session 714 + router.Handle("/auth/logout", &uihandlers.LogoutHandler{ 715 + OAuthApp: oauthApp, 716 + Refresher: refresher, 717 + SessionStore: sessionStore, 718 + OAuthStore: oauthStore, 591 719 }).Methods("GET", "POST") 592 720 593 721 // Start Jetstream worker
+9 -8
docs/BLUESKY_MANIFEST_POSTS.md
··· 694 694 ```bash 695 695 # Enable/disable Bluesky manifest posting (default: false) 696 696 # When enabled, hold will create Bluesky posts when users push images 697 - # Can be overridden per-hold via captain record's enableManifestPosts field 697 + # Synced to captain record's enableBlueskyPosts field on startup 698 698 HOLD_BLUESKY_POSTS_ENABLED=false 699 699 ``` 700 700 ··· 702 702 703 703 ### Feature Flags 704 704 705 - **Captain Record Override:** 706 - The hold's captain record includes an `enableManifestPosts` field that overrides the environment variable: 705 + **Captain Record Sync:** 706 + The hold's captain record includes an `enableBlueskyPosts` field that is synchronized with the environment variable on startup: 707 707 708 708 ```go 709 709 type CaptainRecord struct { 710 710 // ... other fields ... 711 - EnableManifestPosts bool `json:"enableManifestPosts" cborgen:"enableManifestPosts"` 711 + EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` 712 712 } 713 713 ``` 714 714 715 - **Precedence (highest to lowest):** 716 - 1. Captain record `enableManifestPosts` field (if set) 717 - 2. `HOLD_BLUESKY_POSTS_ENABLED` environment variable 718 - 3. Default: `false` (opt-in feature) 715 + **How it works:** 716 + 1. On startup, Bootstrap reads `HOLD_BLUESKY_POSTS_ENABLED` environment variable 717 + 2. Creates or updates the captain record to match the env var setting 718 + 3. At runtime, the code reads from the captain record (which reflects the env var) 719 + 4. To change the setting, update the env var and restart the hold 719 720 720 721 **Rationale:** 721 722 - Default off for backward compatibility and privacy
+6 -3
pkg/appview/config.go
··· 38 38 // Storage (fake in-memory placeholder - all real storage is proxied) 39 39 config.Storage = buildStorageConfig() 40 40 41 + // Get base URL for error messages and auth config 42 + baseURL := GetBaseURL(httpConfig.Addr) 43 + 41 44 // Middleware (ATProto resolver) 42 45 defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID") 43 46 if defaultHoldDID == "" { 44 47 return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") 45 48 } 46 - config.Middleware = buildMiddlewareConfig(defaultHoldDID) 49 + config.Middleware = buildMiddlewareConfig(defaultHoldDID, baseURL) 47 50 48 51 // Auth 49 - baseURL := GetBaseURL(httpConfig.Addr) 50 52 authConfig, err := buildAuthConfig(baseURL) 51 53 if err != nil { 52 54 return nil, fmt.Errorf("failed to build auth config: %w", err) ··· 128 130 } 129 131 130 132 // buildMiddlewareConfig creates middleware configuration 131 - func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Middleware { 133 + func buildMiddlewareConfig(defaultHoldDID string, baseURL string) map[string][]configuration.Middleware { 132 134 // Check test mode 133 135 testMode := os.Getenv("TEST_MODE") == "true" 134 136 ··· 139 141 Options: configuration.Parameters{ 140 142 "default_hold_did": defaultHoldDID, 141 143 "test_mode": testMode, 144 + "base_url": baseURL, 142 145 }, 143 146 }, 144 147 },
+8 -1
pkg/appview/config_test.go
··· 368 368 tests := []struct { 369 369 name string 370 370 defaultHoldDID string 371 + baseURL string 371 372 testMode bool 372 373 setTestMode bool 373 374 wantTestMode bool ··· 375 376 { 376 377 name: "normal mode", 377 378 defaultHoldDID: "did:web:hold01.atcr.io", 379 + baseURL: "https://atcr.io", 378 380 setTestMode: false, 379 381 wantTestMode: false, 380 382 }, 381 383 { 382 384 name: "test mode enabled", 383 385 defaultHoldDID: "did:web:hold01.atcr.io", 386 + baseURL: "https://atcr.io", 384 387 testMode: true, 385 388 setTestMode: true, 386 389 wantTestMode: true, ··· 395 398 os.Unsetenv("TEST_MODE") 396 399 } 397 400 398 - got := buildMiddlewareConfig(tt.defaultHoldDID) 401 + got := buildMiddlewareConfig(tt.defaultHoldDID, tt.baseURL) 399 402 400 403 registryMW, ok := got["registry"] 401 404 if !ok { ··· 413 416 414 417 if mw.Options["default_hold_did"] != tt.defaultHoldDID { 415 418 t.Errorf("default_hold_did = %v, want %v", mw.Options["default_hold_did"], tt.defaultHoldDID) 419 + } 420 + 421 + if mw.Options["base_url"] != tt.baseURL { 422 + t.Errorf("base_url = %v, want %v", mw.Options["base_url"], tt.baseURL) 416 423 } 417 424 418 425 if mw.Options["test_mode"] != tt.wantTestMode {
+67
pkg/appview/handlers/logout.go
··· 1 + package handlers 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "atcr.io/pkg/appview/db" 8 + "atcr.io/pkg/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + // LogoutHandler handles user logout with proper OAuth token revocation 13 + type LogoutHandler struct { 14 + OAuthApp *oauth.App 15 + Refresher *oauth.Refresher 16 + SessionStore *db.SessionStore 17 + OAuthStore *db.OAuthStore 18 + } 19 + 20 + func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 + // Get UI session ID from cookie 22 + uiSessionID, hasSession := db.GetSessionID(r) 23 + if !hasSession { 24 + // No session to logout from, just redirect 25 + http.Redirect(w, r, "/", http.StatusFound) 26 + return 27 + } 28 + 29 + // Get UI session to extract OAuth session ID and user info 30 + uiSession, ok := h.SessionStore.Get(uiSessionID) 31 + if ok && uiSession != nil && uiSession.DID != "" { 32 + // Parse DID for OAuth logout 33 + did, err := syntax.ParseDID(uiSession.DID) 34 + if err != nil { 35 + fmt.Printf("WARNING [logout]: Failed to parse DID %s: %v\n", uiSession.DID, err) 36 + } else { 37 + // Attempt to revoke OAuth tokens on PDS side 38 + if uiSession.OAuthSessionID != "" { 39 + // Call indigo's Logout to revoke tokens on PDS 40 + if err := h.OAuthApp.GetClientApp().Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil { 41 + // Log error but don't block logout - best effort revocation 42 + fmt.Printf("WARNING [logout]: Failed to revoke OAuth tokens for %s on PDS: %v\n", uiSession.DID, err) 43 + } else { 44 + fmt.Printf("INFO [logout]: Successfully revoked OAuth tokens for %s on PDS\n", uiSession.DID) 45 + } 46 + 47 + // Invalidate refresher cache to clear local access tokens 48 + h.Refresher.InvalidateSession(uiSession.DID) 49 + fmt.Printf("INFO [logout]: Invalidated local OAuth cache for %s\n", uiSession.DID) 50 + 51 + // Delete OAuth session from database (cleanup, might already be done by Logout) 52 + if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil { 53 + fmt.Printf("WARNING [logout]: Failed to delete OAuth session from database: %v\n", err) 54 + } 55 + } else { 56 + fmt.Printf("WARNING [logout]: No OAuth session ID found for user %s\n", uiSession.DID) 57 + } 58 + } 59 + } 60 + 61 + // Always delete UI session and clear cookie, even if OAuth revocation failed 62 + h.SessionStore.Delete(uiSessionID) 63 + db.ClearCookie(w) 64 + 65 + // Redirect to home page 66 + http.Redirect(w, r, "/", http.StatusFound) 67 + }
+4 -3
pkg/appview/handlers/settings.go
··· 7 7 "time" 8 8 9 9 "atcr.io/pkg/appview/middleware" 10 + "atcr.io/pkg/appview/storage" 10 11 "atcr.io/pkg/atproto" 11 12 "atcr.io/pkg/auth/oauth" 12 13 ) ··· 41 42 client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 42 43 43 44 // Fetch sailor profile 44 - profile, err := atproto.GetProfile(r.Context(), client) 45 + profile, err := storage.GetProfile(r.Context(), client) 45 46 if err != nil { 46 47 // Error fetching profile - log out user 47 48 fmt.Printf("WARNING [settings]: Failed to fetch profile for %s: %v - logging out\n", user.DID, err) ··· 111 112 client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 112 113 113 114 // Fetch existing profile or create new one 114 - profile, err := atproto.GetProfile(r.Context(), client) 115 + profile, err := storage.GetProfile(r.Context(), client) 115 116 if err != nil || profile == nil { 116 117 // Profile doesn't exist, create new one 117 118 profile = atproto.NewSailorProfileRecord(holdEndpoint) ··· 122 123 } 123 124 124 125 // Save profile 125 - if err := atproto.UpdateProfile(r.Context(), client, profile); err != nil { 126 + if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { 126 127 http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 127 128 return 128 129 }
+2 -2
pkg/appview/holdhealth/checker.go
··· 10 10 "sync" 11 11 "time" 12 12 13 - "atcr.io/pkg/appview" 13 + "atcr.io/pkg/atproto" 14 14 ) 15 15 16 16 // HealthStatus represents the health status of a hold endpoint ··· 53 53 // Convert DID to HTTP URL if needed 54 54 // did:web:hold.example.com → https://hold.example.com 55 55 // https://hold.example.com → https://hold.example.com (passthrough) 56 - httpURL := appview.ResolveHoldURL(endpoint) 56 + httpURL := atproto.ResolveHoldURL(endpoint) 57 57 58 58 // Build health check URL 59 59 healthURL := httpURL + "/xrpc/_health"
+1 -2
pkg/appview/jetstream/backfill.go
··· 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 13 - "atcr.io/pkg/appview" 14 13 "atcr.io/pkg/appview/db" 15 14 "atcr.io/pkg/atproto" 16 15 ) ··· 327 326 } 328 327 329 328 // Resolve hold DID to URL 330 - holdURL := appview.ResolveHoldURL(holdDID) 329 + holdURL := atproto.ResolveHoldURL(holdDID) 331 330 332 331 // Create client for hold's PDS 333 332 holdClient := atproto.NewClient(holdURL, holdDID, "")
+22 -96
pkg/appview/middleware/registry.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "io" 8 - "net/http" 9 - "net/url" 10 7 "strings" 11 8 "sync" 12 - "time" 13 9 14 10 "github.com/bluesky-social/indigo/atproto/identity" 15 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 73 69 distribution.Namespace 74 70 directory identity.Directory 75 71 defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 72 + baseURL string // Base URL for error messages (e.g., "https://atcr.io") 76 73 testMode bool // If true, fallback to default hold when user's hold is unreachable 77 74 repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) 78 75 refresher *oauth.Refresher // OAuth session manager (copied from global on init) ··· 93 90 defaultHoldDID = holdDID 94 91 } 95 92 93 + // Get base URL from config (for error messages) 94 + baseURL := "" 95 + if url, ok := options["base_url"].(string); ok { 96 + baseURL = url 97 + } 98 + 96 99 // Check test mode from options (passed via env var) 97 100 testMode := false 98 101 if tm, ok := options["test_mode"].(bool); ok { ··· 105 108 Namespace: ns, 106 109 directory: directory, 107 110 defaultHoldDID: defaultHoldDID, 111 + baseURL: baseURL, 108 112 testMode: testMode, 109 113 refresher: globalRefresher, 110 114 database: globalDatabase, ··· 113 117 }, nil 114 118 } 115 119 120 + // authErrorMessage creates a user-friendly auth error with login URL 121 + func (nr *NamespaceResolver) authErrorMessage(message string) error { 122 + loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL) 123 + fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL) 124 + return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage) 125 + } 126 + 116 127 // Repository resolves the repository name and delegates to underlying namespace 117 128 // Handles names like: 118 129 // - atcr.io/alice/myimage → resolve alice to DID ··· 160 171 ctx = context.WithValue(ctx, holdDIDKey, holdDID) 161 172 162 173 // Get service token for hold authentication 163 - // Check cache first to avoid unnecessary PDS calls on every request 164 174 var serviceToken string 165 175 if nr.refresher != nil { 166 - cachedToken, expiresAt := token.GetServiceToken(did, holdDID) 167 - 168 - // Use cached token if it exists and has > 10s remaining 169 - if cachedToken != "" && time.Until(expiresAt) > 10*time.Second { 170 - fmt.Printf("DEBUG [registry/middleware]: Using cached service token for DID=%s (expires in %v)\n", 171 - did, time.Until(expiresAt).Round(time.Second)) 172 - serviceToken = cachedToken 173 - } else { 174 - // Cache miss or expiring soon - validate OAuth and get new service token 175 - if cachedToken == "" { 176 - fmt.Printf("DEBUG [registry/middleware]: Cache miss, fetching service token for DID=%s\n", did) 177 - } else { 178 - fmt.Printf("DEBUG [registry/middleware]: Token expiring soon, proactively renewing for DID=%s\n", did) 179 - } 180 - 181 - session, err := nr.refresher.GetSession(ctx, did) 182 - if err != nil { 183 - // OAuth session unavailable - fail fast with proper auth error 184 - nr.refresher.InvalidateSession(did) 185 - token.InvalidateServiceToken(did, holdDID) 186 - fmt.Printf("ERROR [registry/middleware]: Failed to get OAuth session for DID=%s: %v\n", did, err) 187 - fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n") 188 - return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate") 189 - } 190 - 191 - // Call com.atproto.server.getServiceAuth on the user's PDS 192 - // Request 5-minute expiry (PDS may grant less) 193 - // exp must be absolute Unix timestamp, not relative duration 194 - // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID 195 - expiryTime := time.Now().Unix() + 300 // 5 minutes from now 196 - serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d", 197 - pdsEndpoint, 198 - atproto.ServerGetServiceAuth, 199 - url.QueryEscape(holdDID), 200 - url.QueryEscape("com.atproto.repo.getRecord"), 201 - expiryTime, 202 - ) 203 - 204 - req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil) 205 - if err != nil { 206 - fmt.Printf("ERROR [registry/middleware]: Failed to create service auth request: %v\n", err) 207 - return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed") 208 - } 209 - 210 - // Use OAuth session to authenticate to PDS (with DPoP) 211 - resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth") 212 - if err != nil { 213 - // Invalidate session on auth errors (may indicate corrupted session or expired tokens) 214 - nr.refresher.InvalidateSession(did) 215 - token.InvalidateServiceToken(did, holdDID) 216 - fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: %v\n", did, err) 217 - fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n") 218 - return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate") 219 - } 220 - defer resp.Body.Close() 221 - 222 - if resp.StatusCode != http.StatusOK { 223 - // Invalidate session on auth failures 224 - bodyBytes, _ := io.ReadAll(resp.Body) 225 - nr.refresher.InvalidateSession(did) 226 - token.InvalidateServiceToken(did, holdDID) 227 - fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: status %d, body: %s\n", 228 - did, resp.StatusCode, string(bodyBytes)) 229 - fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n") 230 - return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate") 231 - } 232 - 233 - // Parse response to get service token 234 - var result struct { 235 - Token string `json:"token"` 236 - } 237 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 238 - fmt.Printf("ERROR [registry/middleware]: Failed to decode service auth response: %v\n", err) 239 - return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed") 240 - } 241 - 242 - if result.Token == "" { 243 - fmt.Printf("ERROR [registry/middleware]: Empty token in service auth response\n") 244 - return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed") 245 - } 246 - 247 - serviceToken = result.Token 248 - 249 - // Cache the token (parses JWT to extract actual expiry) 250 - if err := token.SetServiceToken(did, holdDID, serviceToken); err != nil { 251 - fmt.Printf("WARN [registry/middleware]: Failed to cache service token: %v\n", err) 252 - // Non-fatal - we have the token, just won't be cached 253 - } 254 - 255 - fmt.Printf("DEBUG [registry/middleware]: OAuth validation succeeded for DID=%s\n", did) 176 + var err error 177 + serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint) 178 + if err != nil { 179 + fmt.Printf("ERROR [registry/middleware]: Failed to get service token for DID=%s: %v\n", did, err) 180 + fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n") 181 + return nil, nr.authErrorMessage("OAuth session expired") 256 182 } 257 183 } 258 184 ··· 366 292 client := atproto.NewClient(pdsEndpoint, did, "") 367 293 368 294 // Check for sailor profile 369 - profile, err := atproto.GetProfile(ctx, client) 295 + profile, err := storage.GetProfile(ctx, client) 370 296 if err != nil { 371 297 // Error reading profile (not a 404) - log and continue 372 298 fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)
+82
pkg/appview/storage/crew.go
··· 1 + package storage 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + 9 + "atcr.io/pkg/atproto" 10 + "atcr.io/pkg/auth/oauth" 11 + "atcr.io/pkg/auth/token" 12 + ) 13 + 14 + // EnsureCrewMembership attempts to register the user as a crew member on their default hold. 15 + // The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew, existing membership, etc). 16 + // This is best-effort and does not fail on errors. 17 + func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, defaultHoldDID string) { 18 + if defaultHoldDID == "" { 19 + return 20 + } 21 + 22 + // Normalize URL to DID if needed 23 + holdDID := atproto.ResolveHoldDIDFromURL(defaultHoldDID) 24 + if holdDID == "" { 25 + slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID) 26 + return 27 + } 28 + 29 + // Resolve hold DID to HTTP endpoint 30 + holdEndpoint := atproto.ResolveHoldURL(holdDID) 31 + 32 + // Get service token for the hold 33 + // Only works with OAuth (refresher required) - app passwords can't get service tokens 34 + if refresher == nil { 35 + slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID) 36 + return 37 + } 38 + 39 + // Wrap the refresher to match OAuthSessionRefresher interface 40 + serviceToken, err := token.GetOrFetchServiceToken(ctx, refresher, client.DID(), holdDID, client.PDSEndpoint()) 41 + if err != nil { 42 + slog.Warn("failed to get service token", "holdDID", holdDID, "error", err) 43 + return 44 + } 45 + 46 + // Call requestCrew endpoint - it handles all the logic: 47 + // - Checks allowAllCrew flag 48 + // - Checks if already a crew member (returns success if so) 49 + // - Creates crew record if authorized 50 + if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil { 51 + slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err) 52 + return 53 + } 54 + 55 + slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID()) 56 + } 57 + 58 + // requestCrewMembership calls the hold's requestCrew endpoint 59 + // The endpoint handles all authorization and duplicate checking internally 60 + func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error { 61 + url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew) 62 + 63 + req, err := http.NewRequestWithContext(ctx, "POST", url, nil) 64 + if err != nil { 65 + return err 66 + } 67 + 68 + req.Header.Set("Authorization", "Bearer "+serviceToken) 69 + req.Header.Set("Content-Type", "application/json") 70 + 71 + resp, err := http.DefaultClient.Do(req) 72 + if err != nil { 73 + return err 74 + } 75 + defer resp.Body.Close() 76 + 77 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 78 + return fmt.Errorf("requestCrew failed with status %d", resp.StatusCode) 79 + } 80 + 81 + return nil 82 + }
+1 -2
pkg/appview/storage/proxy_blob_store.go
··· 10 10 "sync" 11 11 "time" 12 12 13 - "atcr.io/pkg/appview" 14 13 "atcr.io/pkg/atproto" 15 14 "github.com/distribution/distribution/v3" 16 15 "github.com/distribution/distribution/v3/registry/api/errcode" ··· 40 39 // NewProxyBlobStore creates a new proxy blob store 41 40 func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore { 42 41 // Resolve DID to URL once at construction time 43 - holdURL := appview.ResolveHoldURL(ctx.HoldDID) 42 + holdURL := atproto.ResolveHoldURL(ctx.HoldDID) 44 43 45 44 fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n", 46 45 ctx.HoldDID, holdURL, ctx.DID, ctx.Repository)
+1 -2
pkg/appview/storage/proxy_blob_store_test.go
··· 11 11 "testing" 12 12 "time" 13 13 14 - "atcr.io/pkg/appview" 15 14 "atcr.io/pkg/atproto" 16 15 "atcr.io/pkg/auth/token" 17 16 "github.com/opencontainers/go-digest" ··· 219 218 220 219 for _, tt := range tests { 221 220 t.Run(tt.name, func(t *testing.T) { 222 - result := appview.ResolveHoldURL(tt.holdDID) 221 + result := atproto.ResolveHoldURL(tt.holdDID) 223 222 if result != tt.expected { 224 223 t.Errorf("Expected %s, got %s", tt.expected, result) 225 224 }
+3 -3
pkg/appview/utils.go pkg/atproto/utils.go
··· 1 - package appview 1 + package atproto 2 2 3 3 import "strings" 4 4 ··· 14 14 } 15 15 16 16 // If it's a DID, convert to URL 17 - if strings.HasPrefix(holdIdentifier, "did:web:") { 18 - hostname := strings.TrimPrefix(holdIdentifier, "did:web:") 17 + if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok { 18 + hostname := after 19 19 20 20 // Use HTTP for localhost/IP addresses with ports, HTTPS for domains 21 21 if strings.Contains(hostname, ":") ||
+6 -2
pkg/appview/utils_test.go
··· 1 1 package appview 2 2 3 - import "testing" 3 + import ( 4 + "testing" 5 + 6 + "atcr.io/pkg/atproto" 7 + ) 4 8 5 9 func TestResolveHoldURL(t *testing.T) { 6 10 tests := []struct { ··· 52 56 53 57 for _, tt := range tests { 54 58 t.Run(tt.name, func(t *testing.T) { 55 - result := ResolveHoldURL(tt.input) 59 + result := atproto.ResolveHoldURL(tt.input) 56 60 if result != tt.expected { 57 61 t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.input, result, tt.expected) 58 62 }
+10 -10
pkg/atproto/cbor_gen.go
··· 467 467 return err 468 468 } 469 469 470 - // t.EnableManifestPosts (bool) (bool) 471 - if len("enableManifestPosts") > 8192 { 472 - return xerrors.Errorf("Value in field \"enableManifestPosts\" was too long") 470 + // t.EnableBlueskyPosts (bool) (bool) 471 + if len("enableBlueskyPosts") > 8192 { 472 + return xerrors.Errorf("Value in field \"enableBlueskyPosts\" was too long") 473 473 } 474 474 475 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enableManifestPosts"))); err != nil { 475 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enableBlueskyPosts"))); err != nil { 476 476 return err 477 477 } 478 - if _, err := cw.WriteString(string("enableManifestPosts")); err != nil { 478 + if _, err := cw.WriteString(string("enableBlueskyPosts")); err != nil { 479 479 return err 480 480 } 481 481 482 - if err := cbg.WriteBool(w, t.EnableManifestPosts); err != nil { 482 + if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil { 483 483 return err 484 484 } 485 485 return nil ··· 617 617 default: 618 618 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 619 619 } 620 - // t.EnableManifestPosts (bool) (bool) 621 - case "enableManifestPosts": 620 + // t.EnableBlueskyPosts (bool) (bool) 621 + case "enableBlueskyPosts": 622 622 623 623 maj, extra, err = cr.ReadHeader() 624 624 if err != nil { ··· 629 629 } 630 630 switch extra { 631 631 case 20: 632 - t.EnableManifestPosts = false 632 + t.EnableBlueskyPosts = false 633 633 case 21: 634 - t.EnableManifestPosts = true 634 + t.EnableBlueskyPosts = true 635 635 default: 636 636 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 637 637 }
+4
pkg/atproto/client.go
··· 666 666 func (c *Client) DID() string { 667 667 return c.did 668 668 } 669 + 670 + func (c *Client) PDSEndpoint() string { 671 + return c.pdsEndpoint 672 + }
+9 -9
pkg/atproto/lexicon.go
··· 434 434 } 435 435 436 436 // isDID checks if a string is a DID (starts with "did:") 437 - func isDID(s string) bool { 437 + func IsDID(s string) bool { 438 438 return len(s) > 4 && s[:4] == "did:" 439 439 } 440 440 ··· 536 536 // Stored in the hold's embedded PDS to identify the hold owner and settings 537 537 // Uses CBOR encoding for efficient storage in hold's carstore 538 538 type CaptainRecord struct { 539 - Type string `json:"$type" cborgen:"$type"` 540 - Owner string `json:"owner" cborgen:"owner"` // DID of hold owner 541 - Public bool `json:"public" cborgen:"public"` // Public read access 542 - AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 543 - EnableManifestPosts bool `json:"enableManifestPosts" cborgen:"enableManifestPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 544 - DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 545 - Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) 546 - Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) 539 + Type string `json:"$type" cborgen:"$type"` 540 + Owner string `json:"owner" cborgen:"owner"` // DID of hold owner 541 + Public bool `json:"public" cborgen:"public"` // Public read access 542 + AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 543 + EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 544 + DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 545 + Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) 546 + Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) 547 547 } 548 548 549 549 // CrewRecord represents a crew member in the hold
+2 -2
pkg/atproto/lexicon_test.go
··· 824 824 825 825 for _, tt := range tests { 826 826 t.Run(tt.name, func(t *testing.T) { 827 - got := isDID(tt.s) 827 + got := IsDID(tt.s) 828 828 if got != tt.want { 829 - t.Errorf("isDID() = %v, want %v", got, tt.want) 829 + t.Errorf("IsDID() = %v, want %v", got, tt.want) 830 830 } 831 831 }) 832 832 }
+19 -17
pkg/atproto/profile.go pkg/appview/storage/profile.go
··· 1 - package atproto 1 + package storage 2 2 3 3 import ( 4 4 "context" ··· 7 7 "fmt" 8 8 "sync" 9 9 "time" 10 + 11 + "atcr.io/pkg/atproto" 10 12 ) 11 13 12 14 // ProfileRKey is always "self" per lexicon ··· 21 23 // If defaultHoldDID is provided, creates profile with that default (or empty if not provided) 22 24 // Expected format: "did:web:hold01.atcr.io" 23 25 // Normalizes URLs to DIDs for consistency (for backward compatibility) 24 - func EnsureProfile(ctx context.Context, client *Client, defaultHoldDID string) error { 26 + func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID string) error { 25 27 // Check if profile already exists 26 - profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey) 28 + profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey) 27 29 if err == nil && profile != nil { 28 30 // Profile exists, nothing to do 29 31 return nil ··· 33 35 // This ensures we store DIDs consistently in new profiles 34 36 normalizedDID := "" 35 37 if defaultHoldDID != "" { 36 - normalizedDID = ResolveHoldDIDFromURL(defaultHoldDID) 38 + normalizedDID = atproto.ResolveHoldDIDFromURL(defaultHoldDID) 37 39 } 38 40 39 41 // Profile doesn't exist - create it 40 - newProfile := NewSailorProfileRecord(normalizedDID) 42 + newProfile := atproto.NewSailorProfileRecord(normalizedDID) 41 43 42 - _, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile) 44 + _, err = client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, newProfile) 43 45 if err != nil { 44 46 return fmt.Errorf("failed to create sailor profile: %w", err) 45 47 } ··· 51 53 // GetProfile retrieves the user's profile from their PDS 52 54 // Returns nil if profile doesn't exist 53 55 // Automatically migrates old URL-based defaultHold values to DIDs 54 - func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) { 55 - record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey) 56 + func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfileRecord, error) { 57 + record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey) 56 58 if err != nil { 57 59 // Check if it's a 404 (profile doesn't exist) 58 - if errors.Is(err, ErrRecordNotFound) { 60 + if errors.Is(err, atproto.ErrRecordNotFound) { 59 61 return nil, nil 60 62 } 61 63 return nil, fmt.Errorf("failed to get profile: %w", err) 62 64 } 63 65 64 66 // Parse the profile record 65 - var profile SailorProfileRecord 67 + var profile atproto.SailorProfileRecord 66 68 if err := json.Unmarshal(record.Value, &profile); err != nil { 67 69 return nil, fmt.Errorf("failed to parse profile: %w", err) 68 70 } 69 71 70 72 // Migrate old URL-based defaultHold to DID format 71 73 // This ensures backward compatibility with profiles created before DID migration 72 - if profile.DefaultHold != "" && !isDID(profile.DefaultHold) { 74 + if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { 73 75 // Convert URL to DID transparently 74 - migratedDID := ResolveHoldDIDFromURL(profile.DefaultHold) 76 + migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 75 77 profile.DefaultHold = migratedDID 76 78 77 79 // Persist the migration to PDS in a background goroutine 78 80 // Use a lock to ensure only one goroutine migrates this DID 79 - did := client.did 81 + did := client.DID() 80 82 if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded { 81 83 // We got the lock - launch goroutine to persist the migration 82 84 go func() { ··· 106 108 107 109 // UpdateProfile updates the user's profile 108 110 // Normalizes defaultHold to DID format before saving 109 - func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error { 111 + func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfileRecord) error { 110 112 // Normalize defaultHold to DID if it's a URL 111 113 // This ensures we always store DIDs, even if user provides a URL 112 - if profile.DefaultHold != "" && !isDID(profile.DefaultHold) { 113 - profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold) 114 + if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { 115 + profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 114 116 fmt.Printf("DEBUG [profile]: Normalized defaultHold to DID: %s\n", profile.DefaultHold) 115 117 } 116 118 117 - _, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile) 119 + _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile) 118 120 if err != nil { 119 121 return fmt.Errorf("failed to update profile: %w", err) 120 122 }
+27 -25
pkg/atproto/profile_test.go pkg/appview/storage/profile_test.go
··· 1 - package atproto 1 + package storage 2 2 3 3 import ( 4 4 "context" ··· 9 9 "sync" 10 10 "testing" 11 11 "time" 12 + 13 + "atcr.io/pkg/atproto" 12 14 ) 13 15 14 16 // TestEnsureProfile_Create tests creating a new profile when one doesn't exist ··· 37 39 38 40 for _, tt := range tests { 39 41 t.Run(tt.name, func(t *testing.T) { 40 - var createdProfile *SailorProfileRecord 42 + var createdProfile *atproto.SailorProfileRecord 41 43 42 44 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 45 // First request: GetRecord (should 404) ··· 53 55 54 56 // Verify profile data 55 57 recordData := body["record"].(map[string]any) 56 - if recordData["$type"] != SailorProfileCollection { 57 - t.Errorf("$type = %v, want %v", recordData["$type"], SailorProfileCollection) 58 + if recordData["$type"] != atproto.SailorProfileCollection { 59 + t.Errorf("$type = %v, want %v", recordData["$type"], atproto.SailorProfileCollection) 58 60 } 59 61 60 62 // Check defaultHold normalization ··· 81 83 })) 82 84 defer server.Close() 83 85 84 - client := NewClient(server.URL, "did:plc:test123", "test-token") 86 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 85 87 err := EnsureProfile(context.Background(), client, tt.defaultHoldDID) 86 88 87 89 if err != nil { ··· 93 95 t.Fatal("Profile was not created") 94 96 } 95 97 96 - if createdProfile.Type != SailorProfileCollection { 97 - t.Errorf("Type = %v, want %v", createdProfile.Type, SailorProfileCollection) 98 + if createdProfile.Type != atproto.SailorProfileCollection { 99 + t.Errorf("Type = %v, want %v", createdProfile.Type, atproto.SailorProfileCollection) 98 100 } 99 101 100 102 if createdProfile.DefaultHold != tt.wantNormalized { ··· 134 136 })) 135 137 defer server.Close() 136 138 137 - client := NewClient(server.URL, "did:plc:test123", "test-token") 139 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 138 140 err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io") 139 141 140 142 if err != nil { ··· 152 154 name string 153 155 serverResponse string 154 156 serverStatus int 155 - wantProfile *SailorProfileRecord 157 + wantProfile *atproto.SailorProfileRecord 156 158 wantNil bool 157 159 wantErr bool 158 160 expectMigration bool // Whether URL-to-DID migration should happen ··· 239 241 })) 240 242 defer server.Close() 241 243 242 - client := NewClient(server.URL, "did:plc:test123", "test-token") 244 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 243 245 profile, err := GetProfile(context.Background(), client) 244 246 245 247 if (err != nil) != tt.wantErr { ··· 326 328 })) 327 329 defer server.Close() 328 330 329 - client := NewClient(server.URL, "did:plc:test123", "test-token") 331 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 330 332 331 333 // Make 5 concurrent GetProfile calls 332 334 var wg sync.WaitGroup ··· 360 362 func TestUpdateProfile(t *testing.T) { 361 363 tests := []struct { 362 364 name string 363 - profile *SailorProfileRecord 365 + profile *atproto.SailorProfileRecord 364 366 wantNormalized string // Expected defaultHold after normalization 365 367 wantErr bool 366 368 }{ 367 369 { 368 370 name: "update with DID", 369 - profile: &SailorProfileRecord{ 370 - Type: SailorProfileCollection, 371 + profile: &atproto.SailorProfileRecord{ 372 + Type: atproto.SailorProfileCollection, 371 373 DefaultHold: "did:web:hold02.atcr.io", 372 374 CreatedAt: time.Now(), 373 375 UpdatedAt: time.Now(), ··· 377 379 }, 378 380 { 379 381 name: "update with URL - should normalize", 380 - profile: &SailorProfileRecord{ 381 - Type: SailorProfileCollection, 382 + profile: &atproto.SailorProfileRecord{ 383 + Type: atproto.SailorProfileCollection, 382 384 DefaultHold: "https://hold02.atcr.io", 383 385 CreatedAt: time.Now(), 384 386 UpdatedAt: time.Now(), ··· 388 390 }, 389 391 { 390 392 name: "clear default hold", 391 - profile: &SailorProfileRecord{ 392 - Type: SailorProfileCollection, 393 + profile: &atproto.SailorProfileRecord{ 394 + Type: atproto.SailorProfileCollection, 393 395 DefaultHold: "", 394 396 CreatedAt: time.Now(), 395 397 UpdatedAt: time.Now(), ··· 422 424 })) 423 425 defer server.Close() 424 426 425 - client := NewClient(server.URL, "did:plc:test123", "test-token") 427 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 426 428 err := UpdateProfile(context.Background(), client, tt.profile) 427 429 428 430 if (err != nil) != tt.wantErr { ··· 477 479 })) 478 480 defer server.Close() 479 481 480 - client := NewClient(server.URL, "did:plc:test123", "test-token") 482 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 481 483 err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io") 482 484 483 485 if err == nil { ··· 497 499 })) 498 500 defer server.Close() 499 501 500 - client := NewClient(server.URL, "did:plc:test123", "test-token") 502 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 501 503 _, err := GetProfile(context.Background(), client) 502 504 503 505 if err == nil { ··· 522 524 })) 523 525 defer server.Close() 524 526 525 - client := NewClient(server.URL, "did:plc:test123", "test-token") 527 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 526 528 profile, err := GetProfile(context.Background(), client) 527 529 528 530 if err != nil { ··· 542 544 })) 543 545 defer server.Close() 544 546 545 - client := NewClient(server.URL, "did:plc:test123", "test-token") 546 - profile := &SailorProfileRecord{ 547 - Type: SailorProfileCollection, 547 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 548 + profile := &atproto.SailorProfileRecord{ 549 + Type: atproto.SailorProfileCollection, 548 550 DefaultHold: "did:web:hold01.atcr.io", 549 551 CreatedAt: time.Now(), 550 552 UpdatedAt: time.Now(),
+20 -145
pkg/auth/oauth/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "database/sql" 6 5 "fmt" 7 6 "html/template" 8 7 "net/http" 9 8 "strings" 10 9 "time" 11 - 12 - "atcr.io/pkg/appview/db" 13 - "atcr.io/pkg/atproto" 14 - indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 10 ) 17 11 18 12 // UISessionStore is the interface for UI session management ··· 23 17 UpsertUser(did, handle, pdsEndpoint, avatar string) error 24 18 } 25 19 20 + // PostAuthCallback is called after successful OAuth authentication. 21 + // Parameters: ctx, did, handle, pdsEndpoint, sessionID 22 + // This allows AppView to perform business logic (profile creation, avatar fetch, etc.) 23 + // without coupling the OAuth package to AppView-specific dependencies. 24 + type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error 25 + 26 26 // Server handles OAuth authorization for the AppView 27 27 type Server struct { 28 - app *App 29 - refresher *Refresher 30 - uiSessionStore UISessionStore 31 - db *sql.DB 32 - defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 28 + app *App 29 + refresher *Refresher 30 + uiSessionStore UISessionStore 31 + postAuthCallback PostAuthCallback 33 32 } 34 33 35 34 // NewServer creates a new OAuth server ··· 39 38 } 40 39 } 41 40 42 - // SetDefaultHoldDID sets the default hold DID for profile creation 43 - // Expected format: "did:web:hold01.atcr.io" 44 - // To find a hold's DID, visit: https://hold-url/.well-known/did.json 45 - func (s *Server) SetDefaultHoldDID(did string) { 46 - s.defaultHoldDID = did 47 - } 48 - 49 41 // SetRefresher sets the refresher for invalidating session cache 50 42 func (s *Server) SetRefresher(refresher *Refresher) { 51 43 s.refresher = refresher ··· 56 48 s.uiSessionStore = store 57 49 } 58 50 59 - // SetDatabase sets the database for user management 60 - func (s *Server) SetDatabase(db *sql.DB) { 61 - s.db = db 51 + // SetPostAuthCallback sets the callback to be invoked after successful OAuth authentication 52 + // This allows AppView to inject business logic without coupling the OAuth package 53 + func (s *Server) SetPostAuthCallback(callback PostAuthCallback) { 54 + s.postAuthCallback = callback 62 55 } 63 56 64 57 // ServeAuthorize handles GET /auth/oauth/authorize ··· 140 133 handle = did // Fallback to DID if resolution fails 141 134 } 142 135 143 - // Fetch user's Bluesky profile (including avatar) and store in database 144 - if s.db != nil { 145 - s.fetchAndStoreAvatar(r.Context(), did, sessionID, handle, sessionData.HostURL) 136 + // Call post-auth callback for AppView business logic (profile, avatar, etc.) 137 + if s.postAuthCallback != nil { 138 + if err := s.postAuthCallback(r.Context(), did, handle, sessionData.HostURL, sessionID); err != nil { 139 + // Log error but don't fail OAuth flow - business logic is non-critical 140 + fmt.Printf("WARNING [oauth/server]: Post-auth callback failed for DID=%s: %v\n", did, err) 141 + } 146 142 } 147 143 148 144 // Check if this is a UI login (has oauth_return_to cookie) ··· 239 235 if err := tmpl.Execute(w, data); err != nil { 240 236 http.Error(w, "failed to render template", http.StatusInternalServerError) 241 237 } 242 - } 243 - 244 - // fetchAndStoreAvatar fetches the user's Bluesky profile and stores avatar in database 245 - func (s *Server) fetchAndStoreAvatar(ctx context.Context, did, sessionID, handle, pdsEndpoint string) { 246 - fmt.Printf("DEBUG [oauth/server]: Fetching avatar for DID=%s from PDS=%s\n", did, pdsEndpoint) 247 - 248 - // Parse DID for session resume 249 - didParsed, err := syntax.ParseDID(did) 250 - if err != nil { 251 - fmt.Printf("WARNING [oauth/server]: Failed to parse DID %s: %v\n", did, err) 252 - return 253 - } 254 - 255 - // Resume OAuth session to get authenticated client 256 - session, err := s.app.ResumeSession(ctx, didParsed, sessionID) 257 - if err != nil { 258 - fmt.Printf("WARNING [oauth/server]: Failed to resume session for DID=%s: %v\n", did, err) 259 - // Fallback: update user without avatar 260 - _ = db.UpsertUser(s.db, &db.User{ 261 - DID: did, 262 - Handle: handle, 263 - PDSEndpoint: pdsEndpoint, 264 - Avatar: "", 265 - LastSeen: time.Now(), 266 - }) 267 - return 268 - } 269 - 270 - // Create authenticated atproto client using the indigo session's API client 271 - client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient()) 272 - 273 - // Ensure sailor profile exists (creates with default hold if configured, or empty profile if not) 274 - fmt.Printf("DEBUG [oauth/server]: Ensuring profile exists for %s (defaultHold=%s)\n", did, s.defaultHoldDID) 275 - if err := atproto.EnsureProfile(ctx, client, s.defaultHoldDID); err != nil { 276 - fmt.Printf("WARNING [oauth/server]: Failed to ensure profile for %s: %v\n", did, err) 277 - // Continue anyway - profile creation is not critical for avatar fetch 278 - } else { 279 - fmt.Printf("DEBUG [oauth/server]: Profile ensured for %s\n", did) 280 - } 281 - 282 - // Fetch user's profile record from PDS (contains blob references) 283 - profileRecord, err := client.GetProfileRecord(ctx, did) 284 - if err != nil { 285 - fmt.Printf("WARNING [oauth/server]: Failed to fetch profile record for DID=%s: %v\n", did, err) 286 - // Still update user without avatar 287 - _ = db.UpsertUser(s.db, &db.User{ 288 - DID: did, 289 - Handle: handle, 290 - PDSEndpoint: pdsEndpoint, 291 - Avatar: "", 292 - LastSeen: time.Now(), 293 - }) 294 - return 295 - } 296 - 297 - // Construct avatar URL from blob CID using imgs.blue CDN 298 - var avatarURL string 299 - if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 300 - avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link) 301 - fmt.Printf("DEBUG [oauth/server]: Constructed avatar URL: %s\n", avatarURL) 302 - } 303 - 304 - // Store user with avatar in database 305 - err = db.UpsertUser(s.db, &db.User{ 306 - DID: did, 307 - Handle: handle, 308 - PDSEndpoint: pdsEndpoint, 309 - Avatar: avatarURL, 310 - LastSeen: time.Now(), 311 - }) 312 - if err != nil { 313 - fmt.Printf("WARNING [oauth/server]: Failed to store user in database: %v\n", err) 314 - return 315 - } 316 - 317 - fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did) 318 - 319 - // Handle profile migration and crew registration 320 - s.migrateProfileAndRegisterCrew(ctx, client, did, session) 321 - } 322 - 323 - // migrateProfileAndRegisterCrew handles URL→DID migration and crew registration 324 - func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atproto.Client, did string, session *indigooauth.ClientSession) { 325 - // Get user's sailor profile 326 - profile, err := atproto.GetProfile(ctx, client) 327 - if err != nil { 328 - fmt.Printf("WARNING [oauth/server]: Failed to get profile for %s: %v\n", did, err) 329 - return 330 - } 331 - 332 - if profile == nil || profile.DefaultHold == "" { 333 - // No profile or no default hold configured 334 - return 335 - } 336 - 337 - // Check if defaultHold is a URL (needs migration) 338 - var holdDID string 339 - if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { 340 - fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold) 341 - 342 - // Resolve URL to DID 343 - holdDID = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 344 - 345 - // Update profile with DID 346 - profile.DefaultHold = holdDID 347 - if err := atproto.UpdateProfile(ctx, client, profile); err != nil { 348 - fmt.Printf("WARNING [oauth/server]: Failed to update profile with hold DID for %s: %v\n", did, err) 349 - // Continue anyway - crew registration might still work 350 - } else { 351 - fmt.Printf("DEBUG [oauth/server]: Updated profile with hold DID: %s\n", holdDID) 352 - } 353 - } else { 354 - // Already a DID 355 - holdDID = profile.DefaultHold 356 - } 357 - 358 - // TODO: Request crew membership at the hold 359 - // This requires understanding how to make authenticated HTTP requests with indigo's ClientSession 360 - // For now, crew registration will happen on first push when appview validates access 361 - fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID) 362 - _ = session // TODO: use session for crew registration 363 238 } 364 239 365 240 // HTML templates
+38 -31
pkg/auth/token/handler.go
··· 1 1 package token 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "net/http" ··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 12 13 13 14 "atcr.io/pkg/appview/db" 14 - mainAtproto "atcr.io/pkg/atproto" 15 15 "atcr.io/pkg/auth" 16 16 ) 17 17 18 + // PostAuthCallback is called after successful Basic Auth authentication. 19 + // Parameters: ctx, did, handle, pdsEndpoint, accessToken 20 + // This allows AppView to perform business logic (profile creation, etc.) 21 + // without coupling the token package to AppView-specific dependencies. 22 + type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error 23 + 18 24 // Handler handles /auth/token requests 19 25 type Handler struct { 20 - issuer *Issuer 21 - validator *auth.SessionValidator 22 - deviceStore *db.DeviceStore // For validating device secrets 23 - defaultHoldDID string 26 + issuer *Issuer 27 + validator *auth.SessionValidator 28 + deviceStore *db.DeviceStore // For validating device secrets 29 + postAuthCallback PostAuthCallback 24 30 } 25 31 26 32 // NewHandler creates a new token handler 27 - // defaultHoldDID should be in format "did:web:hold01.atcr.io" 28 - // To find a hold's DID, visit: https://hold-url/.well-known/did.json 29 - func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldDID string) *Handler { 33 + func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler { 30 34 return &Handler{ 31 - issuer: issuer, 32 - validator: auth.NewSessionValidator(), 33 - deviceStore: deviceStore, 34 - defaultHoldDID: defaultHoldDID, 35 + issuer: issuer, 36 + validator: auth.NewSessionValidator(), 37 + deviceStore: deviceStore, 35 38 } 39 + } 40 + 41 + // SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication 42 + // This allows AppView to inject business logic without coupling the token package 43 + func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) { 44 + h.postAuthCallback = callback 36 45 } 37 46 38 47 // TokenResponse represents the response from /auth/token ··· 142 151 auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 143 152 fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did) 144 153 145 - // Ensure user profile exists (creates with default hold if needed) 146 - // Resolve PDS endpoint for profile management 147 - directory := identity.DefaultDirectory() 148 - atID, err := syntax.ParseAtIdentifier(username) 149 - if err == nil { 150 - ident, err := directory.Lookup(r.Context(), *atID) 151 - if err != nil { 152 - // Log error but don't fail auth - profile management is not critical 153 - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 154 - } else { 155 - pdsEndpoint := ident.PDSEndpoint() 156 - if pdsEndpoint != "" { 157 - // Create ATProto client with validated token 158 - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) 159 - 160 - // Ensure profile exists (will create with default hold if not exists and default is configured) 161 - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldDID); err != nil { 162 - // Log error but don't fail auth - profile management is not critical 163 - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 154 + // Call post-auth callback for AppView business logic (profile management, etc.) 155 + if h.postAuthCallback != nil { 156 + // Resolve PDS endpoint for callback 157 + directory := identity.DefaultDirectory() 158 + atID, err := syntax.ParseAtIdentifier(username) 159 + if err == nil { 160 + ident, err := directory.Lookup(r.Context(), *atID) 161 + if err != nil { 162 + // Log error but don't fail auth - profile management is not critical 163 + fmt.Printf("WARNING: failed to resolve PDS for callback: %v\n", err) 164 + } else { 165 + pdsEndpoint := ident.PDSEndpoint() 166 + if pdsEndpoint != "" { 167 + if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil { 168 + // Log error but don't fail auth - business logic is non-critical 169 + fmt.Printf("WARNING: post-auth callback failed for DID=%s: %v\n", did, err) 170 + } 164 171 } 165 172 } 166 173 }
+111
pkg/auth/token/servicetoken.go
··· 1 + package token 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "time" 11 + 12 + "atcr.io/pkg/atproto" 13 + "atcr.io/pkg/auth/oauth" 14 + ) 15 + 16 + // GetOrFetchServiceToken gets a service token for hold authentication. 17 + // Checks cache first, then fetches from PDS with OAuth/DPoP if needed. 18 + // This is the canonical implementation used by both middleware and crew registration. 19 + func GetOrFetchServiceToken( 20 + ctx context.Context, 21 + refresher *oauth.Refresher, 22 + did, holdDID, pdsEndpoint string, 23 + ) (string, error) { 24 + if refresher == nil { 25 + return "", fmt.Errorf("refresher is nil (OAuth session required for service tokens)") 26 + } 27 + 28 + // Check cache first to avoid unnecessary PDS calls on every request 29 + cachedToken, expiresAt := GetServiceToken(did, holdDID) 30 + 31 + // Use cached token if it exists and has > 10s remaining 32 + if cachedToken != "" && time.Until(expiresAt) > 10*time.Second { 33 + fmt.Printf("DEBUG [atproto/servicetoken]: Using cached service token for DID=%s (expires in %v)\n", 34 + did, time.Until(expiresAt).Round(time.Second)) 35 + return cachedToken, nil 36 + } 37 + 38 + // Cache miss or expiring soon - validate OAuth and get new service token 39 + if cachedToken == "" { 40 + fmt.Printf("DEBUG [atproto/servicetoken]: Cache miss, fetching service token for DID=%s\n", did) 41 + } else { 42 + fmt.Printf("DEBUG [atproto/servicetoken]: Token expiring soon, proactively renewing for DID=%s\n", did) 43 + } 44 + 45 + session, err := refresher.GetSession(ctx, did) 46 + if err != nil { 47 + // OAuth session unavailable - invalidate and fail 48 + refresher.InvalidateSession(did) 49 + InvalidateServiceToken(did, holdDID) 50 + return "", fmt.Errorf("failed to get OAuth session: %w", err) 51 + } 52 + 53 + // Call com.atproto.server.getServiceAuth on the user's PDS 54 + // Request 5-minute expiry (PDS may grant less) 55 + // exp must be absolute Unix timestamp, not relative duration 56 + // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID 57 + expiryTime := time.Now().Unix() + 300 // 5 minutes from now 58 + serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d", 59 + pdsEndpoint, 60 + atproto.ServerGetServiceAuth, 61 + url.QueryEscape(holdDID), 62 + url.QueryEscape("com.atproto.repo.getRecord"), 63 + expiryTime, 64 + ) 65 + 66 + req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil) 67 + if err != nil { 68 + return "", fmt.Errorf("failed to create service auth request: %w", err) 69 + } 70 + 71 + // Use OAuth session to authenticate to PDS (with DPoP) 72 + resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth") 73 + if err != nil { 74 + // Invalidate session on auth errors (may indicate corrupted session or expired tokens) 75 + refresher.InvalidateSession(did) 76 + InvalidateServiceToken(did, holdDID) 77 + return "", fmt.Errorf("OAuth validation failed: %w", err) 78 + } 79 + defer resp.Body.Close() 80 + 81 + if resp.StatusCode != http.StatusOK { 82 + // Invalidate session on auth failures 83 + bodyBytes, _ := io.ReadAll(resp.Body) 84 + refresher.InvalidateSession(did) 85 + InvalidateServiceToken(did, holdDID) 86 + return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 87 + } 88 + 89 + // Parse response to get service token 90 + var result struct { 91 + Token string `json:"token"` 92 + } 93 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 94 + return "", fmt.Errorf("failed to decode service auth response: %w", err) 95 + } 96 + 97 + if result.Token == "" { 98 + return "", fmt.Errorf("empty token in service auth response") 99 + } 100 + 101 + serviceToken := result.Token 102 + 103 + // Cache the token (parses JWT to extract actual expiry) 104 + if err := SetServiceToken(did, holdDID, serviceToken); err != nil { 105 + fmt.Printf("WARN [atproto/servicetoken]: Failed to cache service token: %v\n", err) 106 + // Non-fatal - we have the token, just won't be cached 107 + } 108 + 109 + fmt.Printf("DEBUG [atproto/servicetoken]: OAuth validation succeeded for DID=%s\n", did) 110 + return serviceToken, nil 111 + }
+1 -1
pkg/hold/config.go
··· 40 40 41 41 // EnableBlueskyPosts controls whether to create Bluesky posts for manifest uploads (from env: HOLD_BLUESKY_POSTS_ENABLED) 42 42 // If true, creates posts when users push images 43 - // Can be overridden per-hold via captain record's enableManifestPosts field 43 + // Synced to captain record's enableBlueskyPosts field on startup 44 44 EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"` 45 45 } 46 46
+9 -3
pkg/hold/oci/xrpc.go
··· 244 244 } 245 245 246 246 // Check if manifest posts are enabled 247 - // Controlled by HOLD_BLUESKY_POSTS_ENABLED environment variable 248 - // TODO: Override with captain record enableManifestPosts field if set 249 - postsEnabled := h.enableBlueskyPosts 247 + // Read from captain record (which is synced with HOLD_BLUESKY_POSTS_ENABLED env var) 248 + postsEnabled := false 249 + _, captain, err := h.pds.GetCaptainRecord(ctx) 250 + if err == nil { 251 + postsEnabled = captain.EnableBlueskyPosts 252 + } else { 253 + // Fallback to env var if captain record doesn't exist (shouldn't happen in normal operation) 254 + postsEnabled = h.enableBlueskyPosts 255 + } 250 256 251 257 // Create layer records for each blob 252 258 layersCreated := 0
+1 -1
pkg/hold/pds/auth_test.go
··· 742 742 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, false, false) 743 743 744 744 // Update captain to be private 745 - _, err := pds.UpdateCaptainRecord(ctx, false, false) 745 + _, err := pds.UpdateCaptainRecord(ctx, false, false, false) 746 746 if err != nil { 747 747 t.Fatalf("Failed to update captain record: %v", err) 748 748 }
+10 -8
pkg/hold/pds/captain.go
··· 16 16 17 17 // CreateCaptainRecord creates the captain record for the hold (first-time only). 18 18 // This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify. 19 - func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) (cid.Cid, error) { 19 + func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) { 20 20 captainRecord := &atproto.CaptainRecord{ 21 - Type: atproto.CaptainCollection, 22 - Owner: ownerDID, 23 - Public: public, 24 - AllowAllCrew: allowAllCrew, 25 - DeployedAt: time.Now().Format(time.RFC3339), 21 + Type: atproto.CaptainCollection, 22 + Owner: ownerDID, 23 + Public: public, 24 + AllowAllCrew: allowAllCrew, 25 + EnableBlueskyPosts: enableBlueskyPosts, 26 + DeployedAt: time.Now().Format(time.RFC3339), 26 27 } 27 28 28 29 // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists ··· 53 54 return recordCID, captainRecord, nil 54 55 } 55 56 56 - // UpdateCaptainRecord updates the captain record (e.g., to change public/allowAllCrew settings) 57 - func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAllCrew bool) (cid.Cid, error) { 57 + // UpdateCaptainRecord updates the captain record (e.g., to change public/allowAllCrew/enableBlueskyPosts settings) 58 + func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) { 58 59 // Get existing record to preserve other fields 59 60 _, existing, err := p.GetCaptainRecord(ctx) 60 61 if err != nil { ··· 64 65 // Update the fields 65 66 existing.Public = public 66 67 existing.AllowAllCrew = allowAllCrew 68 + existing.EnableBlueskyPosts = enableBlueskyPosts 67 69 68 70 recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, existing) 69 71 if err != nil {
+43 -29
pkg/hold/pds/captain_test.go
··· 71 71 // TestCreateCaptainRecord tests creating a captain record with various settings 72 72 func TestCreateCaptainRecord(t *testing.T) { 73 73 tests := []struct { 74 - name string 75 - ownerDID string 76 - public bool 77 - allowAllCrew bool 74 + name string 75 + ownerDID string 76 + public bool 77 + allowAllCrew bool 78 + enableBlueskyPosts bool 78 79 }{ 79 80 { 80 - name: "Private hold, no all-crew", 81 - ownerDID: "did:plc:alice123", 82 - public: false, 83 - allowAllCrew: false, 81 + name: "Private hold, no all-crew", 82 + ownerDID: "did:plc:alice123", 83 + public: false, 84 + allowAllCrew: false, 85 + enableBlueskyPosts: false, 84 86 }, 85 87 { 86 - name: "Public hold, no all-crew", 87 - ownerDID: "did:plc:bob456", 88 - public: true, 89 - allowAllCrew: false, 88 + name: "Public hold, no all-crew", 89 + ownerDID: "did:plc:bob456", 90 + public: true, 91 + allowAllCrew: false, 92 + enableBlueskyPosts: true, 90 93 }, 91 94 { 92 - name: "Public hold, allow all crew", 93 - ownerDID: "did:plc:charlie789", 94 - public: true, 95 - allowAllCrew: true, 95 + name: "Public hold, allow all crew", 96 + ownerDID: "did:plc:charlie789", 97 + public: true, 98 + allowAllCrew: true, 99 + enableBlueskyPosts: false, 96 100 }, 97 101 { 98 - name: "Private hold, allow all crew", 99 - ownerDID: "did:plc:dave012", 100 - public: false, 101 - allowAllCrew: true, 102 + name: "Private hold, allow all crew", 103 + ownerDID: "did:plc:dave012", 104 + public: false, 105 + allowAllCrew: true, 106 + enableBlueskyPosts: true, 102 107 }, 103 108 } 104 109 ··· 109 114 defer pds.Close() 110 115 111 116 // Create captain record 112 - recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew) 117 + recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts) 113 118 if err != nil { 114 119 t.Fatalf("CreateCaptainRecord failed: %v", err) 115 120 } ··· 138 143 if captain.AllowAllCrew != tt.allowAllCrew { 139 144 t.Errorf("Expected allowAllCrew=%v, got %v", tt.allowAllCrew, captain.AllowAllCrew) 140 145 } 146 + if captain.EnableBlueskyPosts != tt.enableBlueskyPosts { 147 + t.Errorf("Expected enableBlueskyPosts=%v, got %v", tt.enableBlueskyPosts, captain.EnableBlueskyPosts) 148 + } 141 149 if captain.Type != atproto.CaptainCollection { 142 150 t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type) 143 151 } ··· 156 164 ownerDID := "did:plc:alice123" 157 165 158 166 // Create captain record 159 - createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false) 167 + createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false) 160 168 if err != nil { 161 169 t.Fatalf("CreateCaptainRecord failed: %v", err) 162 170 } ··· 212 220 213 221 ownerDID := "did:plc:alice123" 214 222 215 - // Create initial captain record (public=false, allowAllCrew=false) 216 - _, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false) 223 + // Create initial captain record (public=false, allowAllCrew=false, enableBlueskyPosts=false) 224 + _, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false) 217 225 if err != nil { 218 226 t.Fatalf("CreateCaptainRecord failed: %v", err) 219 227 } ··· 231 239 if captain1.AllowAllCrew { 232 240 t.Error("Expected initial allowAllCrew=false") 233 241 } 242 + if captain1.EnableBlueskyPosts { 243 + t.Error("Expected initial enableBlueskyPosts=false") 244 + } 234 245 235 - // Update to public=true, allowAllCrew=true 236 - updatedCID, err := pds.UpdateCaptainRecord(ctx, true, true) 246 + // Update to public=true, allowAllCrew=true, enableBlueskyPosts=true 247 + updatedCID, err := pds.UpdateCaptainRecord(ctx, true, true, true) 237 248 if err != nil { 238 249 t.Fatalf("UpdateCaptainRecord failed: %v", err) 239 250 } ··· 260 271 if !captain2.AllowAllCrew { 261 272 t.Error("Expected allowAllCrew=true after update") 262 273 } 274 + if !captain2.EnableBlueskyPosts { 275 + t.Error("Expected enableBlueskyPosts=true after update") 276 + } 263 277 264 278 // Verify owner didn't change 265 279 if captain2.Owner != ownerDID { 266 280 t.Errorf("Expected owner to remain %s, got %s", ownerDID, captain2.Owner) 267 281 } 268 282 269 - // Update again to different values (public=true, allowAllCrew=false) 270 - _, err = pds.UpdateCaptainRecord(ctx, true, false) 283 + // Update again to different values (public=true, allowAllCrew=false, enableBlueskyPosts=false) 284 + _, err = pds.UpdateCaptainRecord(ctx, true, false, false) 271 285 if err != nil { 272 286 t.Fatalf("Second UpdateCaptainRecord failed: %v", err) 273 287 } ··· 292 306 defer pds.Close() 293 307 294 308 // Try to update captain record before creating one 295 - _, err := pds.UpdateCaptainRecord(ctx, true, true) 309 + _, err := pds.UpdateCaptainRecord(ctx, true, true, true) 296 310 if err == nil { 297 311 t.Fatal("Expected error when updating non-existent captain record") 298 312 }
+20 -2
pkg/hold/pds/server.go
··· 155 155 } 156 156 157 157 // Create captain record (hold ownership and settings) 158 - _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew) 158 + _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts) 159 159 if err != nil { 160 160 return fmt.Errorf("failed to create captain record: %w", err) 161 161 } 162 162 163 - fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew) 163 + fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v, enableBlueskyPosts=%v)\n", public, allowAllCrew, p.enableBlueskyPosts) 164 164 165 165 // Add hold owner as first crew member with admin role 166 166 _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) ··· 169 169 } 170 170 171 171 fmt.Printf("✅ Added %s as hold admin\n", ownerDID) 172 + } else { 173 + // Captain record exists, check if we need to sync settings from env vars 174 + _, existingCaptain, err := p.GetCaptainRecord(ctx) 175 + if err == nil { 176 + // Check if any settings need updating 177 + needsUpdate := existingCaptain.Public != public || 178 + existingCaptain.AllowAllCrew != allowAllCrew || 179 + existingCaptain.EnableBlueskyPosts != p.enableBlueskyPosts 180 + 181 + if needsUpdate { 182 + // Update captain record to match env vars 183 + _, err = p.UpdateCaptainRecord(ctx, public, allowAllCrew, p.enableBlueskyPosts) 184 + if err != nil { 185 + return fmt.Errorf("failed to update captain record: %w", err) 186 + } 187 + fmt.Printf("✅ Synced captain record with env vars (public=%v, allowAllCrew=%v, enableBlueskyPosts=%v)\n", public, allowAllCrew, p.enableBlueskyPosts) 188 + } 189 + } 172 190 } 173 191 174 192 // Create Bluesky profile record (idempotent - check if exists first)
+1 -1
pkg/hold/pds/server_test.go
··· 560 560 561 561 // Create captain record WITHOUT crew (unusual state) 562 562 ownerDID := "did:plc:alice123" 563 - _, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false) 563 + _, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false) 564 564 if err != nil { 565 565 t.Fatalf("CreateCaptainRecord failed: %v", err) 566 566 }
+3 -3
pkg/hold/pds/xrpc_test.go
··· 1199 1199 handler, ctx := setupTestXRPCHandler(t) 1200 1200 1201 1201 // Update captain record to allow all crew 1202 - _, err := handler.pds.UpdateCaptainRecord(ctx, true, true) // public=true, allowAllCrew=true 1202 + _, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false) // public=true, allowAllCrew=true, enableBlueskyPosts=false 1203 1203 if err != nil { 1204 1204 t.Fatalf("Failed to update captain record: %v", err) 1205 1205 } ··· 1243 1243 1244 1244 // Captain record was created with allowAllCrew=false in setupTestXRPCHandler 1245 1245 // Update to make sure it's false 1246 - _, err := handler.pds.UpdateCaptainRecord(ctx, true, false) // public=true, allowAllCrew=false 1246 + _, err := handler.pds.UpdateCaptainRecord(ctx, true, false, false) // public=true, allowAllCrew=false, enableBlueskyPosts=false 1247 1247 if err != nil { 1248 1248 t.Fatalf("Failed to update captain record: %v", err) 1249 1249 } ··· 1715 1715 handler, _, ctx := setupTestXRPCHandlerWithBlobs(t) 1716 1716 1717 1717 // Make hold public 1718 - _, err := handler.pds.UpdateCaptainRecord(ctx, true, false) 1718 + _, err := handler.pds.UpdateCaptainRecord(ctx, true, false, false) 1719 1719 if err != nil { 1720 1720 t.Fatalf("Failed to update captain: %v", err) 1721 1721 }
+165 -32
scripts/migrate-image.sh
··· 1 1 #!/bin/bash 2 2 set -e 3 3 4 - # Configuration 5 - SOURCE_REGISTRY="ghcr.io/evanjarrett/hsm-secrets-operator" 6 - TARGET_REGISTRY="atcr.io/evan.jarrett.net/hsm-secrets-operator" 7 - TAG="latest" 4 + # Usage function 5 + usage() { 6 + echo "Usage: $0 <source-image> [target-image]" 7 + echo "" 8 + echo "Examples:" 9 + echo " $0 ghcr.io/evanjarrett/myapp:latest" 10 + echo " $0 ghcr.io/evanjarrett/myapp:latest atcr.io/evan.jarrett.net/myapp:latest" 11 + echo "" 12 + echo "If target-image is not specified, it will use atcr.io/<username>/<repo>:<tag>" 13 + exit 1 14 + } 15 + 16 + # Check arguments 17 + if [ $# -lt 1 ]; then 18 + usage 19 + fi 20 + 21 + SOURCE_IMAGE="$1" 22 + TARGET_IMAGE="${2:-}" 23 + 24 + # Parse source image to extract components 25 + # Format: [registry/]repository[:tag|@digest] 26 + parse_image_ref() { 27 + local ref="$1" 28 + local registry="" 29 + local repository="" 30 + local tag="latest" 31 + 32 + # Remove digest if present (we'll fetch the manifest-list) 33 + ref="${ref%@*}" 34 + 35 + # Extract tag 36 + if [[ "$ref" == *:* ]]; then 37 + tag="${ref##*:}" 38 + ref="${ref%:*}" 39 + fi 40 + 41 + # Extract registry and repository 42 + if [[ "$ref" == */*/* ]]; then 43 + # Has registry 44 + registry="${ref%%/*}" 45 + repository="${ref#*/}" 46 + else 47 + # No registry, assume Docker Hub 48 + registry="docker.io" 49 + repository="$ref" 50 + fi 51 + 52 + echo "$registry" "$repository" "$tag" 53 + } 54 + 55 + # Parse source image 56 + read -r SOURCE_REGISTRY SOURCE_REPO SOURCE_TAG <<< "$(parse_image_ref "$SOURCE_IMAGE")" 57 + 58 + # If no target specified, auto-generate it 59 + if [ -z "$TARGET_IMAGE" ]; then 60 + # Extract just the repo name (last component) 61 + REPO_NAME="${SOURCE_REPO##*/}" 62 + # Try to extract username from source 63 + if [[ "$SOURCE_REPO" == */* ]]; then 64 + USERNAME="${SOURCE_REPO%/*}" 65 + USERNAME="${USERNAME##*/}" 66 + else 67 + USERNAME="default" 68 + fi 69 + TARGET_IMAGE="atcr.io/${USERNAME}/${REPO_NAME}:${SOURCE_TAG}" 70 + fi 8 71 9 - # Image digests 10 - AMD64_DIGEST="sha256:274284a623810cf07c5b4735628832751926b7d192863681d5af1b4137f44254" 11 - ARM64_DIGEST="sha256:b57929fd100033092766aad1c7e747deef9b1e3206756c11d0d7a7af74daedff" 72 + # Parse target image 73 + read -r TARGET_REGISTRY TARGET_REPO TARGET_TAG <<< "$(parse_image_ref "$TARGET_IMAGE")" 12 74 13 - echo "=== Migrating multi-arch image from GHCR to ATCR ===" 14 - echo "Source: ${SOURCE_REGISTRY}" 15 - echo "Target: ${TARGET_REGISTRY}:${TAG}" 75 + echo "=== Migrating multi-arch image ===" 76 + echo "Source: ${SOURCE_REGISTRY}/${SOURCE_REPO}:${SOURCE_TAG}" 77 + echo "Target: ${TARGET_REGISTRY}/${TARGET_REPO}:${TARGET_TAG}" 16 78 echo "" 17 79 18 - # Tag and push amd64 image 19 - echo ">>> Tagging and pushing amd64 image..." 20 - docker tag "${SOURCE_REGISTRY}@${AMD64_DIGEST}" "${TARGET_REGISTRY}:${TAG}-amd64" 21 - docker push "${TARGET_REGISTRY}:${TAG}-amd64" 80 + # Full source reference 81 + SOURCE_REF="${SOURCE_REGISTRY}/${SOURCE_REPO}:${SOURCE_TAG}" 82 + TARGET_REF="${TARGET_REGISTRY}/${TARGET_REPO}:${TARGET_TAG}" 83 + 84 + # Fetch the manifest list 85 + echo ">>> Fetching manifest list from source..." 86 + MANIFEST_JSON=$(docker manifest inspect "$SOURCE_REF" 2>/dev/null || { 87 + echo "Error: Failed to fetch manifest list. This may not be a multi-arch image." 88 + echo "Trying as single-arch image..." 89 + 90 + # Try pulling as single image 91 + docker pull "$SOURCE_REF" 92 + docker tag "$SOURCE_REF" "$TARGET_REF" 93 + docker push "$TARGET_REF" 94 + echo "=== Migration complete (single-arch) ===" 95 + exit 0 96 + }) 97 + 98 + # Check if this is a manifest list 99 + MEDIA_TYPE=$(echo "$MANIFEST_JSON" | jq -r '.mediaType // .schemaVersion') 100 + if [[ ! "$MEDIA_TYPE" =~ "manifest.list" ]] && [[ ! "$MEDIA_TYPE" =~ "index" ]]; then 101 + echo "Warning: Source appears to be a single-arch image, not a manifest list." 102 + docker pull "$SOURCE_REF" 103 + docker tag "$SOURCE_REF" "$TARGET_REF" 104 + docker push "$TARGET_REF" 105 + echo "=== Migration complete (single-arch) ===" 106 + exit 0 107 + fi 108 + 109 + echo "Found multi-arch manifest list" 22 110 echo "" 23 111 24 - # Tag and push arm64 image 25 - echo ">>> Tagging and pushing arm64 image..." 26 - docker tag "${SOURCE_REGISTRY}@${ARM64_DIGEST}" "${TARGET_REGISTRY}:${TAG}-arm64" 27 - docker push "${TARGET_REGISTRY}:${TAG}-arm64" 28 - echo "" 112 + # Extract platform information and digests 113 + PLATFORMS=$(echo "$MANIFEST_JSON" | jq -r '.manifests[] | "\(.platform.os)|\(.platform.architecture)|\(.platform.variant // "")|\(.digest)"') 114 + 115 + # Arrays to store pushed images for manifest creation 116 + declare -a PUSHED_IMAGES 117 + declare -a PLATFORM_INFO 118 + 119 + # Process each platform 120 + while IFS='|' read -r os arch variant digest; do 121 + # Create platform tag (e.g., "linux-amd64" or "linux-arm-v7") 122 + PLATFORM_TAG="${os}-${arch}" 123 + if [ -n "$variant" ]; then 124 + PLATFORM_TAG="${PLATFORM_TAG}-${variant}" 125 + fi 29 126 30 - # Create multi-arch manifest using the pushed tags 127 + echo ">>> Processing ${os}/${arch}${variant:+/$variant}..." 128 + echo " Digest: $digest" 129 + 130 + # Pull by digest 131 + echo " Pulling image..." 132 + docker pull "${SOURCE_REGISTRY}/${SOURCE_REPO}@${digest}" 133 + 134 + # Tag for target 135 + TARGET_PLATFORM_REF="${TARGET_REGISTRY}/${TARGET_REPO}:${TARGET_TAG}-${PLATFORM_TAG}" 136 + echo " Tagging as: ${TARGET_PLATFORM_REF}" 137 + docker tag "${SOURCE_REGISTRY}/${SOURCE_REPO}@${digest}" "${TARGET_PLATFORM_REF}" 138 + 139 + # Push platform-specific image 140 + echo " Pushing..." 141 + docker push "${TARGET_PLATFORM_REF}" 142 + 143 + # Store for manifest creation 144 + PUSHED_IMAGES+=("${TARGET_PLATFORM_REF}") 145 + PLATFORM_INFO+=("${os}|${arch}|${variant}") 146 + 147 + echo "" 148 + done <<< "$PLATFORMS" 149 + 150 + # Create multi-arch manifest 31 151 echo ">>> Creating multi-arch manifest..." 32 - docker manifest create "${TARGET_REGISTRY}:${TAG}" \ 33 - --amend "${TARGET_REGISTRY}:${TAG}-amd64" \ 34 - --amend "${TARGET_REGISTRY}:${TAG}-arm64" 152 + MANIFEST_CREATE_CMD="docker manifest create ${TARGET_REF}" 153 + for img in "${PUSHED_IMAGES[@]}"; do 154 + MANIFEST_CREATE_CMD="${MANIFEST_CREATE_CMD} --amend ${img}" 155 + done 156 + 157 + eval "$MANIFEST_CREATE_CMD" 35 158 echo "" 36 159 37 - # Annotate the manifest with platform information 160 + # Annotate each platform 38 161 echo ">>> Annotating manifest with platform information..." 39 - docker manifest annotate "${TARGET_REGISTRY}:${TAG}" \ 40 - "${TARGET_REGISTRY}:${TAG}-amd64" \ 41 - --os linux --arch amd64 162 + for i in "${!PUSHED_IMAGES[@]}"; do 163 + IFS='|' read -r os arch variant <<< "${PLATFORM_INFO[$i]}" 42 164 43 - docker manifest annotate "${TARGET_REGISTRY}:${TAG}" \ 44 - "${TARGET_REGISTRY}:${TAG}-arm64" \ 45 - --os linux --arch arm64 165 + ANNOTATE_CMD="docker manifest annotate ${TARGET_REF} ${PUSHED_IMAGES[$i]} --os ${os} --arch ${arch}" 166 + if [ -n "$variant" ]; then 167 + ANNOTATE_CMD="${ANNOTATE_CMD} --variant ${variant}" 168 + fi 169 + 170 + echo " Annotating ${os}/${arch}${variant:+/$variant}..." 171 + eval "$ANNOTATE_CMD" 172 + done 46 173 echo "" 47 174 48 175 # Push the manifest list 49 176 echo ">>> Pushing multi-arch manifest..." 50 - docker manifest push "${TARGET_REGISTRY}:${TAG}" 177 + docker manifest push "${TARGET_REF}" 51 178 echo "" 52 179 53 180 echo "=== Migration complete! ===" 54 - echo "You can now pull: docker pull ${TARGET_REGISTRY}:${TAG}" 181 + echo "You can now pull: docker pull ${TARGET_REF}" 182 + echo "" 183 + echo "Migrated platforms:" 184 + for i in "${!PLATFORM_INFO[@]}"; do 185 + IFS='|' read -r os arch variant <<< "${PLATFORM_INFO[$i]}" 186 + echo " - ${os}/${arch}${variant:+/$variant}" 187 + done