···67 "atcr.io/pkg/appview/db"
8 "atcr.io/pkg/auth/oauth"
09 "github.com/bluesky-social/indigo/atproto/syntax"
10)
1112// LogoutHandler handles user logout with proper OAuth token revocation
13type LogoutHandler struct {
14- OAuthApp *oauth.App
15- Refresher *oauth.Refresher
16- SessionStore *db.SessionStore
17- OAuthStore *db.OAuthStore
18}
1920func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···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 slog.Warn("Failed to revoke OAuth tokens on PDS", "component", "logout", "did", uiSession.DID, "error", err)
43 } else {
44 slog.Info("Successfully revoked OAuth tokens on PDS", "component", "logout", "did", uiSession.DID)
45 }
46-47- // Invalidate refresher cache to clear local access tokens
48- h.Refresher.InvalidateSession(uiSession.DID)
49- slog.Info("Invalidated local OAuth cache", "component", "logout", "did", uiSession.DID)
5051 // 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 {
···67 "atcr.io/pkg/appview/db"
8 "atcr.io/pkg/auth/oauth"
9+ indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
10 "github.com/bluesky-social/indigo/atproto/syntax"
11)
1213// LogoutHandler handles user logout with proper OAuth token revocation
14type LogoutHandler struct {
15+ OAuthClientApp *indigooauth.ClientApp
16+ Refresher *oauth.Refresher
17+ SessionStore *db.SessionStore
18+ OAuthStore *db.OAuthStore
19}
2021func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···38 // Attempt to revoke OAuth tokens on PDS side
39 if uiSession.OAuthSessionID != "" {
40 // Call indigo's Logout to revoke tokens on PDS
41+ if err := h.OAuthClientApp.Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
42 // Log error but don't block logout - best effort revocation
43 slog.Warn("Failed to revoke OAuth tokens on PDS", "component", "logout", "did", uiSession.DID, "error", err)
44 } else {
45 slog.Info("Successfully revoked OAuth tokens on PDS", "component", "logout", "did", uiSession.DID)
46 }
00004748 // Delete OAuth session from database (cleanup, might already be done by Logout)
49 if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil {
+7-18
pkg/appview/middleware/registry.go
···6 "fmt"
7 "log/slog"
8 "strings"
9- "sync"
1011 "github.com/distribution/distribution/v3"
12 "github.com/distribution/distribution/v3/registry/api/errcode"
···69 defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
70 baseURL string // Base URL for error messages (e.g., "https://atcr.io")
71 testMode bool // If true, fallback to default hold when user's hold is unreachable
72- repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
73 refresher *oauth.Refresher // OAuth session manager (copied from global on init)
74 database storage.DatabaseMetrics // Metrics database (copied from global on init)
75 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
···224 // Example: "evan.jarrett.net/debian" -> store as "debian"
225 repositoryName := imageName
226227- // Cache key is DID + repository name
228- cacheKey := did + ":" + repositoryName
229-230- // Check cache first and update service token
231- if cached, ok := nr.repositories.Load(cacheKey); ok {
232- cachedRepo := cached.(*storage.RoutingRepository)
233- // Always update the service token even for cached repos (token may have been renewed)
234- cachedRepo.Ctx.ServiceToken = serviceToken
235- return cachedRepo, nil
236- }
237-238 // Create routing repository - routes manifests to ATProto, blobs to hold service
239 // The registry is stateless - no local storage is used
240 // Bundle all context into a single RegistryContext struct
000000241 registryCtx := &storage.RegistryContext{
242 DID: did,
243 Handle: handle,
···251 Refresher: nr.refresher,
252 ReadmeCache: nr.readmeCache,
253 }
254- routingRepo := storage.NewRoutingRepository(repo, registryCtx)
255256- // Cache the repository
257- nr.repositories.Store(cacheKey, routingRepo)
258-259- return routingRepo, nil
260}
261262// Repositories delegates to underlying namespace
···6 "fmt"
7 "log/slog"
8 "strings"
0910 "github.com/distribution/distribution/v3"
11 "github.com/distribution/distribution/v3/registry/api/errcode"
···68 defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
69 baseURL string // Base URL for error messages (e.g., "https://atcr.io")
70 testMode bool // If true, fallback to default hold when user's hold is unreachable
071 refresher *oauth.Refresher // OAuth session manager (copied from global on init)
72 database storage.DatabaseMetrics // Metrics database (copied from global on init)
73 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
···222 // Example: "evan.jarrett.net/debian" -> store as "debian"
223 repositoryName := imageName
22400000000000225 // Create routing repository - routes manifests to ATProto, blobs to hold service
226 // The registry is stateless - no local storage is used
227 // Bundle all context into a single RegistryContext struct
228+ //
229+ // NOTE: We create a fresh RoutingRepository on every request (no caching) because:
230+ // 1. Each layer upload is a separate HTTP request (possibly different process)
231+ // 2. OAuth sessions can be refreshed/invalidated between requests
232+ // 3. The refresher already caches sessions efficiently (in-memory + DB)
233+ // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
234 registryCtx := &storage.RegistryContext{
235 DID: did,
236 Handle: handle,
···244 Refresher: nr.refresher,
245 ReadmeCache: nr.readmeCache,
246 }
0247248+ return storage.NewRoutingRepository(repo, registryCtx), nil
000249}
250251// Repositories delegates to underlying namespace
···10 "time"
1112 "atcr.io/pkg/atproto"
013)
1415// UISessionStore is the interface for UI session management
16-// UISessionStore is defined in refresher.go to avoid duplication
1718// UserStore is the interface for user management
19type UserStore interface {
···2829// Server handles OAuth authorization for the AppView
30type Server struct {
31- app *App
32 refresher *Refresher
33 uiSessionStore UISessionStore
34 postAuthCallback PostAuthCallback
35}
3637// NewServer creates a new OAuth server
38-func NewServer(app *App) *Server {
39 return &Server{
40- app: app,
41 }
42}
43···74 slog.Debug("Starting OAuth flow", "handle", handle)
7576 // Start auth flow via indigo
77- authURL, err := s.app.StartAuthFlow(r.Context(), handle)
78 if err != nil {
79 slog.Error("Failed to start auth flow", "error", err, "handle", handle)
80···111 }
112113 // Process OAuth callback via indigo (handles state validation internally)
114- sessionData, err := s.app.ProcessCallback(r.Context(), r.URL.Query())
115 if err != nil {
116 s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err))
117 return
···129 type sessionCleaner interface {
130 DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error
131 }
132- if cleaner, ok := s.app.clientApp.Store.(sessionCleaner); ok {
133 if err := cleaner.DeleteOldSessionsForDID(r.Context(), did, sessionID); err != nil {
134 slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err)
135 // Non-fatal - log and continue
136 } else {
137 slog.Debug("Cleaned up old OAuth sessions", "did", did, "kept", sessionID)
138 }
139- }
140-141- // Invalidate cached session (if any) since we have a new session with new tokens
142- // This happens AFTER deleting old sessions from database, ensuring the cache
143- // will load the correct session when it's next accessed
144- if s.refresher != nil {
145- s.refresher.InvalidateSession(did)
146- slog.Debug("Invalidated cached session after creating new session", "did", did)
147 }
148149 // Look up identity (resolve DID to handle)
···10 "time"
1112 "atcr.io/pkg/atproto"
13+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
14)
1516// UISessionStore is the interface for UI session management
17+// UISessionStore is defined in client.go (session management section)
1819// UserStore is the interface for user management
20type UserStore interface {
···2930// Server handles OAuth authorization for the AppView
31type Server struct {
32+ clientApp *oauth.ClientApp
33 refresher *Refresher
34 uiSessionStore UISessionStore
35 postAuthCallback PostAuthCallback
36}
3738// NewServer creates a new OAuth server
39+func NewServer(clientApp *oauth.ClientApp) *Server {
40 return &Server{
41+ clientApp: clientApp,
42 }
43}
44···75 slog.Debug("Starting OAuth flow", "handle", handle)
7677 // Start auth flow via indigo
78+ authURL, err := s.clientApp.StartAuthFlow(r.Context(), handle)
79 if err != nil {
80 slog.Error("Failed to start auth flow", "error", err, "handle", handle)
81···112 }
113114 // Process OAuth callback via indigo (handles state validation internally)
115+ sessionData, err := s.clientApp.ProcessCallback(r.Context(), r.URL.Query())
116 if err != nil {
117 s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err))
118 return
···130 type sessionCleaner interface {
131 DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error
132 }
133+ if cleaner, ok := s.clientApp.Store.(sessionCleaner); ok {
134 if err := cleaner.DeleteOldSessionsForDID(r.Context(), did, sessionID); err != nil {
135 slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err)
136 // Non-fatal - log and continue
137 } else {
138 slog.Debug("Cleaned up old OAuth sessions", "did", did, "kept", sessionID)
139 }
00000000140 }
141142 // Look up identity (resolve DID to handle)