A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
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